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