- commit
- 0cc0f35
- parent
- 532c4ee
- author
- im_wower
- date
- 2026-03-22 21:54:39 +0800 CST
fix(conductor-daemon): stabilize exec failure result shape
4 files changed,
+133,
-6
+59,
-1
1@@ -1,6 +1,6 @@
2 import assert from "node:assert/strict";
3 import { mkdtempSync, rmSync } from "node:fs";
4-import { tmpdir } from "node:os";
5+import { homedir, tmpdir } from "node:os";
6 import { join } from "node:path";
7 import test from "node:test";
8
9@@ -216,6 +216,24 @@ function parseJsonBody(response) {
10 return JSON.parse(response.body);
11 }
12
13+async function withMockedPlatform(platform, callback) {
14+ const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
15+
16+ assert.ok(descriptor);
17+
18+ Object.defineProperty(process, "platform", {
19+ configurable: true,
20+ enumerable: descriptor.enumerable ?? true,
21+ value: platform
22+ });
23+
24+ try {
25+ return await callback();
26+ } finally {
27+ Object.defineProperty(process, "platform", descriptor);
28+ }
29+}
30+
31 function createWebSocketMessageQueue(socket) {
32 const messages = [];
33 const waiters = [];
34@@ -951,6 +969,46 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
35 }
36 });
37
38+test(
39+ "handleConductorHttpRequest normalizes exec failures that are blocked by macOS TCC preflight",
40+ { concurrency: false },
41+ async () => {
42+ const { repository, snapshot } = await createLocalApiFixture();
43+
44+ const execResponse = await withMockedPlatform("darwin", () =>
45+ handleConductorHttpRequest(
46+ {
47+ body: JSON.stringify({
48+ command: "pwd",
49+ cwd: join(homedir(), "Downloads", "nested"),
50+ timeoutMs: 2_000
51+ }),
52+ method: "POST",
53+ path: "/v1/exec"
54+ },
55+ {
56+ repository,
57+ snapshotLoader: () => snapshot
58+ }
59+ )
60+ );
61+
62+ assert.equal(execResponse.status, 200);
63+ const execPayload = parseJsonBody(execResponse);
64+ assert.equal(execPayload.data.ok, false);
65+ assert.equal(execPayload.data.operation, "exec");
66+ assert.equal(execPayload.data.error.code, "TCC_PERMISSION_DENIED");
67+ assert.equal(execPayload.data.result.stdout, "");
68+ assert.equal(execPayload.data.result.stderr, "");
69+ assert.equal(execPayload.data.result.exitCode, null);
70+ assert.equal(execPayload.data.result.signal, null);
71+ assert.equal(execPayload.data.result.durationMs, 0);
72+ assert.equal(execPayload.data.result.timedOut, false);
73+ assert.equal(typeof execPayload.data.result.startedAt, "string");
74+ assert.equal(typeof execPayload.data.result.finishedAt, "string");
75+ }
76+);
77+
78 test("ConductorRuntime serves health and migrated local API endpoints over HTTP", async () => {
79 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-"));
80 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-host-"));
+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@@ -413,6 +415,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@@ -1195,12 +1233,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@@ -73,6 +73,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
+33,
-0
1@@ -88,6 +88,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@@ -182,8 +203,20 @@ curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
30 "nodeBinary": "/opt/homebrew/bin/node",
31 "requiresFullDiskAccess": true
32 }
33+ },
34+ "result": {
35+ "stdout": "",
36+ "stderr": "",
37+ "exitCode": null,
38+ "signal": null,
39+ "durationMs": 0,
40+ "startedAt": "2026-03-22T09:10:00.000Z",
41+ "finishedAt": "2026-03-22T09:10:00.000Z",
42+ "timedOut": false
43 }
44 }
45 ```
46
47 当前这是基于命令字面量的快速预检,不会解析 shell 变量、命令替换或更复杂的间接路径展开。
48+
49+补充说明:底层 `@baa-conductor/host-ops` 包仍然返回原始 union;稳定的 `exec` 失败 `result` 骨架由 `conductor-daemon` 的 HTTP `/v1/exec` 适配层补齐。