- 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
+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 保护的用户目录,会在启动子进程前直接返回:
+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");
+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) {