baa-conductor

git clone 

commit
eab74d1
parent
41a4dc3
author
im_wower
date
2026-03-24 23:34:50 +0800 CST
Merge branch 'fix/exec-cwd-enoent-message'

# Conflicts:
#	packages/host-ops/src/index.test.js
3 files changed,  +142, -2
M docs/api/local-host-ops.md
+38, -0
 1@@ -36,6 +36,7 @@
 2 ## Input Semantics
 3 
 4 - `cwd`:可选字符串。省略时使用 `conductor-daemon` 进程当前工作目录。
 5+- `exec` 会在启动 shell 之前先校验 `cwd`:不存在时返回 `INVALID_INPUT` 和 `message: "exec.cwd does not exist: <cwd>"`;存在但不是目录时返回 `INVALID_INPUT` 和 `message: "exec.cwd is not a directory: <cwd>"`。两种情况都会在 `error.details.cwd` 里回传原始 `cwd`。
 6 - `path`:可为绝对路径;相对路径会相对 `cwd` 解析。
 7 - `timeoutMs`:仅 `exec` 使用。可选整数 `>= 0`,默认 `30000`;`0` 表示不启用超时。
 8 - `maxBufferBytes`:仅 `exec` 使用。可选整数 `> 0`,默认 `10485760`。
 9@@ -210,6 +211,43 @@ curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
10 
11 它们全部返回结构化 result union,不依赖 HTTP 层;HTTP 层只是把这份 union 包进 `data`。
12 
13+## Exec cwd Validation
14+
15+当 `POST /v1/exec` 传入不存在的 `cwd` 时,会在调用 `child_process.exec()` 之前直接返回:
16+
17+```json
18+{
19+  "ok": false,
20+  "operation": "exec",
21+  "input": {
22+    "command": "echo ok",
23+    "cwd": "/tmp/does-not-exist",
24+    "timeoutMs": 30000,
25+    "maxBufferBytes": 10485760
26+  },
27+  "error": {
28+    "code": "INVALID_INPUT",
29+    "message": "exec.cwd does not exist: /tmp/does-not-exist",
30+    "retryable": false,
31+    "details": {
32+      "cwd": "/tmp/does-not-exist"
33+    }
34+  },
35+  "result": {
36+    "stdout": "",
37+    "stderr": "",
38+    "exitCode": null,
39+    "signal": null,
40+    "durationMs": 0,
41+    "startedAt": null,
42+    "finishedAt": null,
43+    "timedOut": false
44+  }
45+}
46+```
47+
48+如果 `cwd` 指向的是文件而不是目录,返回同样的 `INVALID_INPUT` 结构,但 message 会改成 `exec.cwd is not a directory: <cwd>`。
49+
50 ## macOS TCC Fast-Fail
51 
52 如果 `exec` 在 macOS 上检测到 `cwd` 落在受 TCC 保护的用户目录内,会在启动子进程前直接返回:
M packages/host-ops/src/index.test.js
+58, -2
 1@@ -1,5 +1,5 @@
 2 import assert from "node:assert/strict";
 3-import { mkdtemp, readFile, realpath, rm } from "node:fs/promises";
 4+import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
 5 import { homedir, tmpdir } from "node:os";
 6 import { join } from "node:path";
 7 import test from "node:test";
 8@@ -192,7 +192,7 @@ test(
 9     const result = await withMockedPlatform("darwin", () =>
10       executeCommand({
11         command: "pwd",
12-        cwd: join(homedir(), "Downloads", "nested"),
13+        cwd: join(homedir(), "Downloads"),
14         timeoutMs: 2_000
15       })
16     );
17@@ -241,6 +241,62 @@ test(
18   }
19 );
20 
21+test(
22+  "executeCommand returns INVALID_INPUT when cwd does not exist",
23+  { concurrency: false },
24+  async () => {
25+    const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-missing-cwd-"));
26+    const missingCwd = join(directory, "does-not-exist");
27+
28+    try {
29+      const result = await executeCommand({
30+        command: "pwd",
31+        cwd: missingCwd,
32+        timeoutMs: 2_000
33+      });
34+
35+      assert.equal(result.ok, false);
36+      assert.equal(result.operation, "exec");
37+      assert.equal(result.error.code, "INVALID_INPUT");
38+      assert.equal(result.error.message, `exec.cwd does not exist: ${missingCwd}`);
39+      assert.equal(result.error.retryable, false);
40+      assert.equal(result.error.details?.cwd, missingCwd);
41+      assertEmptyExecResultShape(result.result);
42+    } finally {
43+      await rm(directory, { force: true, recursive: true });
44+    }
45+  }
46+);
47+
48+test(
49+  "executeCommand returns INVALID_INPUT when cwd is not a directory",
50+  { concurrency: false },
51+  async () => {
52+    const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-file-cwd-"));
53+    const fileCwd = join(directory, "cwd.txt");
54+
55+    try {
56+      await writeFile(fileCwd, "not a directory", "utf8");
57+
58+      const result = await executeCommand({
59+        command: "pwd",
60+        cwd: fileCwd,
61+        timeoutMs: 2_000
62+      });
63+
64+      assert.equal(result.ok, false);
65+      assert.equal(result.operation, "exec");
66+      assert.equal(result.error.code, "INVALID_INPUT");
67+      assert.equal(result.error.message, `exec.cwd is not a directory: ${fileCwd}`);
68+      assert.equal(result.error.retryable, false);
69+      assert.equal(result.error.details?.cwd, fileCwd);
70+      assertEmptyExecResultShape(result.result);
71+    } finally {
72+      await rm(directory, { force: true, recursive: true });
73+    }
74+  }
75+);
76+
77 test("writeTextFile and readTextFile roundtrip content with metadata", { concurrency: false }, async () => {
78   const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-"));
79   const targetFile = join(directory, "nested", "hello.txt");
M packages/host-ops/src/index.ts
+46, -0
 1@@ -428,6 +428,46 @@ function createMacOsTccPermissionDeniedResponse(
 2   );
 3 }
 4 
 5+function createInvalidExecCwdResponse(input: ExecOperationInput, message: string): ExecOperationFailure {
 6+  return createExecFailureResponse(
 7+    input,
 8+    createHostOpError("INVALID_INPUT", message, false, { cwd: input.cwd })
 9+  );
10+}
11+
12+function isDirectoryStat(value: unknown): boolean {
13+  const candidate = value as { isDirectory?: unknown };
14+
15+  return (
16+    typeof candidate.isDirectory === "function" &&
17+    (candidate.isDirectory as () => boolean)()
18+  );
19+}
20+
21+async function validateExecCwd(input: ExecOperationInput): Promise<ExecOperationFailure | null> {
22+  try {
23+    const cwdStats = await stat(input.cwd);
24+
25+    if (isDirectoryStat(cwdStats)) {
26+      return null;
27+    }
28+
29+    return createInvalidExecCwdResponse(input, `exec.cwd is not a directory: ${input.cwd}`);
30+  } catch (error) {
31+    const errorCode = getErrorCode(error);
32+
33+    if (errorCode === "ENOENT") {
34+      return createInvalidExecCwdResponse(input, `exec.cwd does not exist: ${input.cwd}`);
35+    }
36+
37+    if (errorCode === "ENOTDIR") {
38+      return createInvalidExecCwdResponse(input, `exec.cwd is not a directory: ${input.cwd}`);
39+    }
40+
41+    return null;
42+  }
43+}
44+
45 function getErrorLike(error: unknown): RuntimeErrorLike {
46   if (typeof error === "object" && error !== null) {
47     return error as RuntimeErrorLike;
48@@ -773,6 +813,12 @@ export async function executeCommand(request: ExecOperationRequest): Promise<Exe
49     return normalized.response;
50   }
51 
52+  const invalidCwdResponse = await validateExecCwd(normalized.input);
53+
54+  if (invalidCwdResponse !== null) {
55+    return invalidCwdResponse;
56+  }
57+
58   const macOsTccBlockMatch = findMacOsTccBlockMatch(normalized.input);
59 
60   if (macOsTccBlockMatch !== null) {