baa-conductor

git clone 

commit
284e9ac
parent
7dd8b89
author
im_wower
date
2026-03-22 22:30:08 +0800 CST
fix(host-ops): narrow TCC preflight to cwd
3 files changed,  +129, -112
M docs/api/local-host-ops.md
+26, -7
 1@@ -40,7 +40,7 @@
 2 - `timeoutMs`:仅 `exec` 使用。可选整数 `>= 0`,默认 `30000`;`0` 表示不启用超时。
 3 - `maxBufferBytes`:仅 `exec` 使用。可选整数 `> 0`,默认 `10485760`。
 4 - `exec` 子进程默认不会继承 `conductor-daemon` 的完整环境变量。当前在 macOS / Linux 只透传最小集合:`PATH`、`HOME`、`USER`、`LOGNAME`、`LANG`、`TERM`、`TMPDIR`;在 Windows 只透传启动 shell 和常见可执行文件解析所需的最小集合(如 `ComSpec`、`PATH`、`PATHEXT`、`SystemRoot`、`TEMP`、`TMP`、`USERNAME`、`USERPROFILE`、`WINDIR`)。因此 `BAA_SHARED_TOKEN`、`*_TOKEN`、`*_SECRET`、`*_KEY`、`*_PASSWORD` 等 daemon 自身环境变量不会默认暴露给 `exec`。
 5-- 在 macOS 上,`exec` 会对 `cwd` 和 `command` 里明显命中的 TCC 受保护目录做前置检查,避免后台进程访问 `Desktop` / `Documents` / `Downloads` 时无提示挂住 30 秒。当前覆盖 `~/Desktop`、`~/Documents`、`~/Downloads` 以及 `/Users/<user>/Desktop|Documents|Downloads` 这类直接字面量命中。
 6+- 在 macOS 上,`exec` 只会对显式传入的 `cwd` 做 TCC 受保护目录前置检查,避免后台进程把工作目录直接设到 `Desktop` / `Documents` / `Downloads` 时无提示挂住 30 秒。`command` 仍按 shell 字符串原样执行,这个预检不是安全边界,也不会尝试解析 `command` 里的变量、`cd`、命令替换或其他间接路径。
 7 - `createParents`:仅 `files/write` 使用。可选布尔值,默认 `true`;会递归创建缺失父目录。
 8 - `overwrite`:仅 `files/write` 使用。可选布尔值,默认 `true`;为 `false` 且目标文件已存在时返回 `FILE_ALREADY_EXISTS`。
 9 
10@@ -48,6 +48,25 @@
11 
12 包级返回 union 仍保持不变。
13 
14+`exec` 的失败返回现在也保证带完整的 `result` 结构,不再出现 `result: null` 或空对象:
15+
16+```json
17+{
18+  "result": {
19+    "stdout": "",
20+    "stderr": "",
21+    "exitCode": null,
22+    "signal": null,
23+    "durationMs": 0,
24+    "startedAt": null,
25+    "finishedAt": null,
26+    "timedOut": false
27+  }
28+}
29+```
30+
31+如果子进程已经启动,则 `result` 会继续返回实际采集到的 `stdout` / `stderr` / `durationMs` / 时间戳。
32+
33 成功返回:
34 
35 ```json
36@@ -193,24 +212,24 @@ curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
37 
38 ## macOS TCC Fast-Fail
39 
40-如果 `exec` 在 macOS 上检测到命令或 `cwd` 直接命中受 TCC 保护的用户目录,会在启动子进程前直接返回:
41+如果 `exec` 在 macOS 上检测到 `cwd` 落在受 TCC 保护的用户目录内,会在启动子进程前直接返回:
42 
43 ```json
44 {
45   "ok": false,
46   "operation": "exec",
47   "input": {
48-    "command": "ls ~/Desktop",
49-    "cwd": "/tmp",
50+    "command": "pwd",
51+    "cwd": "/Users/george/Desktop/project",
52     "timeoutMs": 2000,
53     "maxBufferBytes": 10485760
54   },
55   "error": {
56     "code": "TCC_PERMISSION_DENIED",
57-    "message": "Command references macOS TCC-protected path /Users/george/Desktop. Grant Full Disk Access to the Node.js binary running conductor-daemon (/opt/homebrew/bin/node) and retry.",
58+    "message": "Command cwd resolves inside macOS TCC-protected path /Users/george/Desktop. Grant Full Disk Access to the Node.js binary running conductor-daemon (/opt/homebrew/bin/node) and retry.",
59     "retryable": false,
60     "details": {
61-      "accessPoint": "command",
62+      "accessPoint": "cwd",
63       "protectedPath": "/Users/george/Desktop",
64       "nodeBinary": "/opt/homebrew/bin/node",
65       "requiresFullDiskAccess": true
66@@ -219,4 +238,4 @@ curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
67 }
68 ```
69 
70-当前这是基于命令字面量的快速预检,不会解析 shell 变量、命令替换或更复杂的间接路径展开。
71+当前这只是针对 `cwd` 的快速预检,用来减少已知的 TCC 挂起场景;真正是否允许访问仍由 macOS 自身权限决定。
M packages/host-ops/src/index.test.js
+34, -18
  1@@ -57,6 +57,19 @@ async function withPatchedEnv(patch, callback) {
  2   }
  3 }
  4 
  5+function assertEmptyExecResultShape(result) {
  6+  assert.deepEqual(result, {
  7+    stdout: "",
  8+    stderr: "",
  9+    exitCode: null,
 10+    signal: null,
 11+    durationMs: 0,
 12+    startedAt: null,
 13+    finishedAt: null,
 14+    timedOut: false
 15+  });
 16+}
 17+
 18 test("executeCommand returns structured stdout for a successful command", { concurrency: false }, async () => {
 19   const result = await executeCommand({
 20     command: "printf 'host-ops-smoke'",
 21@@ -84,6 +97,17 @@ test("executeCommand returns a structured failure for non-zero exit codes", { co
 22   assert.equal(result.result?.stderr, "broken");
 23 });
 24 
 25+test("executeCommand returns a complete empty exec result for INVALID_INPUT failures", { concurrency: false }, async () => {
 26+  for (const request of [{ command: ["echo", "hello"] }, { command: "" }]) {
 27+    const result = await executeCommand(request);
 28+
 29+    assert.equal(result.ok, false);
 30+    assert.equal(result.operation, "exec");
 31+    assert.equal(result.error.code, "INVALID_INPUT");
 32+    assertEmptyExecResultShape(result.result);
 33+  }
 34+});
 35+
 36 test(
 37   "executeCommand keeps enough environment for common shell commands",
 38   { concurrency: false },
 39@@ -102,7 +126,7 @@ test(
 40 
 41       const outputLines = result.result.stdout.trim().split("\n");
 42       assert.equal(outputLines[0], resolvedDirectory);
 43-      assert.match(outputLines[1], /^v\d+\./);
 44+      assert.match(outputLines[1], /^v\\d+\\./);
 45     } finally {
 46       await rm(directory, { force: true, recursive: true });
 47     }
 48@@ -121,7 +145,7 @@ test(
 49       },
 50       () =>
 51         executeCommand({
 52-          command: "node -e \"process.stdout.write(JSON.stringify(process.env))\"",
 53+          command: "node -e \\\"process.stdout.write(JSON.stringify(process.env))\\\"",
 54           timeoutMs: 2_000
 55         })
 56     );
 57@@ -145,26 +169,20 @@ test(
 58 );
 59 
 60 test(
 61-  "executeCommand fails fast for macOS TCC-protected command paths",
 62+  "executeCommand does not preflight-block macOS TCC-looking command strings",
 63   { concurrency: false },
 64   async () => {
 65     const result = await withMockedPlatform("darwin", () =>
 66       executeCommand({
 67-        command: "ls /Users/example/Desktop/project",
 68+        command: "printf '/Users/example/Desktop/project'",
 69         cwd: "/tmp",
 70         timeoutMs: 2_000
 71       })
 72     );
 73 
 74-    assert.equal(result.ok, false);
 75+    assert.equal(result.ok, true);
 76     assert.equal(result.operation, "exec");
 77-    assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
 78-    assert.equal(result.error.retryable, false);
 79-    assert.equal(result.error.details?.accessPoint, "command");
 80-    assert.equal(result.error.details?.protectedPath, "/Users/example/Desktop");
 81-    assert.equal(result.error.details?.requiresFullDiskAccess, true);
 82-    assert.match(result.error.message, /Full Disk Access/);
 83-    assert.equal(result.result, undefined);
 84+    assert.equal(result.result.stdout, "/Users/example/Desktop/project");
 85   }
 86 );
 87 
 88@@ -189,22 +207,20 @@ test(
 89 );
 90 
 91 test(
 92-  "executeCommand detects ~/Documents style macOS TCC-protected paths",
 93+  "executeCommand does not preflight-block ~/Documents style command strings",
 94   { concurrency: false },
 95   async () => {
 96     const result = await withMockedPlatform("darwin", () =>
 97       executeCommand({
 98-        command: "ls ~/Documents/project",
 99+        command: "printf '~/Documents/project'",
100         cwd: "/tmp",
101         timeoutMs: 2_000
102       })
103     );
104 
105-    assert.equal(result.ok, false);
106+    assert.equal(result.ok, true);
107     assert.equal(result.operation, "exec");
108-    assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
109-    assert.equal(result.error.details?.accessPoint, "command");
110-    assert.equal(result.error.details?.protectedPath, join(homedir(), "Documents"));
111+    assert.equal(result.result.stdout, "~/Documents/project");
112   }
113 );
114 
M packages/host-ops/src/index.ts
+69, -87
  1@@ -35,10 +35,6 @@ const WINDOWS_SAFE_EXEC_ENV_KEYS = [
  2   "USERPROFILE",
  3   "WINDIR"
  4 ] as const;
  5-const MACOS_TCC_ABSOLUTE_PATH_PATTERN =
  6-  /(^|[\s"'`=:(|&;<>])(\/Users\/[^/\s"'`]+\/(?:Desktop|Documents|Downloads))(?=$|[\/\s"'`),:|&;<>])/u;
  7-const MACOS_TCC_HOME_PATH_PATTERN =
  8-  /(^|[\s=:(|&;<>])(~\/(?:Desktop|Documents|Downloads))(?=$|[\/\s"'`),:|&;<>])/u;
  9 
 10 export type HostOperationName = (typeof HOST_OPERATION_NAMES)[number];
 11 export type HostOpErrorCode = (typeof HOST_OP_ERROR_CODES)[number];
 12@@ -85,9 +81,9 @@ export interface ExecOperationInput {
 13 export interface ExecOperationResult {
 14   durationMs: number;
 15   exitCode: number | null;
 16-  finishedAt: string;
 17+  finishedAt: string | null;
 18   signal: string | null;
 19-  startedAt: string;
 20+  startedAt: string | null;
 21   stderr: string;
 22   stdout: string;
 23   timedOut: boolean;
 24@@ -98,11 +94,10 @@ export type ExecOperationSuccess = HostOperationSuccess<
 25   ExecOperationInput,
 26   ExecOperationResult
 27 >;
 28-export type ExecOperationFailure = HostOperationFailure<
 29-  "exec",
 30-  ExecOperationInput,
 31-  ExecOperationResult
 32->;
 33+export interface ExecOperationFailure
 34+  extends HostOperationFailure<"exec", ExecOperationInput, ExecOperationResult> {
 35+  result: ExecOperationResult;
 36+}
 37 export type ExecOperationResponse = ExecOperationSuccess | ExecOperationFailure;
 38 
 39 export interface FileReadOperationRequest {
 40@@ -204,7 +199,7 @@ interface RuntimeErrorLike {
 41 
 42 interface MacOsTccBlockMatch {
 43   protectedPath: string;
 44-  source: "command" | "cwd";
 45+  source: "cwd";
 46 }
 47 
 48 interface NormalizedInputSuccess<TInput> {
 49@@ -212,14 +207,14 @@ interface NormalizedInputSuccess<TInput> {
 50   input: TInput;
 51 }
 52 
 53-interface NormalizedInputFailure<TOperation extends HostOperationName, TInput> {
 54+interface NormalizedInputFailure<TFailure> {
 55   ok: false;
 56-  response: HostOperationFailure<TOperation, TInput>;
 57+  response: TFailure;
 58 }
 59 
 60-type NormalizedInputResult<TOperation extends HostOperationName, TInput> =
 61+type NormalizedInputResult<TInput, TFailure> =
 62   | NormalizedInputSuccess<TInput>
 63-  | NormalizedInputFailure<TOperation, TInput>;
 64+  | NormalizedInputFailure<TFailure>;
 65 
 66 function getDefaultCwd(): string {
 67   if (typeof process === "object" && process !== null && typeof process.cwd === "function") {
 68@@ -286,6 +281,36 @@ function createHostOpError(
 69   return details === undefined ? { code, message, retryable } : { code, message, retryable, details };
 70 }
 71 
 72+function createEmptyExecResult(): ExecOperationResult {
 73+  return {
 74+    durationMs: 0,
 75+    exitCode: null,
 76+    finishedAt: null,
 77+    signal: null,
 78+    startedAt: null,
 79+    stderr: "",
 80+    stdout: "",
 81+    timedOut: false
 82+  };
 83+}
 84+
 85+function createExecFailureResponse(
 86+  input: ExecOperationInput,
 87+  error: HostOpError
 88+): ExecOperationFailure {
 89+  return {
 90+    ok: false,
 91+    operation: "exec",
 92+    input,
 93+    error,
 94+    result: createEmptyExecResult()
 95+  };
 96+}
 97+
 98+function normalizeExecCommandInput(command: unknown): string {
 99+  return typeof command === "string" ? command : "";
100+}
101+
102 function resolveOperationPath(pathValue: string, cwd: string): string {
103   return isAbsolute(pathValue) ? pathValue : resolve(cwd, pathValue);
104 }
105@@ -364,26 +389,6 @@ function findMacOsTccProtectedCwd(cwd: string, userHomeDirectory = homedir()): s
106   return null;
107 }
108 
109-function findMacOsTccProtectedCommandPath(
110-  command: string,
111-  userHomeDirectory = homedir()
112-): string | null {
113-  const absolutePathMatch = MACOS_TCC_ABSOLUTE_PATH_PATTERN.exec(command);
114-  const homePathMatch = MACOS_TCC_HOME_PATH_PATTERN.exec(command);
115-  const absolutePath = typeof absolutePathMatch?.[2] === "string" ? absolutePathMatch[2] : null;
116-  const homePath = typeof homePathMatch?.[2] === "string" ? homePathMatch[2] : null;
117-
118-  if (absolutePath === null && homePath === null) {
119-    return null;
120-  }
121-
122-  if (absolutePath !== null && (homePathMatch === null || absolutePathMatch!.index <= homePathMatch.index)) {
123-    return resolve(absolutePath);
124-  }
125-
126-  return resolve(userHomeDirectory, homePath!.slice(2));
127-}
128-
129 function findMacOsTccBlockMatch(input: ExecOperationInput): MacOsTccBlockMatch | null {
130   if (!isMacOs()) {
131     return null;
132@@ -398,16 +403,7 @@ function findMacOsTccBlockMatch(input: ExecOperationInput): MacOsTccBlockMatch |
133     };
134   }
135 
136-  const protectedCommandPath = findMacOsTccProtectedCommandPath(input.command);
137-
138-  if (protectedCommandPath === null) {
139-    return null;
140-  }
141-
142-  return {
143-    protectedPath: protectedCommandPath,
144-    source: "command"
145-  };
146+  return null;
147 }
148 
149 function createMacOsTccPermissionDeniedResponse(
150@@ -415,18 +411,12 @@ function createMacOsTccPermissionDeniedResponse(
151   blockMatch: MacOsTccBlockMatch
152 ): ExecOperationFailure {
153   const nodeBinary = getProcessExecPath();
154-  const blockedVia =
155-    blockMatch.source === "cwd"
156-      ? `Command cwd resolves inside macOS TCC-protected path ${blockMatch.protectedPath}.`
157-      : `Command references macOS TCC-protected path ${blockMatch.protectedPath}.`;
158 
159-  return {
160-    ok: false,
161-    operation: "exec",
162+  return createExecFailureResponse(
163     input,
164-    error: createHostOpError(
165+    createHostOpError(
166       "TCC_PERMISSION_DENIED",
167-      `${blockedVia} Grant Full Disk Access to the Node.js binary running conductor-daemon (${nodeBinary}) and retry.`,
168+      `Command cwd resolves inside macOS TCC-protected path ${blockMatch.protectedPath}. Grant Full Disk Access to the Node.js binary running conductor-daemon (${nodeBinary}) and retry.`,
169       false,
170       {
171         accessPoint: blockMatch.source,
172@@ -435,7 +425,7 @@ function createMacOsTccPermissionDeniedResponse(
173         requiresFullDiskAccess: true
174       }
175     )
176-  };
177+  );
178 }
179 
180 function getErrorLike(error: unknown): RuntimeErrorLike {
181@@ -468,23 +458,21 @@ function isRetryableFsErrorCode(code: string | undefined): boolean {
182 
183 function normalizeExecRequest(
184   request: ExecOperationRequest
185-): NormalizedInputResult<"exec", ExecOperationInput> {
186+): NormalizedInputResult<ExecOperationInput, ExecOperationFailure> {
187   const cwd = normalizeCwd(request.cwd);
188 
189   if (cwd === null) {
190     return {
191       ok: false,
192-      response: {
193-        ok: false,
194-        operation: "exec",
195-        input: {
196-          command: request.command,
197+      response: createExecFailureResponse(
198+        {
199+          command: normalizeExecCommandInput(request.command),
200           cwd: getDefaultCwd(),
201           maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
202           timeoutMs: request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
203         },
204-        error: createHostOpError("INVALID_INPUT", "exec.cwd must be a non-empty string when provided.", false)
205-      }
206+        createHostOpError("INVALID_INPUT", "exec.cwd must be a non-empty string when provided.", false)
207+      )
208     };
209   }
210 
211@@ -493,17 +481,15 @@ function normalizeExecRequest(
212   if (timeoutMs === null) {
213     return {
214       ok: false,
215-      response: {
216-        ok: false,
217-        operation: "exec",
218-        input: {
219-          command: request.command,
220+      response: createExecFailureResponse(
221+        {
222+          command: normalizeExecCommandInput(request.command),
223           cwd,
224           maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
225           timeoutMs: request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
226         },
227-        error: createHostOpError("INVALID_INPUT", "exec.timeoutMs must be an integer >= 0.", false)
228-      }
229+        createHostOpError("INVALID_INPUT", "exec.timeoutMs must be an integer >= 0.", false)
230+      )
231     };
232   }
233 
234@@ -515,34 +501,30 @@ function normalizeExecRequest(
235   if (maxBufferBytes === null) {
236     return {
237       ok: false,
238-      response: {
239-        ok: false,
240-        operation: "exec",
241-        input: {
242-          command: request.command,
243+      response: createExecFailureResponse(
244+        {
245+          command: normalizeExecCommandInput(request.command),
246           cwd,
247           maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
248           timeoutMs
249         },
250-        error: createHostOpError("INVALID_INPUT", "exec.maxBufferBytes must be an integer > 0.", false)
251-      }
252+        createHostOpError("INVALID_INPUT", "exec.maxBufferBytes must be an integer > 0.", false)
253+      )
254     };
255   }
256 
257   if (!isNonEmptyString(request.command)) {
258     return {
259       ok: false,
260-      response: {
261-        ok: false,
262-        operation: "exec",
263-        input: {
264-          command: typeof request.command === "string" ? request.command : "",
265+      response: createExecFailureResponse(
266+        {
267+          command: normalizeExecCommandInput(request.command),
268           cwd,
269           maxBufferBytes,
270           timeoutMs
271         },
272-        error: createHostOpError("INVALID_INPUT", "exec.command must be a non-empty string.", false)
273-      }
274+        createHostOpError("INVALID_INPUT", "exec.command must be a non-empty string.", false)
275+      )
276     };
277   }
278 
279@@ -559,7 +541,7 @@ function normalizeExecRequest(
280 
281 function normalizeReadRequest(
282   request: FileReadOperationRequest
283-): NormalizedInputResult<"files/read", FileReadOperationInput> {
284+): NormalizedInputResult<FileReadOperationInput, FileReadOperationFailure> {
285   const cwd = normalizeCwd(request.cwd);
286 
287   if (cwd === null) {
288@@ -624,7 +606,7 @@ function normalizeReadRequest(
289 
290 function normalizeWriteRequest(
291   request: FileWriteOperationRequest
292-): NormalizedInputResult<"files/write", FileWriteOperationInput> {
293+): NormalizedInputResult<FileWriteOperationInput, FileWriteOperationFailure> {
294   const cwd = normalizeCwd(request.cwd);
295   const createParents = normalizeBoolean(request.createParents, true);
296   const overwrite = normalizeBoolean(request.overwrite, true);