- commit
- 0d7bd28
- parent
- eab74d1
- author
- im_wower
- date
- 2026-03-24 23:35:55 +0800 CST
Merge branch 'fix/tcc-exec-result-shape' # Conflicts: # apps/conductor-daemon/src/index.test.js # docs/api/local-host-ops.md
4 files changed,
+107,
-6
+41,
-1
1@@ -1,7 +1,7 @@
2 import assert from "node:assert/strict";
3 import { createServer } from "node:http";
4 import { mkdtempSync, rmSync } from "node:fs";
5-import { tmpdir } from "node:os";
6+import { homedir, tmpdir } from "node:os";
7 import { join } from "node:path";
8 import test from "node:test";
9
10@@ -1738,6 +1738,46 @@ test("handleConductorHttpRequest returns a clear 503 for Claude browser actions
11 assert.equal(payload.error, "browser_bridge_unavailable");
12 });
13
14+test(
15+ "handleConductorHttpRequest normalizes exec failures that are blocked by macOS TCC preflight",
16+ { concurrency: false },
17+ async () => {
18+ const { repository, snapshot } = await createLocalApiFixture();
19+
20+ const execResponse = await withMockedPlatform("darwin", () =>
21+ handleConductorHttpRequest(
22+ {
23+ body: JSON.stringify({
24+ command: "pwd",
25+ cwd: join(homedir(), "Downloads", "nested"),
26+ timeoutMs: 2_000
27+ }),
28+ method: "POST",
29+ path: "/v1/exec"
30+ },
31+ {
32+ repository,
33+ snapshotLoader: () => snapshot
34+ }
35+ )
36+ );
37+
38+ assert.equal(execResponse.status, 200);
39+ const execPayload = parseJsonBody(execResponse);
40+ assert.equal(execPayload.data.ok, false);
41+ assert.equal(execPayload.data.operation, "exec");
42+ assert.equal(execPayload.data.error.code, "TCC_PERMISSION_DENIED");
43+ assert.equal(execPayload.data.result.stdout, "");
44+ assert.equal(execPayload.data.result.stderr, "");
45+ assert.equal(execPayload.data.result.exitCode, null);
46+ assert.equal(execPayload.data.result.signal, null);
47+ assert.equal(execPayload.data.result.durationMs, 0);
48+ assert.equal(execPayload.data.result.timedOut, false);
49+ assert.equal(typeof execPayload.data.result.startedAt, "string");
50+ assert.equal(typeof execPayload.data.result.finishedAt, "string");
51+ }
52+);
53+
54 test("ConductorRuntime serves health and migrated local API endpoints over HTTP", async () => {
55 const codexd = await startCodexdStubServer();
56 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-"));
+40,
-5
1@@ -19,6 +19,8 @@ import {
2 executeCommand,
3 readTextFile,
4 writeTextFile,
5+ type ExecOperationResponse,
6+ type ExecOperationResult,
7 type ExecOperationRequest,
8 type FileReadOperationRequest,
9 type FileWriteOperationRequest
10@@ -550,6 +552,42 @@ function buildExecOperationRequest(body: JsonObject): ExecOperationRequest {
11 };
12 }
13
14+function buildDefaultExecFailureResult(timestamp: string): ExecOperationResult {
15+ return {
16+ durationMs: 0,
17+ exitCode: null,
18+ finishedAt: timestamp,
19+ signal: null,
20+ startedAt: timestamp,
21+ stderr: "",
22+ stdout: "",
23+ timedOut: false
24+ };
25+}
26+
27+function normalizeExecOperationResponse(response: ExecOperationResponse): ExecOperationResponse {
28+ if (response.ok) {
29+ return response;
30+ }
31+
32+ const fallbackResult = buildDefaultExecFailureResult(new Date().toISOString());
33+ const result = response.result;
34+
35+ return {
36+ ...response,
37+ result: {
38+ durationMs: result?.durationMs ?? fallbackResult.durationMs,
39+ exitCode: result?.exitCode ?? fallbackResult.exitCode,
40+ finishedAt: result?.finishedAt ?? result?.startedAt ?? fallbackResult.finishedAt,
41+ signal: result?.signal ?? fallbackResult.signal,
42+ startedAt: result?.startedAt ?? fallbackResult.startedAt,
43+ stderr: result?.stderr ?? fallbackResult.stderr,
44+ stdout: result?.stdout ?? fallbackResult.stdout,
45+ timedOut: result?.timedOut ?? fallbackResult.timedOut
46+ }
47+ };
48+}
49+
50 function buildFileReadOperationRequest(body: JsonObject): FileReadOperationRequest {
51 return {
52 cwd: readBodyField(body, "cwd") as FileReadOperationRequest["cwd"],
53@@ -3148,12 +3186,9 @@ async function handleSystemMutation(
54
55 async function handleHostExec(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
56 const body = readBodyObject(context.request, true);
57+ const response = normalizeExecOperationResponse(await executeCommand(buildExecOperationRequest(body)));
58
59- return buildSuccessEnvelope(
60- context.requestId,
61- 200,
62- (await executeCommand(buildExecOperationRequest(body))) as unknown as JsonValue
63- );
64+ return buildSuccessEnvelope(context.requestId, 200, response as unknown as JsonValue);
65 }
66
67 async function handleHostFileRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
+1,
-0
1@@ -98,6 +98,7 @@
2 - `path`:可为绝对路径,也可相对 `cwd`
3 - `timeoutMs`:仅 `/v1/exec` 使用,默认 `30000`
4 - `overwrite`:仅 `/v1/files/write` 使用,默认 `true`
5+- `POST /v1/exec` 在成功和失败时都返回稳定的 `data.result`;失败时 AI caller 也可以直接依赖 `stdout`、`stderr`、`exitCode`、`signal`、`durationMs`、`startedAt`、`finishedAt`、`timedOut`
6
7 ### 低层诊断
8
+25,
-0
1@@ -118,6 +118,27 @@
2 }
3 ```
4
5+对于 `conductor-daemon` 的 HTTP `POST /v1/exec`,所有失败路径现在都返回稳定的 `result` 骨架。
6+也就是说,即使请求在 macOS TCC 快速预检或输入校验阶段就被拦截,`data.result` 里也至少会有这些字段:
7+
8+- `stdout`
9+- `stderr`
10+- `exitCode`
11+- `signal`
12+- `durationMs`
13+- `startedAt`
14+- `finishedAt`
15+- `timedOut`
16+
17+对于未真正启动子进程的提前失败,当前默认值为:
18+
19+- `stdout: ""`
20+- `stderr: ""`
21+- `exitCode: null`
22+- `signal: null`
23+- `durationMs: 0`
24+- `timedOut: false`
25+
26 ## HTTP Envelope
27
28 `conductor-daemon` 的 HTTP 返回外层仍然使用统一 envelope:
29@@ -287,3 +308,7 @@ curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
30 ```
31
32 当前这只是针对 `cwd` 的快速预检,用来减少已知的 TCC 挂起场景;真正是否允许访问仍由 macOS 自身权限决定。
33+当前这只是针对 `cwd` 的快速预检,用来减少已知的 TCC 挂起场景;真正是否允许访问仍由 macOS 自身权限决定。
34+它不会解析 shell 变量、命令替换或更复杂的间接路径展开。
35+
36+补充说明:底层 `@baa-conductor/host-ops` 包返回原始 union;如果经由 `conductor-daemon` HTTP `/v1/exec` 调用,适配层还会把失败 `result` 补齐为稳定骨架,并把 `startedAt` / `finishedAt` 归一化为字符串时间戳。