- 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
+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 自身权限决定。
+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
+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);