baa-conductor

git clone 

commit
f4a6df1
parent
532c4ee
author
im_wower
date
2026-03-22 21:56:27 +0800 CST
fix(host-ops): clarify invalid exec cwd errors
3 files changed,  +134, -2
M docs/api/local-host-ops.md
+28, -0
 1@@ -27,6 +27,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@@ -158,6 +159,33 @@ 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+}
36+```
37+
38+如果 `cwd` 指向的是文件而不是目录,返回同样的 `INVALID_INPUT` 结构,但 message 会改成 `exec.cwd is not a directory: <cwd>`。
39+
40 ## macOS TCC Fast-Fail
41 
42 如果 `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, rm } from "node:fs/promises";
 4+import { mkdtemp, readFile, 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@@ -87,7 +87,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@@ -137,6 +137,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+      assert.equal(result.result, undefined);
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+      assert.equal(result.result, undefined);
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
+48, -0
 1@@ -395,6 +395,48 @@ function createMacOsTccPermissionDeniedResponse(
 2   };
 3 }
 4 
 5+function createInvalidExecCwdResponse(input: ExecOperationInput, message: string): ExecOperationFailure {
 6+  return {
 7+    ok: false,
 8+    operation: "exec",
 9+    input,
10+    error: createHostOpError("INVALID_INPUT", message, false, { cwd: input.cwd })
11+  };
12+}
13+
14+function isDirectoryStat(value: unknown): boolean {
15+  const candidate = value as { isDirectory?: unknown };
16+
17+  return (
18+    typeof candidate.isDirectory === "function" &&
19+    (candidate.isDirectory as () => boolean)()
20+  );
21+}
22+
23+async function validateExecCwd(input: ExecOperationInput): Promise<ExecOperationFailure | null> {
24+  try {
25+    const cwdStats = await stat(input.cwd);
26+
27+    if (isDirectoryStat(cwdStats)) {
28+      return null;
29+    }
30+
31+    return createInvalidExecCwdResponse(input, `exec.cwd is not a directory: ${input.cwd}`);
32+  } catch (error) {
33+    const errorCode = getErrorCode(error);
34+
35+    if (errorCode === "ENOENT") {
36+      return createInvalidExecCwdResponse(input, `exec.cwd does not exist: ${input.cwd}`);
37+    }
38+
39+    if (errorCode === "ENOTDIR") {
40+      return createInvalidExecCwdResponse(input, `exec.cwd is not a directory: ${input.cwd}`);
41+    }
42+
43+    return null;
44+  }
45+}
46+
47 function getErrorLike(error: unknown): RuntimeErrorLike {
48   if (typeof error === "object" && error !== null) {
49     return error as RuntimeErrorLike;
50@@ -748,6 +790,12 @@ export async function executeCommand(request: ExecOperationRequest): Promise<Exe
51     return normalized.response;
52   }
53 
54+  const invalidCwdResponse = await validateExecCwd(normalized.input);
55+
56+  if (invalidCwdResponse !== null) {
57+    return invalidCwdResponse;
58+  }
59+
60   const macOsTccBlockMatch = findMacOsTccBlockMatch(normalized.input);
61 
62   if (macOsTccBlockMatch !== null) {