diff --git a/src/daemon/orchestrator.ts b/src/daemon/orchestrator.ts index 3a7edcb..7f36c33 100644 --- a/src/daemon/orchestrator.ts +++ b/src/daemon/orchestrator.ts @@ -31,26 +31,36 @@ export function workerEntry(): string[] { } /** - * spawn worker 时传给子进程的最小 env:只保留 PATH/HOME/LANG 与 ANTHROPIC_ / CLAUDE_ 前缀变量, - * 不把 daemon 全量 env(含 MAESTRO_ 控制变量、无关密钥)漏给独立 worker 进程。 - * MAESTRO_DATA_DIR 例外透传——worker 必须和 daemon 看同一个 runs/ 目录(job.json/outbox 路径一致)。 + * spawn worker 时传给子进程的 env:透传完整会话/系统环境,仅【剥离敏感密钥类变量】。 + * 为什么不用严格 allowlist:本机鉴权走 macOS keychain(无 API_KEY/凭证文件),而 Security/keychain + * 访问依赖一批会话变量(USER/LOGNAME/__CF_USER_TEXT_ENCODING/TMPDIR/XPC_* 等,均非密钥);只放 + * PATH/HOME/LANG 会让 keychain 读不到 → "Not logged in"。故改为黑名单:会话变量照常透传、保 keychain 可用, + * 同时剥掉 *_SECRET/*_TOKEN/*_KEY/AWS_/CF_ 等真正的密钥(鉴权用的 ANTHROPIC_/CLAUDE_ 始终保留)。 */ +const KEEP_AUTH = (k: string): boolean => k.startsWith('ANTHROPIC_') || k.startsWith('CLAUDE_') || k === 'CLAUDECODE'; +const SECRET_KEY_RE = /SECRET|PASSWORD|PASSWD|PRIVATE_KEY|CREDENTIAL|_TOKEN|API[_-]?KEY|ACCESS_KEY|^AWS_|^CF_|^GITHUB|^GH_|^NPM_/i; export function workerEnv(src: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { const out: NodeJS.ProcessEnv = {}; - for (const k of ['PATH', 'HOME', 'LANG', 'MAESTRO_DATA_DIR', 'MAESTRO_WORKER_CMD'] as const) { - if (src[k] !== undefined) out[k] = src[k]; - } for (const [k, v] of Object.entries(src)) { - if (v !== undefined && (k.startsWith('ANTHROPIC_') || k.startsWith('CLAUDE_'))) out[k] = v; + if (v === undefined) continue; + if (KEEP_AUTH(k)) { out[k] = v; continue; } // 鉴权变量始终保留(即便名含 TOKEN/KEY) + if (SECRET_KEY_RE.test(k)) continue; // 真正的密钥类:剥离 + out[k] = v; // 其余(会话/系统变量)透传 → keychain 可用 } return out; } -/** 默认 spawnWorker:detached + 脱离 stdio + 收敛 env,unref 后自存活(daemon 退出不杀 worker)。返回子进程 pid。 */ +/** + * 默认 spawnWorker:unref + 脱离 stdio + 收敛 env,返回子进程 pid。 + * 关键:【不 detached(不 setsid)】——保持与 daemon 同会话,worker 内起的 `claude` 才能沿用 + * daemon 的鉴权上下文(本机无 API_KEY/凭证文件,鉴权走 macOS keychain;detached 后新会话读不到 → Not logged in)。 + * 存活性不依赖 detached:unref 后 daemon 退出,worker 作为孤儿被 init/launchd 收养、继续运行(daemon 无控制终端, + * 不会有进程组 SIGHUP;重启用单 pid kill 不波及 worker)。 + */ function defaultSpawnWorker(runId: string): number { const [cmd, ...args] = workerEntry(); const child = spawn(cmd, [...args, runId], { - detached: true, + detached: false, stdio: 'ignore', env: workerEnv(), }); diff --git a/test/orchestrator.test.ts b/test/orchestrator.test.ts index 0320332..efc04ce 100644 --- a/test/orchestrator.test.ts +++ b/test/orchestrator.test.ts @@ -68,22 +68,29 @@ test('workerEntry:MAESTRO_WORKER_CMD 覆盖整条命令(空格分隔)', () else process.env.MAESTRO_WORKER_CMD = prev; }); -test('workerEnv:只透传 PATH/HOME/LANG + ANTHROPIC_/CLAUDE_ 前缀;丢弃无关与 MAESTRO_ 控制变量', () => { +test('workerEnv:透传会话/系统变量(keychain 可用),剥离密钥,保留鉴权变量', () => { const env = workerEnv({ PATH: '/usr/bin', HOME: '/home/u', LANG: 'en_US.UTF-8', - ANTHROPIC_API_KEY: 'sk-x', CLAUDE_CODE_FOO: 'y', + USER: 'wj', LOGNAME: 'wj', __CF_USER_TEXT_ENCODING: '0x1F5:0:0', TMPDIR: '/tmp', XPC_SERVICE_NAME: '0', + ANTHROPIC_API_KEY: 'sk-x', CLAUDE_CODE_OAUTH_TOKEN: 'oauth-x', CLAUDE_CODE_FOO: 'y', MAESTRO_ORCH_INTERVAL: '15', MAESTRO_DATA_DIR: '/data', - SOME_SECRET: 'leak', AWS_SECRET_ACCESS_KEY: 'nope', + SOME_SECRET: 'leak', AWS_SECRET_ACCESS_KEY: 'nope', GITHUB_TOKEN: 'gh', CF_API_TOKEN: 'cf', }); - assert.equal(env.PATH, '/usr/bin'); - assert.equal(env.HOME, '/home/u'); - assert.equal(env.LANG, 'en_US.UTF-8'); + // 会话/系统变量透传(macOS keychain/Security 访问需要它们) + for (const k of ['PATH', 'HOME', 'LANG', 'USER', 'LOGNAME', '__CF_USER_TEXT_ENCODING', 'TMPDIR', 'XPC_SERVICE_NAME']) { + assert.ok(env[k] !== undefined, `会话变量 ${k} 应透传`); + } + // 鉴权变量始终保留(即便名含 TOKEN/KEY) assert.equal(env.ANTHROPIC_API_KEY, 'sk-x'); + assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, 'oauth-x'); assert.equal(env.CLAUDE_CODE_FOO, 'y'); - assert.equal(env.MAESTRO_DATA_DIR, '/data'); // 例外:worker 须看同一 runs/ 目录 - assert.equal(env.MAESTRO_ORCH_INTERVAL, undefined, 'daemon 控制变量不下传'); - assert.equal(env.SOME_SECRET, undefined, '无关变量不下传'); - assert.equal(env.AWS_SECRET_ACCESS_KEY, undefined, '无关密钥不下传'); + assert.equal(env.MAESTRO_DATA_DIR, '/data'); // worker 须看同一 runs/ 目录 + assert.equal(env.MAESTRO_ORCH_INTERVAL, '15'); // 非密钥 → 透传(黑名单语义) + // 真正的密钥类被剥离 + assert.equal(env.SOME_SECRET, undefined, '*_SECRET 剥离'); + assert.equal(env.AWS_SECRET_ACCESS_KEY, undefined, 'AWS 密钥剥离'); + assert.equal(env.GITHUB_TOKEN, undefined, 'GITHUB_TOKEN 剥离'); + assert.equal(env.CF_API_TOKEN, undefined, 'CF token 剥离'); }); // ───────────────────────── tick 顺序:ingest + reaper 先于领取 ─────────────────────────