merge: maestro/tsk_oTFzOMeAU20D [tsk_oTFzOMeAU20D]
This commit is contained in:
@@ -113,3 +113,21 @@ exec_review accept 时自动 `--no-ff` 合并:若 defaultBranch 在主检出
|
||||
| `MAESTRO_MODEL_REVIEW_EASY` | reviewer Easy 模型覆盖 | `claude-sonnet-4-6` |
|
||||
| `MAESTRO_MODEL_REVIEW_MEDIUM` | reviewer Medium 模型覆盖 | `claude-opus-4-8` |
|
||||
| `MAESTRO_MODEL_REVIEW_HARD` | reviewer Hard 模型覆盖 | `claude-opus-4-8` |
|
||||
| `MAESTRO_SANDBOX` | worker OS 级沙箱总开关(`on`/`off`) | 关闭 |
|
||||
| `MAESTRO_SANDBOX_PROFILE` | 是否套 `sandbox-exec`(仅 macOS;`off` 则仅留 ulimit) | 开启 |
|
||||
| `MAESTRO_SANDBOX_NET` | 是否放行网络(SDK 需连 Anthropic;`off` 隔离) | 放行 |
|
||||
| `MAESTRO_SANDBOX_CPU` | CPU 时间上限(秒),`0`=不限 | `0` |
|
||||
| `MAESTRO_SANDBOX_MEM` | 虚拟内存上限(MB),`0`=不限(macOS 对 `-v` 支持不稳定) | `0` |
|
||||
| `MAESTRO_SANDBOX_NOFILE` | 打开文件描述符上限 | `4096` |
|
||||
| `MAESTRO_SANDBOX_FSIZE` | 单文件大小上限(MB),`0`=不限 | `0` |
|
||||
| `MAESTRO_SANDBOX_WRITE_EXTRA` | 额外放行写的绝对路径(冒号分隔) | 空 |
|
||||
|
||||
## worker 沙箱(OS 级硬隔离,可选)
|
||||
|
||||
进程隔离(独立 worker 进程 + env 收敛,密钥/DB/API 与 daemon 隔开)之上,再加一层 OS 级硬隔离,**默认关闭**,开启后对每个 worker 生效:
|
||||
|
||||
- **文件写围栏**(macOS `sandbox-exec`):基线 `allow default`(放行读 / exec / 网络 / mach——keychain 鉴权所需),仅对**写**收口,逐条放行 worker 必需的写目标:任务 worktree、主仓 `.git`/`node_modules`、`runs/<runId>`、`transcripts`、`$TMPDIR` 与常用工具缓存(`~/.npm`、`~/Library/Caches`、`GOCACHE` 等)。越界写(改系统、污染其它仓库、动 maestro 自身 DB)被内核拒绝。为什么不 `deny default` 全围栏:合法的 git/npm/go/node 需读海量系统/缓存路径,全围栏极易误伤——真正的破坏向量是「写」。
|
||||
- **资源上限**(`ulimit`):CPU 时间 / 虚拟内存 / 打开文件数 / 单文件大小,防失控 agent 拖垮机器;超限由内核杀进程,daemon 的 reaper 据此回收并重试/转 `needs_attention`。
|
||||
- 生效的策略会在 daemon 启动日志打一行;每个 run 的实际 profile 落在 `runs/<runId>/sandbox.sb`(清晰记录)。
|
||||
|
||||
**先在一个项目灰度**:开 `MAESTRO_SANDBOX=on` 后跑一轮含 `npm/go/git` 的真实任务,确认能跑通、越界写被拒、超限被杀且日志清晰,再逐步铺开。个别项目若有特殊写路径,用 `MAESTRO_SANDBOX_WRITE_EXTRA` 放行。
|
||||
|
||||
@@ -7,6 +7,7 @@ import { rankByScore } from '../model/scoring.js';
|
||||
import { branchFor, worktreeDirFor } from '../executor/worktree.js';
|
||||
import { writeJobSpec, isWorkerAlive, heartbeatAgeMs, type JobSpec } from '../executor/protocol.js';
|
||||
import { pickModel } from '../executor/models.js';
|
||||
import { prepareSandboxedSpawn, describeSandbox } from '../executor/sandbox.js';
|
||||
import { ingestAll, type IngestLogger } from './ingest.js';
|
||||
|
||||
/** 失败后默认最多自动重试次数(项目级 maxRetries 未设置时回退此值)。失败/退避策略本体在 store.failTaskAttempt。 */
|
||||
@@ -56,10 +57,16 @@ export function workerEnv(src: NodeJS.ProcessEnv = process.env): NodeJS.ProcessE
|
||||
* daemon 的鉴权上下文(本机无 API_KEY/凭证文件,鉴权走 macOS keychain;detached 后新会话读不到 → Not logged in)。
|
||||
* 存活性不依赖 detached:unref 后 daemon 退出,worker 作为孤儿被 init/launchd 收养、继续运行(daemon 无控制终端,
|
||||
* 不会有进程组 SIGHUP;重启用单 pid kill 不波及 worker)。
|
||||
*
|
||||
* OS 级沙箱(可选,默认关):MAESTRO_SANDBOX 开启时,prepareSandboxedSpawn 把命令包成
|
||||
* `sh -c 'ulimit…; exec sandbox-exec -f profile <原命令>'`——文件写围栏 + 资源上限。
|
||||
* 末尾 exec 链保证 pid 不变(仍是真 worker 的 pid),unref/同会话/keychain 等约束不受影响。
|
||||
*/
|
||||
function defaultSpawnWorker(runId: string): number {
|
||||
const [cmd, ...args] = workerEntry();
|
||||
const child = spawn(cmd, [...args, runId], {
|
||||
const base = [...workerEntry(), runId];
|
||||
const wrapped = prepareSandboxedSpawn(base, runId); // 沙箱关闭 → null,按原命令直接 spawn
|
||||
const [cmd, ...args] = wrapped ? [wrapped.cmd, ...wrapped.args] : base;
|
||||
const child = spawn(cmd, args, {
|
||||
detached: false,
|
||||
stdio: 'ignore',
|
||||
env: workerEnv(),
|
||||
@@ -242,5 +249,6 @@ export function startOrchestrator(
|
||||
const timer = setInterval(() => orch.tick(), intervalSec * 1000);
|
||||
timer.unref();
|
||||
app.log.info(`编排器已启用:每 ${intervalSec}s 一轮(ingest→回收→领取,autonomy≠manual 的 active 项目)`);
|
||||
app.log.info(describeSandbox()); // 记录当前生效的沙箱/资源上限策略
|
||||
return timer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// worker 的 OS 级硬隔离:macOS `sandbox-exec` profile(文件写围栏 + 可选网络隔离)+ `ulimit` 资源上限。
|
||||
//
|
||||
// 进程隔离(独立 worker 进程 + env 收敛)已在 orchestrator/worker 落地,本模块再加一层 OS 级硬隔离:
|
||||
// 1) sandbox-exec:限制 worker 只能【写】其 worktree + 主仓 .git/node_modules + runs/<runId> + transcripts
|
||||
// + 必要的临时/缓存目录,越界写被内核拒绝(读/exec/网络默认放行,避免误伤 git/npm/go/node)。
|
||||
// 2) ulimit:CPU 时间 / 虚拟内存 / 打开文件数 / 单文件大小上限,防失控 agent 拖垮机器。
|
||||
//
|
||||
// 设计取舍(为什么是「写围栏」而非「deny default 全围栏」):
|
||||
// - 合法的 git/npm/go/node 需要【读】海量系统/缓存/依赖路径(node 二进制、系统库、~/.npm、GOCACHE…),
|
||||
// deny-default 极易误伤且回归成本高(即任务里写的「难点」)。真正的破坏向量是【写】——改坏系统、
|
||||
// 污染其它仓库、动 maestro 自己的 DB。故基线 `allow default`,仅对 file-write* 收口后逐条放行。
|
||||
// - 网络默认放行:Agent SDK 需连 Anthropic;可经 MAESTRO_SANDBOX_NET=off 关闭(离线/本地任务)。
|
||||
//
|
||||
// 全部【可选】:默认关闭(MAESTRO_SANDBOX 未开启时 prepareSandboxedSpawn 返回 null,spawn 行为零变化),
|
||||
// 便于「先在一个项目灰度」。开启后 macOS 套 profile + ulimit;非 macOS 仅套 ulimit(profile 跳过)。
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { readJobSpec, runDir } from './protocol.js';
|
||||
|
||||
/** ulimit 资源上限。每项 0 表示不设(不下发对应 ulimit 行)。 */
|
||||
export interface ResourceLimits {
|
||||
/** ulimit -t:CPU 时间秒数(累计 CPU,非墙钟)。超限内核发 SIGXCPU 杀进程。 */
|
||||
cpuSec: number;
|
||||
/** ulimit -v:虚拟内存地址空间 KB。注意 macOS 对 -v 支持不稳定(常被忽略),Linux 生效。 */
|
||||
asKB: number;
|
||||
/** ulimit -n:可打开文件描述符数。 */
|
||||
nofile: number;
|
||||
/** ulimit -f:单个文件最大大小 KB(防失控写爆盘)。 */
|
||||
fsizeKB: number;
|
||||
}
|
||||
|
||||
/** 沙箱总配置(由 env 解析)。 */
|
||||
export interface SandboxConfig {
|
||||
/** 总开关(MAESTRO_SANDBOX)。关闭时 prepareSandboxedSpawn 直接返回 null。 */
|
||||
enabled: boolean;
|
||||
/** 是否套 sandbox-exec profile(仅 macOS;可经 MAESTRO_SANDBOX_PROFILE=off 单独关,仅留 ulimit)。 */
|
||||
profile: boolean;
|
||||
/** 是否放行网络(默认 true;SDK 需连 Anthropic)。 */
|
||||
allowNetwork: boolean;
|
||||
limits: ResourceLimits;
|
||||
/** 额外放行写的绝对路径(MAESTRO_SANDBOX_WRITE_EXTRA,冒号分隔),供个别项目按需放宽。 */
|
||||
extraWritePaths: string[];
|
||||
}
|
||||
|
||||
/** ulimit -n 默认值(macOS 默认仅 256,agent 起一堆子进程易顶满,给个宽裕但有界的值)。 */
|
||||
export const DEFAULT_NOFILE = 4096;
|
||||
|
||||
const TRUTHY = new Set(['1', 'on', 'true', 'yes', 'enable', 'enabled']);
|
||||
const FALSY = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
|
||||
|
||||
/** env 布尔解析:认得 on/off/true/false/1/0…,无法识别时回退 def。 */
|
||||
function isOn(v: string | undefined, def: boolean): boolean {
|
||||
if (v === undefined) return def;
|
||||
const s = v.trim().toLowerCase();
|
||||
if (TRUTHY.has(s)) return true;
|
||||
if (FALSY.has(s)) return false;
|
||||
return def;
|
||||
}
|
||||
|
||||
/** env 非负整数解析;空/非法/负数 → def。 */
|
||||
function intEnv(v: string | undefined, def: number): number {
|
||||
if (v === undefined || v.trim() === '') return def;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && Number.isInteger(n) && n >= 0 ? n : def;
|
||||
}
|
||||
|
||||
/** 数据根目录(与 protocol/worktree 同约定):<MAESTRO_DATA_DIR 或 ~/.maestro>。 */
|
||||
function dataDir(env: NodeJS.ProcessEnv): string {
|
||||
return env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
|
||||
}
|
||||
/** transcripts 目录(与 cc.ts transcriptDir 同约定)。 */
|
||||
function transcriptsDir(env: NodeJS.ProcessEnv): string {
|
||||
return join(dataDir(env), 'transcripts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 env 解析沙箱配置。
|
||||
* - MAESTRO_SANDBOX:总开关(默认关)。
|
||||
* - MAESTRO_SANDBOX_PROFILE:是否套 sandbox-exec(默认开,仅 macOS 有效)。
|
||||
* - MAESTRO_SANDBOX_NET:是否放行网络(默认放行)。
|
||||
* - MAESTRO_SANDBOX_CPU:CPU 秒(默认 0=不限;建议灰度时设 1800~3600 拦截死循环)。
|
||||
* - MAESTRO_SANDBOX_MEM:虚拟内存 MB(默认 0=不限;macOS 可能无效)。
|
||||
* - MAESTRO_SANDBOX_NOFILE:打开文件数(默认 4096)。
|
||||
* - MAESTRO_SANDBOX_FSIZE:单文件 MB(默认 0=不限)。
|
||||
* - MAESTRO_SANDBOX_WRITE_EXTRA:额外放行写的绝对路径(冒号分隔)。
|
||||
*/
|
||||
export function readSandboxConfig(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): SandboxConfig {
|
||||
const enabled = isOn(env.MAESTRO_SANDBOX, false);
|
||||
const profile = enabled && platform === 'darwin' && isOn(env.MAESTRO_SANDBOX_PROFILE, true);
|
||||
const allowNetwork = isOn(env.MAESTRO_SANDBOX_NET, true);
|
||||
const memMB = intEnv(env.MAESTRO_SANDBOX_MEM, 0);
|
||||
const fsizeMB = intEnv(env.MAESTRO_SANDBOX_FSIZE, 0);
|
||||
const limits: ResourceLimits = {
|
||||
cpuSec: intEnv(env.MAESTRO_SANDBOX_CPU, 0),
|
||||
asKB: memMB * 1024,
|
||||
nofile: intEnv(env.MAESTRO_SANDBOX_NOFILE, DEFAULT_NOFILE),
|
||||
fsizeKB: fsizeMB * 1024,
|
||||
};
|
||||
const extraWritePaths = (env.MAESTRO_SANDBOX_WRITE_EXTRA ?? '')
|
||||
.split(':').map((s) => s.trim()).filter(Boolean);
|
||||
return { enabled, profile, allowNetwork, limits, extraWritePaths };
|
||||
}
|
||||
|
||||
/** 把绝对路径转义进 sandbox profile 的字符串字面量(仅需转义 \ 与 ")。 */
|
||||
function esc(p: string): string {
|
||||
return p.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** buildSandboxProfile 的输入:worker 必需的写目标(绝对路径)。 */
|
||||
export interface SandboxProfileInput {
|
||||
/** worktree 父目录 <base>/<repo>:放行整目录,便于 worker 自建/清理本任务 worktree(含 worktreeDir)。 */
|
||||
worktreeParent: string;
|
||||
/** 主仓 .git:git worktree 共享对象库 / 注册项 / info/exclude / index.lock 等均写这里。 */
|
||||
repoGitDir: string;
|
||||
/** 主仓 node_modules:worktree 经软链复用它;放行使任务里的 npm 安装/缓存写不被拒。 */
|
||||
repoNodeModules: string;
|
||||
/** runs/<runId>:outbox/heartbeat/job/sandbox.sb。 */
|
||||
runDir: string;
|
||||
/** transcripts 目录:<runId>.jsonl 转录。 */
|
||||
transcriptsDir: string;
|
||||
/** 进程临时目录($TMPDIR)。 */
|
||||
tmpDir: string;
|
||||
/** 用户 HOME(推导 ~/.npm、~/Library/Caches、GOCACHE 等缓存)。 */
|
||||
homeDir: string;
|
||||
/** 是否放行网络。 */
|
||||
allowNetwork: boolean;
|
||||
/** 额外放行写的绝对路径。 */
|
||||
extraWritePaths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 macOS sandbox-exec profile 文本(SBPL)。
|
||||
* 策略:`(allow default)` 基线 → `(deny file-write*)` 收口 → 逐条 `(allow file-write* …)` 放行。
|
||||
* Apple sandbox 后匹配优先(last-match-wins),故越界写命中 deny 即被拒,放行目标被最后的 allow 覆盖。
|
||||
*/
|
||||
export function buildSandboxProfile(i: SandboxProfileInput): string {
|
||||
const writeSubpaths = [
|
||||
i.worktreeParent,
|
||||
i.repoGitDir,
|
||||
i.repoNodeModules,
|
||||
i.runDir,
|
||||
i.transcriptsDir,
|
||||
i.tmpDir,
|
||||
'/private/tmp', '/tmp',
|
||||
'/private/var/folders', '/var/folders',
|
||||
// 常用工具缓存(npm/go/git/claude)——这些通常已存在于开发机,放行其整目录的写
|
||||
join(i.homeDir, '.npm'),
|
||||
join(i.homeDir, '.cache'),
|
||||
join(i.homeDir, '.config'),
|
||||
join(i.homeDir, '.cargo'),
|
||||
join(i.homeDir, 'go'),
|
||||
join(i.homeDir, 'Library', 'Caches'),
|
||||
join(i.homeDir, '.claude'),
|
||||
...i.extraWritePaths,
|
||||
];
|
||||
const writeLiterals = [
|
||||
join(i.homeDir, '.gitconfig'),
|
||||
join(i.homeDir, '.npmrc'),
|
||||
'/dev/null', '/dev/zero', '/dev/stdout', '/dev/stderr',
|
||||
];
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('(version 1)');
|
||||
lines.push(';; maestro worker OS 级沙箱(macOS sandbox-exec)。');
|
||||
lines.push(';; 基线放行读/exec/网络/mach(keychain 鉴权所需),仅对【写】收口后逐条放行。');
|
||||
lines.push('(allow default)');
|
||||
lines.push('(deny file-write*)');
|
||||
lines.push('(allow file-write*');
|
||||
for (const p of writeSubpaths) lines.push(` (subpath "${esc(p)}")`);
|
||||
for (const p of writeLiterals) lines.push(` (literal "${esc(p)}")`);
|
||||
lines.push(' (subpath "/dev/fd")');
|
||||
lines.push(' (regex #"^/dev/tty"))');
|
||||
if (!i.allowNetwork) {
|
||||
lines.push(';; 网络隔离:SDK 将无法连 Anthropic,仅适用于离线/本地任务');
|
||||
lines.push('(deny network*)');
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** 由 limits 生成 ulimit 行(0 项跳过)。每行 `|| true` + 吞 stderr:某项不被平台支持(如 macOS 的 -v)不应中断启动。 */
|
||||
export function ulimitLines(l: ResourceLimits): string[] {
|
||||
const out: string[] = [];
|
||||
const add = (flag: string, val: number): void => {
|
||||
if (val > 0) out.push(`ulimit ${flag} ${val} 2>/dev/null || true`);
|
||||
};
|
||||
add('-t', l.cpuSec); // CPU 秒
|
||||
add('-v', l.asKB); // 虚拟内存 KB
|
||||
add('-n', l.nofile); // 打开文件数
|
||||
add('-f', l.fsizeKB); // 单文件 KB
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把基础命令包成「ulimit + 可选 sandbox-exec」的 `/bin/sh -c` 调用。
|
||||
* 经 sh 是为了下发 ulimit(spawn 本身设不了),末尾 `exec "$@"` 让 sh 替换为目标进程(pid 不变,
|
||||
* 保证 daemon 记录的 workerPid 即真 worker——sandbox-exec 同样 exec 替换,pid 链一路保留)。
|
||||
* 目标命令经 `"$@"` 位置参数传入,天然免转义(路径含空格也安全)。
|
||||
*/
|
||||
export function wrapCommand(
|
||||
baseCmd: string[],
|
||||
opts: { profilePath?: string; limits: ResourceLimits },
|
||||
): { cmd: string; args: string[] } {
|
||||
const target = opts.profilePath
|
||||
? ['sandbox-exec', '-f', opts.profilePath, ...baseCmd]
|
||||
: [...baseCmd];
|
||||
const script = [...ulimitLines(opts.limits), 'exec "$@"'].join('\n') + '\n';
|
||||
// sh -c <script> <$0> <$1...>:$0=标签,$@=target,exec "$@" 执行目标
|
||||
return { cmd: '/bin/sh', args: ['-c', script, 'maestro-worker', ...target] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为某个 run 准备沙箱化的 spawn 命令。沙箱关闭 → 返回 null(调用方按原始命令直接 spawn)。
|
||||
* 开启 → 读 job.json 得 worktree/repo,预建必要父目录(避免沙箱内因父目录不可写而建子目录失败),
|
||||
* macOS 写 runs/<runId>/sandbox.sb 并套 sandbox-exec,最后叠加 ulimit,返回包装后的 {cmd,args}。
|
||||
*/
|
||||
export function prepareSandboxedSpawn(
|
||||
baseCmd: string[],
|
||||
runId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): { cmd: string; args: string[] } | null {
|
||||
const cfg = readSandboxConfig(env);
|
||||
if (!cfg.enabled) return null;
|
||||
|
||||
const job = readJobSpec(runId);
|
||||
const worktreeDir = job.worktreeDir;
|
||||
const worktreeParent = dirname(worktreeDir);
|
||||
const repoGitDir = join(job.project.repoPath, '.git');
|
||||
const repoNodeModules = join(job.project.repoPath, 'node_modules');
|
||||
const rd = runDir(runId);
|
||||
const tDir = transcriptsDir(env);
|
||||
const home = env.HOME ?? homedir();
|
||||
const tmp = env.TMPDIR ?? '/tmp';
|
||||
|
||||
// 预建父目录:沙箱只放行这些目录【内部】的写,不放行其父级;提前建好,worker 内的
|
||||
// mkdirSync(recursive) / git worktree add / 写转录就不会因创建中间目录而被拒。
|
||||
mkdirSync(worktreeParent, { recursive: true });
|
||||
mkdirSync(tDir, { recursive: true });
|
||||
mkdirSync(rd, { recursive: true });
|
||||
|
||||
let profilePath: string | undefined;
|
||||
if (cfg.profile) {
|
||||
const profile = buildSandboxProfile({
|
||||
worktreeParent,
|
||||
repoGitDir,
|
||||
repoNodeModules,
|
||||
runDir: rd,
|
||||
transcriptsDir: tDir,
|
||||
tmpDir: tmp,
|
||||
homeDir: home,
|
||||
allowNetwork: cfg.allowNetwork,
|
||||
extraWritePaths: cfg.extraWritePaths,
|
||||
});
|
||||
profilePath = join(rd, 'sandbox.sb');
|
||||
writeFileSync(profilePath, profile);
|
||||
}
|
||||
|
||||
return wrapCommand(baseCmd, { profilePath, limits: cfg.limits });
|
||||
}
|
||||
|
||||
/** 人读的沙箱状态摘要(daemon 启动时打一行,作为「当前生效策略」的清晰记录)。 */
|
||||
export function describeSandbox(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string {
|
||||
const c = readSandboxConfig(env, platform);
|
||||
if (!c.enabled) return '沙箱:关闭(MAESTRO_SANDBOX 未开启)';
|
||||
const lims: string[] = [];
|
||||
if (c.limits.cpuSec) lims.push(`cpu=${c.limits.cpuSec}s`);
|
||||
if (c.limits.asKB) lims.push(`mem=${Math.round(c.limits.asKB / 1024)}MB`);
|
||||
if (c.limits.nofile) lims.push(`nofile=${c.limits.nofile}`);
|
||||
if (c.limits.fsizeKB) lims.push(`fsize=${Math.round(c.limits.fsizeKB / 1024)}MB`);
|
||||
const profile = c.profile
|
||||
? 'sandbox-exec'
|
||||
: (platform === 'darwin' ? 'profile=off' : `无profile(${platform})`);
|
||||
const net = c.allowNetwork ? '网络放行' : '网络隔离';
|
||||
return `沙箱:启用(${profile},${net},ulimit: ${lims.join(' ') || '无'})`;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
readSandboxConfig,
|
||||
buildSandboxProfile,
|
||||
ulimitLines,
|
||||
wrapCommand,
|
||||
prepareSandboxedSpawn,
|
||||
describeSandbox,
|
||||
DEFAULT_NOFILE,
|
||||
type SandboxProfileInput,
|
||||
} from '../src/executor/sandbox.js';
|
||||
import { writeJobSpec, runDir } from '../src/executor/protocol.js';
|
||||
import type { JobSpec } from '../src/executor/protocol.js';
|
||||
import type { Project, Task } from '../src/model/types.js';
|
||||
|
||||
// ───────────────────────── readSandboxConfig ─────────────────────────
|
||||
|
||||
test('readSandboxConfig:默认关闭,spawn 行为零变化', () => {
|
||||
const c = readSandboxConfig({}, 'darwin');
|
||||
assert.equal(c.enabled, false);
|
||||
assert.equal(c.profile, false);
|
||||
});
|
||||
|
||||
test('readSandboxConfig:MAESTRO_SANDBOX 开启 → enabled;macOS 默认套 profile', () => {
|
||||
const c = readSandboxConfig({ MAESTRO_SANDBOX: 'on' }, 'darwin');
|
||||
assert.equal(c.enabled, true);
|
||||
assert.equal(c.profile, true);
|
||||
assert.equal(c.allowNetwork, true, '默认放行网络(SDK 连 Anthropic)');
|
||||
assert.equal(c.limits.nofile, DEFAULT_NOFILE);
|
||||
assert.equal(c.limits.cpuSec, 0, '默认不限 CPU');
|
||||
});
|
||||
|
||||
test('readSandboxConfig:非 macOS 不套 profile(仅 ulimit)', () => {
|
||||
const c = readSandboxConfig({ MAESTRO_SANDBOX: '1' }, 'linux');
|
||||
assert.equal(c.enabled, true);
|
||||
assert.equal(c.profile, false);
|
||||
});
|
||||
|
||||
test('readSandboxConfig:MAESTRO_SANDBOX_PROFILE=off 单独关 profile,保留 ulimit', () => {
|
||||
const c = readSandboxConfig({ MAESTRO_SANDBOX: '1', MAESTRO_SANDBOX_PROFILE: 'off' }, 'darwin');
|
||||
assert.equal(c.enabled, true);
|
||||
assert.equal(c.profile, false);
|
||||
});
|
||||
|
||||
test('readSandboxConfig:资源上限从 env 解析(MB→KB),网络可关', () => {
|
||||
const c = readSandboxConfig({
|
||||
MAESTRO_SANDBOX: 'true',
|
||||
MAESTRO_SANDBOX_CPU: '1800',
|
||||
MAESTRO_SANDBOX_MEM: '2048',
|
||||
MAESTRO_SANDBOX_NOFILE: '1024',
|
||||
MAESTRO_SANDBOX_FSIZE: '512',
|
||||
MAESTRO_SANDBOX_NET: 'off',
|
||||
}, 'darwin');
|
||||
assert.equal(c.limits.cpuSec, 1800);
|
||||
assert.equal(c.limits.asKB, 2048 * 1024);
|
||||
assert.equal(c.limits.nofile, 1024);
|
||||
assert.equal(c.limits.fsizeKB, 512 * 1024);
|
||||
assert.equal(c.allowNetwork, false);
|
||||
});
|
||||
|
||||
test('readSandboxConfig:非法/负数限额回退默认', () => {
|
||||
const c = readSandboxConfig({
|
||||
MAESTRO_SANDBOX: 'on', MAESTRO_SANDBOX_CPU: 'abc', MAESTRO_SANDBOX_NOFILE: '-5',
|
||||
}, 'darwin');
|
||||
assert.equal(c.limits.cpuSec, 0);
|
||||
assert.equal(c.limits.nofile, DEFAULT_NOFILE);
|
||||
});
|
||||
|
||||
test('readSandboxConfig:额外写路径按冒号分隔', () => {
|
||||
const c = readSandboxConfig({ MAESTRO_SANDBOX: 'on', MAESTRO_SANDBOX_WRITE_EXTRA: '/a/b: /c/d :' }, 'darwin');
|
||||
assert.deepEqual(c.extraWritePaths, ['/a/b', '/c/d']);
|
||||
});
|
||||
|
||||
// ───────────────────────── buildSandboxProfile ─────────────────────────
|
||||
|
||||
function profileInput(over: Partial<SandboxProfileInput> = {}): SandboxProfileInput {
|
||||
return {
|
||||
worktreeParent: '/data/worktrees/repo',
|
||||
repoGitDir: '/proj/repo/.git',
|
||||
repoNodeModules: '/proj/repo/node_modules',
|
||||
runDir: '/data/runs/run1',
|
||||
transcriptsDir: '/data/transcripts',
|
||||
tmpDir: '/tmp/x',
|
||||
homeDir: '/Users/u',
|
||||
allowNetwork: true,
|
||||
extraWritePaths: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildSandboxProfile:allow default + 写收口 + 逐条放行', () => {
|
||||
const p = buildSandboxProfile(profileInput());
|
||||
assert.match(p, /^\(version 1\)/m);
|
||||
assert.match(p, /\(allow default\)/);
|
||||
assert.match(p, /\(deny file-write\*\)/);
|
||||
// 关键写目标都在
|
||||
assert.match(p, /\(subpath "\/data\/worktrees\/repo"\)/);
|
||||
assert.match(p, /\(subpath "\/proj\/repo\/\.git"\)/);
|
||||
assert.match(p, /\(subpath "\/proj\/repo\/node_modules"\)/);
|
||||
assert.match(p, /\(subpath "\/data\/runs\/run1"\)/);
|
||||
assert.match(p, /\(subpath "\/data\/transcripts"\)/);
|
||||
assert.match(p, /\(subpath "\/tmp\/x"\)/);
|
||||
// home 缓存
|
||||
assert.match(p, /\(subpath "\/Users\/u\/\.npm"\)/);
|
||||
assert.match(p, /\(subpath "\/Users\/u\/Library\/Caches"\)/);
|
||||
});
|
||||
|
||||
test('buildSandboxProfile:deny file-write* 出现在第一条 allow file-write* 之前(后匹配优先才正确)', () => {
|
||||
const p = buildSandboxProfile(profileInput());
|
||||
assert.ok(p.indexOf('(deny file-write*)') < p.indexOf('(allow file-write*'), 'deny 须在放行之前');
|
||||
});
|
||||
|
||||
test('buildSandboxProfile:默认放行网络(无 deny network*);allowNetwork=false 时隔离', () => {
|
||||
assert.doesNotMatch(buildSandboxProfile(profileInput({ allowNetwork: true })), /deny network/);
|
||||
assert.match(buildSandboxProfile(profileInput({ allowNetwork: false })), /\(deny network\*\)/);
|
||||
});
|
||||
|
||||
test('buildSandboxProfile:额外写路径被纳入;含特殊字符的路径被转义', () => {
|
||||
const p = buildSandboxProfile(profileInput({ extraWritePaths: ['/extra/one'], homeDir: '/U/a"b\\c' }));
|
||||
assert.match(p, /\(subpath "\/extra\/one"\)/);
|
||||
assert.match(p, /\\"b\\\\c/); // " → \" 和 \ → \\
|
||||
});
|
||||
|
||||
// ───────────────────────── ulimitLines ─────────────────────────
|
||||
|
||||
test('ulimitLines:0 项跳过,非 0 项下发且容错(|| true)', () => {
|
||||
const lines = ulimitLines({ cpuSec: 1800, asKB: 0, nofile: 4096, fsizeKB: 0 });
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0], /^ulimit -t 1800 2>\/dev\/null \|\| true$/);
|
||||
assert.match(lines[1], /^ulimit -n 4096 2>\/dev\/null \|\| true$/);
|
||||
});
|
||||
|
||||
test('ulimitLines:全 0 → 空', () => {
|
||||
assert.deepEqual(ulimitLines({ cpuSec: 0, asKB: 0, nofile: 0, fsizeKB: 0 }), []);
|
||||
});
|
||||
|
||||
// ───────────────────────── wrapCommand ─────────────────────────
|
||||
|
||||
test('wrapCommand:套 profile → sh -c,含 sandbox-exec 与 ulimit 与 exec "$@"', () => {
|
||||
const { cmd, args } = wrapCommand(['node', '/w/worker.js', 'run1'], {
|
||||
profilePath: '/data/runs/run1/sandbox.sb',
|
||||
limits: { cpuSec: 600, asKB: 0, nofile: 4096, fsizeKB: 0 },
|
||||
});
|
||||
assert.equal(cmd, '/bin/sh');
|
||||
assert.equal(args[0], '-c');
|
||||
const script = args[1];
|
||||
assert.match(script, /ulimit -t 600/);
|
||||
assert.match(script, /ulimit -n 4096/);
|
||||
assert.match(script, /exec "\$@"\n$/);
|
||||
// 位置参数:$0 标签 + 目标命令
|
||||
assert.deepEqual(args.slice(2), [
|
||||
'maestro-worker', 'sandbox-exec', '-f', '/data/runs/run1/sandbox.sb', 'node', '/w/worker.js', 'run1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('wrapCommand:无 profile(非 macOS)→ 仅 ulimit + 原命令', () => {
|
||||
const { cmd, args } = wrapCommand(['node', '/w/worker.js', 'run1'], {
|
||||
profilePath: undefined,
|
||||
limits: { cpuSec: 0, asKB: 0, nofile: 4096, fsizeKB: 0 },
|
||||
});
|
||||
assert.equal(cmd, '/bin/sh');
|
||||
assert.deepEqual(args.slice(2), ['maestro-worker', 'node', '/w/worker.js', 'run1']);
|
||||
assert.doesNotMatch(args[1], /sandbox-exec/);
|
||||
});
|
||||
|
||||
// ───────────────────────── prepareSandboxedSpawn ─────────────────────────
|
||||
|
||||
function fakeJob(runId: string, dataDir: string, repoPath: string): JobSpec {
|
||||
const task = { id: 'tsk_x', title: 't', status: 'executing' } as unknown as Task;
|
||||
const project = { id: 'prj_x', repoPath, defaultBranch: 'main' } as unknown as Project;
|
||||
return {
|
||||
runId,
|
||||
task,
|
||||
project,
|
||||
worktreeDir: join(dataDir, 'worktrees', 'repo', 'tsk_x'),
|
||||
branch: 'maestro/tsk_x',
|
||||
};
|
||||
}
|
||||
|
||||
test('prepareSandboxedSpawn:关闭 → null(原命令直接 spawn)', () => {
|
||||
const out = prepareSandboxedSpawn(['node', 'w.js', 'r'], 'r', {});
|
||||
assert.equal(out, null);
|
||||
});
|
||||
|
||||
test('prepareSandboxedSpawn:开启 → 读 job、预建父目录、(macOS)写 profile 并包命令', () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-sbx-'));
|
||||
try {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
MAESTRO_SANDBOX: 'on',
|
||||
MAESTRO_DATA_DIR: dataDir,
|
||||
HOME: dataDir,
|
||||
TMPDIR: join(dataDir, 'tmp'),
|
||||
};
|
||||
const runId = 'run_sbx';
|
||||
const job = fakeJob(runId, dataDir, join(dataDir, 'repo'));
|
||||
// writeJobSpec / runDir 走 MAESTRO_DATA_DIR
|
||||
const prev = process.env.MAESTRO_DATA_DIR;
|
||||
process.env.MAESTRO_DATA_DIR = dataDir;
|
||||
try {
|
||||
writeJobSpec(job);
|
||||
const out = prepareSandboxedSpawn(['node', 'w.js', runId], runId, env);
|
||||
assert.ok(out);
|
||||
assert.equal(out!.cmd, '/bin/sh');
|
||||
// 预建的父目录
|
||||
assert.ok(existsSync(join(dataDir, 'worktrees', 'repo')), '预建 worktree 父目录');
|
||||
assert.ok(existsSync(join(dataDir, 'transcripts')), '预建 transcripts 目录');
|
||||
|
||||
const onDarwin = process.platform === 'darwin';
|
||||
const profilePath = join(runDir(runId), 'sandbox.sb');
|
||||
if (onDarwin) {
|
||||
assert.ok(existsSync(profilePath), 'macOS 应落 sandbox.sb');
|
||||
const prof = readFileSync(profilePath, 'utf8');
|
||||
assert.match(prof, /\(deny file-write\*\)/);
|
||||
// 放行的是 worktree 父目录(含本任务 worktree),以及 runs/<runId>
|
||||
assert.ok(prof.includes(`(subpath "${join(dataDir, 'worktrees', 'repo')}")`), 'profile 含 worktree 父目录');
|
||||
assert.ok(prof.includes(`(subpath "${runDir(runId)}")`), 'profile 含 runs/<runId>');
|
||||
assert.ok(out!.args.includes('sandbox-exec'));
|
||||
} else {
|
||||
assert.ok(!out!.args.includes('sandbox-exec'), '非 macOS 不套 profile');
|
||||
}
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.MAESTRO_DATA_DIR; else process.env.MAESTRO_DATA_DIR = prev;
|
||||
}
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────── describeSandbox ─────────────────────────
|
||||
|
||||
test('describeSandbox:关闭/开启的人读摘要', () => {
|
||||
assert.match(describeSandbox({}, 'darwin'), /关闭/);
|
||||
const s = describeSandbox({ MAESTRO_SANDBOX: 'on', MAESTRO_SANDBOX_CPU: '1800' }, 'darwin');
|
||||
assert.match(s, /启用/);
|
||||
assert.match(s, /sandbox-exec/);
|
||||
assert.match(s, /cpu=1800s/);
|
||||
assert.match(s, /nofile=4096/);
|
||||
});
|
||||
Reference in New Issue
Block a user