baa-conductor

git clone 

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
M apps/conductor-daemon/src/index.test.js
+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-"));
M apps/conductor-daemon/src/local-api.ts
+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> {
M docs/api/control-interfaces.md
+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 
M docs/api/local-host-ops.md
+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` 适配层补齐。