- commit
- 62ac782
- parent
- 77573ae
- author
- im_wower
- date
- 2026-03-22 23:31:27 +0800 CST
fix(host-ops): secure and normalize local host operations
9 files changed,
+867,
-243
+9,
-3
1@@ -41,7 +41,7 @@
2 - `GET /describe/business`
3 - 或 `GET /describe/control`
4 4. 如有需要,再调 `GET /v1/capabilities`
5-5. 完成能力感知后,再执行业务查询或控制动作
6+5. 完成能力感知后,再执行业务查询或控制动作;如果要调用 `/v1/exec` 或 `/v1/files/*`,必须带 `Authorization: Bearer <BAA_SHARED_TOKEN>`
7
8 ## 当前目录结构
9
10@@ -96,8 +96,8 @@ docs/
11
12 | 面 | 地址 | 定位 | 说明 |
13 | --- | --- | --- | --- |
14-| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write` |
15-| public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317` |
16+| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
17+| public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317`;`/v1/exec` 和 `/v1/files/*` 不再允许匿名调用 |
18 | local status view | `http://100.71.210.78:4318` | 本地只读观察面 | 迁移期保留,不是主控制面 |
19
20 legacy 兼容说明:
21@@ -137,3 +137,9 @@ legacy 兼容说明:
22 - `/v1/exec`
23 - `/v1/files/read`
24 - `/v1/files/write`
25+
26+这些 host-ops 路由统一要求:
27+
28+- `Authorization: Bearer <BAA_SHARED_TOKEN>`
29+- token 来自 daemon 启动时配置的 `BAA_SHARED_TOKEN`
30+- 缺少或错误 token 时直接返回 `401` JSON 错误
+172,
-85
1@@ -44,6 +44,7 @@ function createLeaseResult({
2 }
3
4 async function createLocalApiFixture() {
5+ const sharedToken = "local-shared-token";
6 const controlPlane = new ConductorLocalControlPlane({
7 databasePath: ":memory:"
8 });
9@@ -181,7 +182,7 @@ async function createLocalApiFixture() {
10 controlApi: {
11 baseUrl: "https://control.example.test",
12 firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
13- hasSharedToken: false,
14+ hasSharedToken: true,
15 localApiBase: "http://127.0.0.1:4317",
16 usesPlaceholderToken: false
17 },
18@@ -208,6 +209,7 @@ async function createLocalApiFixture() {
19 return {
20 controlPlane,
21 repository,
22+ sharedToken,
23 snapshot
24 };
25 }
26@@ -216,6 +218,37 @@ function parseJsonBody(response) {
27 return JSON.parse(response.body);
28 }
29
30+async function withMockedPlatform(platform, callback) {
31+ const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
32+
33+ assert.ok(descriptor);
34+
35+ Object.defineProperty(process, "platform", {
36+ configurable: true,
37+ enumerable: descriptor.enumerable ?? true,
38+ value: platform
39+ });
40+
41+ try {
42+ return await callback();
43+ } finally {
44+ Object.defineProperty(process, "platform", descriptor);
45+ }
46+}
47+
48+function assertEmptyExecResultShape(result) {
49+ assert.deepEqual(result, {
50+ stdout: "",
51+ stderr: "",
52+ exitCode: null,
53+ signal: null,
54+ durationMs: 0,
55+ startedAt: null,
56+ finishedAt: null,
57+ timedOut: false
58+ });
59+}
60+
61 function createWebSocketMessageQueue(socket) {
62 const messages = [];
63 const waiters = [];
64@@ -674,8 +707,20 @@ test("handleConductorHttpRequest keeps degraded runtimes observable but not read
65 });
66
67 test("handleConductorHttpRequest serves the migrated local business endpoints from the local repository", async () => {
68- const { repository, snapshot } = await createLocalApiFixture();
69+ const { repository, sharedToken, snapshot } = await createLocalApiFixture();
70 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-local-host-http-"));
71+ const authorizedHeaders = {
72+ authorization: `Bearer ${sharedToken}`
73+ };
74+ const localApiContext = {
75+ repository,
76+ sharedToken,
77+ snapshotLoader: () => snapshot
78+ };
79+ const versionedLocalApiContext = {
80+ ...localApiContext,
81+ version: "1.2.3"
82+ };
83
84 try {
85 const describeResponse = await handleConductorHttpRequest(
86@@ -683,11 +728,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
87 method: "GET",
88 path: "/describe"
89 },
90- {
91- repository,
92- snapshotLoader: () => snapshot,
93- version: "1.2.3"
94- }
95+ versionedLocalApiContext
96 );
97 assert.equal(describeResponse.status, 200);
98 const describePayload = parseJsonBody(describeResponse);
99@@ -697,17 +738,15 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
100 assert.equal(describePayload.data.describe_endpoints.business.path, "/describe/business");
101 assert.equal(describePayload.data.describe_endpoints.control.path, "/describe/control");
102 assert.equal(describePayload.data.host_operations.enabled, true);
103+ assert.equal(describePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
104+ assert.equal(describePayload.data.host_operations.auth.configured, true);
105
106 const businessDescribeResponse = await handleConductorHttpRequest(
107 {
108 method: "GET",
109 path: "/describe/business"
110 },
111- {
112- repository,
113- snapshotLoader: () => snapshot,
114- version: "1.2.3"
115- }
116+ versionedLocalApiContext
117 );
118 assert.equal(businessDescribeResponse.status, 200);
119 const businessDescribePayload = parseJsonBody(businessDescribeResponse);
120@@ -721,11 +760,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
121 method: "GET",
122 path: "/describe/control"
123 },
124- {
125- repository,
126- snapshotLoader: () => snapshot,
127- version: "1.2.3"
128- }
129+ versionedLocalApiContext
130 );
131 assert.equal(controlDescribeResponse.status, 200);
132 const controlDescribePayload = parseJsonBody(controlDescribeResponse);
133@@ -733,17 +768,14 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
134 assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
135 assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
136 assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
137+ assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
138
139 const healthResponse = await handleConductorHttpRequest(
140 {
141 method: "GET",
142 path: "/health"
143 },
144- {
145- repository,
146- snapshotLoader: () => snapshot,
147- version: "1.2.3"
148- }
149+ versionedLocalApiContext
150 );
151 assert.equal(healthResponse.status, 200);
152 assert.equal(parseJsonBody(healthResponse).data.status, "ok");
153@@ -753,10 +785,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
154 method: "GET",
155 path: "/v1/controllers?limit=5"
156 },
157- {
158- repository,
159- snapshotLoader: () => snapshot
160- }
161+ localApiContext
162 );
163 const controllersPayload = parseJsonBody(controllersResponse);
164 assert.equal(controllersPayload.data.count, 1);
165@@ -768,10 +797,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
166 method: "GET",
167 path: "/v1/tasks?status=running&limit=5"
168 },
169- {
170- repository,
171- snapshotLoader: () => snapshot
172- }
173+ localApiContext
174 );
175 const tasksPayload = parseJsonBody(tasksResponse);
176 assert.equal(tasksPayload.data.count, 1);
177@@ -782,10 +808,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
178 method: "GET",
179 path: "/v1/tasks/task_demo"
180 },
181- {
182- repository,
183- snapshotLoader: () => snapshot
184- }
185+ localApiContext
186 );
187 assert.equal(parseJsonBody(taskResponse).data.task_id, "task_demo");
188
189@@ -794,10 +817,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
190 method: "GET",
191 path: "/v1/tasks/task_demo/logs?limit=10"
192 },
193- {
194- repository,
195- snapshotLoader: () => snapshot
196- }
197+ localApiContext
198 );
199 const taskLogsPayload = parseJsonBody(taskLogsResponse);
200 assert.equal(taskLogsPayload.data.task_id, "task_demo");
201@@ -809,10 +829,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
202 method: "GET",
203 path: "/v1/runs?limit=5"
204 },
205- {
206- repository,
207- snapshotLoader: () => snapshot
208- }
209+ localApiContext
210 );
211 const runsPayload = parseJsonBody(runsResponse);
212 assert.equal(runsPayload.data.count, 1);
213@@ -823,10 +840,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
214 method: "GET",
215 path: "/v1/runs/run_demo"
216 },
217- {
218- repository,
219- snapshotLoader: () => snapshot
220- }
221+ localApiContext
222 );
223 assert.equal(parseJsonBody(runResponse).data.run_id, "run_demo");
224
225@@ -839,15 +853,46 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
226 method: "POST",
227 path: "/v1/system/pause"
228 },
229- {
230- repository,
231- snapshotLoader: () => snapshot
232- }
233+ localApiContext
234 );
235 assert.equal(pauseResponse.status, 200);
236 assert.equal(parseJsonBody(pauseResponse).data.mode, "paused");
237 assert.equal((await repository.getAutomationState())?.mode, "paused");
238
239+ const missingTokenExecResponse = await handleConductorHttpRequest(
240+ {
241+ body: JSON.stringify({
242+ command: "printf 'host-http-ok'",
243+ cwd: hostOpsDir,
244+ timeoutMs: 2000
245+ }),
246+ method: "POST",
247+ path: "/v1/exec"
248+ },
249+ localApiContext
250+ );
251+ assert.equal(missingTokenExecResponse.status, 401);
252+ const missingTokenExecPayload = parseJsonBody(missingTokenExecResponse);
253+ assert.equal(missingTokenExecPayload.error, "unauthorized");
254+
255+ const invalidTokenReadResponse = await handleConductorHttpRequest(
256+ {
257+ body: JSON.stringify({
258+ path: "notes/demo.txt",
259+ cwd: hostOpsDir
260+ }),
261+ headers: {
262+ authorization: "Bearer wrong-token"
263+ },
264+ method: "POST",
265+ path: "/v1/files/read"
266+ },
267+ localApiContext
268+ );
269+ assert.equal(invalidTokenReadResponse.status, 401);
270+ const invalidTokenReadPayload = parseJsonBody(invalidTokenReadResponse);
271+ assert.equal(invalidTokenReadPayload.error, "unauthorized");
272+
273 const writeResponse = await handleConductorHttpRequest(
274 {
275 body: JSON.stringify({
276@@ -857,13 +902,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
277 overwrite: false,
278 createParents: true
279 }),
280+ headers: authorizedHeaders,
281 method: "POST",
282 path: "/v1/files/write"
283 },
284- {
285- repository,
286- snapshotLoader: () => snapshot
287- }
288+ localApiContext
289 );
290 assert.equal(writeResponse.status, 200);
291 const writePayload = parseJsonBody(writeResponse);
292@@ -877,13 +920,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
293 path: "notes/demo.txt",
294 cwd: hostOpsDir
295 }),
296+ headers: authorizedHeaders,
297 method: "POST",
298 path: "/v1/files/read"
299 },
300- {
301- repository,
302- snapshotLoader: () => snapshot
303- }
304+ localApiContext
305 );
306 assert.equal(readResponse.status, 200);
307 const readPayload = parseJsonBody(readResponse);
308@@ -898,13 +939,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
309 content: "should not overwrite",
310 overwrite: false
311 }),
312+ headers: authorizedHeaders,
313 method: "POST",
314 path: "/v1/files/write"
315 },
316- {
317- repository,
318- snapshotLoader: () => snapshot
319- }
320+ localApiContext
321 );
322 assert.equal(duplicateWriteResponse.status, 200);
323 const duplicateWritePayload = parseJsonBody(duplicateWriteResponse);
324@@ -918,13 +957,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
325 cwd: hostOpsDir,
326 timeoutMs: 2000
327 }),
328+ headers: authorizedHeaders,
329 method: "POST",
330 path: "/v1/exec"
331 },
332- {
333- repository,
334- snapshotLoader: () => snapshot
335- }
336+ localApiContext
337 );
338 assert.equal(execResponse.status, 200);
339 const execPayload = parseJsonBody(execResponse);
340@@ -932,24 +969,62 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
341 assert.equal(execPayload.data.operation, "exec");
342 assert.equal(execPayload.data.result.stdout, "host-http-ok");
343
344- const systemStateResponse = await handleConductorHttpRequest(
345+ const invalidExecResponse = await handleConductorHttpRequest(
346 {
347- method: "GET",
348- path: "/v1/system/state"
349+ body: JSON.stringify({
350+ command: ["echo", "hello"]
351+ }),
352+ method: "POST",
353+ path: "/v1/exec"
354 },
355 {
356 repository,
357 snapshotLoader: () => snapshot
358 }
359 );
360+ assert.equal(invalidExecResponse.status, 200);
361+ const invalidExecPayload = parseJsonBody(invalidExecResponse);
362+ assert.equal(invalidExecPayload.data.ok, false);
363+ assert.equal(invalidExecPayload.data.error.code, "INVALID_INPUT");
364+ assertEmptyExecResultShape(invalidExecPayload.data.result);
365+
366+ const tccExecResponse = await withMockedPlatform("darwin", () =>
367+ handleConductorHttpRequest(
368+ {
369+ body: JSON.stringify({
370+ command: "pwd",
371+ cwd: join(homedir(), "Desktop", "project"),
372+ timeoutMs: 2000
373+ }),
374+ method: "POST",
375+ path: "/v1/exec"
376+ },
377+ {
378+ repository,
379+ snapshotLoader: () => snapshot
380+ }
381+ );
382+ assert.equal(execResponse.status, 200);
383+ const execPayload = parseJsonBody(execResponse);
384+ assert.equal(execPayload.data.ok, true);
385+ assert.equal(execPayload.data.operation, "exec");
386+ assert.equal(execPayload.data.result.stdout, "host-http-ok");
387+
388+ const systemStateResponse = await handleConductorHttpRequest(
389+ {
390+ method: "GET",
391+ path: "/v1/system/state"
392+ },
393+ localApiContext
394+ );
395 assert.equal(parseJsonBody(systemStateResponse).data.mode, "paused");
396- } finally {
397- rmSync(hostOpsDir, {
398- force: true,
399- recursive: true
400- });
401+ } finally {
402+ rmSync(hostOpsDir, {
403+ force: true,
404+ recursive: true
405+ });
406 }
407-});
408+);
409
410 test("ConductorRuntime serves health and migrated local API endpoints over HTTP", async () => {
411 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-"));
412@@ -979,6 +1054,10 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
413 assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
414
415 const baseUrl = snapshot.controlApi.localApiBase;
416+ const hostOpsHeaders = {
417+ authorization: "Bearer replace-me",
418+ "content-type": "application/json"
419+ };
420
421 const healthResponse = await fetch(`${baseUrl}/healthz`);
422 assert.equal(healthResponse.status, 200);
423@@ -1048,8 +1127,9 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
424 const controlDescribePayload = await controlDescribeResponse.json();
425 assert.equal(controlDescribePayload.data.surface, "control");
426 assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
427+ assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
428
429- const execResponse = await fetch(`${baseUrl}/v1/exec`, {
430+ const unauthorizedExecResponse = await fetch(`${baseUrl}/v1/exec`, {
431 method: "POST",
432 headers: {
433 "content-type": "application/json"
434@@ -1060,6 +1140,19 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
435 timeoutMs: 2000
436 })
437 });
438+ assert.equal(unauthorizedExecResponse.status, 401);
439+ const unauthorizedExecPayload = await unauthorizedExecResponse.json();
440+ assert.equal(unauthorizedExecPayload.error, "unauthorized");
441+
442+ const execResponse = await fetch(`${baseUrl}/v1/exec`, {
443+ method: "POST",
444+ headers: hostOpsHeaders,
445+ body: JSON.stringify({
446+ command: "printf 'runtime-host-op'",
447+ cwd: hostOpsDir,
448+ timeoutMs: 2000
449+ })
450+ });
451 assert.equal(execResponse.status, 200);
452 const execPayload = await execResponse.json();
453 assert.equal(execPayload.data.ok, true);
454@@ -1067,9 +1160,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
455
456 const writeResponse = await fetch(`${baseUrl}/v1/files/write`, {
457 method: "POST",
458- headers: {
459- "content-type": "application/json"
460- },
461+ headers: hostOpsHeaders,
462 body: JSON.stringify({
463 path: "runtime/demo.txt",
464 cwd: hostOpsDir,
465@@ -1085,9 +1176,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
466
467 const duplicateWriteResponse = await fetch(`${baseUrl}/v1/files/write`, {
468 method: "POST",
469- headers: {
470- "content-type": "application/json"
471- },
472+ headers: hostOpsHeaders,
473 body: JSON.stringify({
474 path: "runtime/demo.txt",
475 cwd: hostOpsDir,
476@@ -1102,9 +1191,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
477
478 const readResponse = await fetch(`${baseUrl}/v1/files/read`, {
479 method: "POST",
480- headers: {
481- "content-type": "application/json"
482- },
483+ headers: hostOpsHeaders,
484 body: JSON.stringify({
485 path: "runtime/demo.txt",
486 cwd: hostOpsDir
+25,
-0
1@@ -533,11 +533,31 @@ async function readIncomingRequestBody(request: IncomingMessage): Promise<string
2 });
3 }
4
5+function normalizeIncomingRequestHeaders(
6+ headers: IncomingMessage["headers"]
7+): Record<string, string | undefined> {
8+ const normalized: Record<string, string | undefined> = {};
9+
10+ for (const [name, value] of Object.entries(headers)) {
11+ if (typeof value === "string") {
12+ normalized[name] = value;
13+ continue;
14+ }
15+
16+ if (Array.isArray(value)) {
17+ normalized[name] = value.join(", ");
18+ }
19+ }
20+
21+ return normalized;
22+}
23+
24 class ConductorLocalHttpServer {
25 private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
26 private readonly localApiBase: string;
27 private readonly now: () => number;
28 private readonly repository: ControlPlaneRepository;
29+ private readonly sharedToken: string | null;
30 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
31 private readonly version: string | null;
32 private resolvedBaseUrl: string;
33@@ -547,12 +567,14 @@ class ConductorLocalHttpServer {
34 localApiBase: string,
35 repository: ControlPlaneRepository,
36 snapshotLoader: () => ConductorRuntimeSnapshot,
37+ sharedToken: string | null,
38 version: string | null,
39 now: () => number
40 ) {
41 this.localApiBase = localApiBase;
42 this.now = now;
43 this.repository = repository;
44+ this.sharedToken = sharedToken;
45 this.snapshotLoader = snapshotLoader;
46 this.version = version;
47 this.resolvedBaseUrl = localApiBase;
48@@ -583,11 +605,13 @@ class ConductorLocalHttpServer {
49 const payload = await handleConductorLocalHttpRequest(
50 {
51 body: await readIncomingRequestBody(request),
52+ headers: normalizeIncomingRequestHeaders(request.headers),
53 method: request.method ?? "GET",
54 path: request.url ?? "/"
55 },
56 {
57 repository: this.repository,
58+ sharedToken: this.sharedToken,
59 snapshotLoader: this.snapshotLoader,
60 version: this.version
61 }
62@@ -1772,6 +1796,7 @@ export class ConductorRuntime {
63 this.config.localApiBase,
64 this.localControlPlane.repository,
65 () => this.getRuntimeSnapshot(),
66+ this.config.sharedToken,
67 this.config.version,
68 this.now
69 );
+324,
-39
1@@ -36,10 +36,18 @@ const DEFAULT_LOG_LIMIT = 200;
2 const MAX_LIST_LIMIT = 100;
3 const MAX_LOG_LIMIT = 500;
4 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
5+const HOST_OPERATIONS_ROUTE_IDS = new Set(["host.exec", "host.files.read", "host.files.write"]);
6+const HOST_OPERATIONS_AUTH_HEADER = "Authorization: Bearer <BAA_SHARED_TOKEN>";
7+const HOST_OPERATIONS_WWW_AUTHENTICATE = 'Bearer realm="baa-conductor-host-ops"';
8
9 type LocalApiRouteMethod = "GET" | "POST";
10 type LocalApiRouteKind = "probe" | "read" | "write";
11 type LocalApiDescribeSurface = "business" | "control";
12+type SharedTokenAuthFailureReason =
13+ | "empty_bearer_token"
14+ | "invalid_authorization_scheme"
15+ | "invalid_token"
16+ | "missing_authorization_header";
17
18 interface LocalApiRouteDefinition {
19 id: string;
20@@ -61,6 +69,7 @@ interface LocalApiRequestContext {
21 repository: ControlPlaneRepository | null;
22 request: ConductorHttpRequest;
23 requestId: string;
24+ sharedToken: string | null;
25 snapshotLoader: () => ConductorRuntimeApiSnapshot;
26 url: URL;
27 }
28@@ -96,6 +105,7 @@ export interface ConductorRuntimeApiSnapshot {
29 export interface ConductorLocalApiContext {
30 now?: () => number;
31 repository: ControlPlaneRepository | null;
32+ sharedToken?: string | null;
33 snapshotLoader: () => ConductorRuntimeApiSnapshot;
34 version?: string | null;
35 }
36@@ -309,6 +319,15 @@ function isJsonObject(value: JsonValue | null): value is JsonObject {
37 return value !== null && typeof value === "object" && !Array.isArray(value);
38 }
39
40+function normalizeOptionalString(value: string | null | undefined): string | null {
41+ if (value == null) {
42+ return null;
43+ }
44+
45+ const normalized = value.trim();
46+ return normalized === "" ? null : normalized;
47+}
48+
49 function buildSuccessEnvelope(
50 requestId: string,
51 status: number,
52@@ -674,9 +693,119 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
53 };
54 }
55
56-function buildHostOperationsData(origin: string): JsonObject {
57+function isHostOperationsRoute(route: LocalApiRouteDefinition): boolean {
58+ return HOST_OPERATIONS_ROUTE_IDS.has(route.id);
59+}
60+
61+function readHeaderValue(request: ConductorHttpRequest, headerName: string): string | undefined {
62+ const headers = request.headers;
63+
64+ if (headers == null) {
65+ return undefined;
66+ }
67+
68+ const exactMatch = headers[headerName];
69+
70+ if (typeof exactMatch === "string") {
71+ return exactMatch;
72+ }
73+
74+ const normalizedHeaderName = headerName.toLowerCase();
75+ const lowerCaseMatch = headers[normalizedHeaderName];
76+
77+ if (typeof lowerCaseMatch === "string") {
78+ return lowerCaseMatch;
79+ }
80+
81+ for (const [name, value] of Object.entries(headers)) {
82+ if (name.toLowerCase() === normalizedHeaderName && typeof value === "string") {
83+ return value;
84+ }
85+ }
86+
87+ return undefined;
88+}
89+
90+function extractBearerToken(
91+ authorizationHeader: string | undefined
92+): { ok: true; token: string } | { ok: false; reason: SharedTokenAuthFailureReason } {
93+ if (!authorizationHeader) {
94+ return {
95+ ok: false,
96+ reason: "missing_authorization_header"
97+ };
98+ }
99+
100+ const [scheme, ...rest] = authorizationHeader.trim().split(/\s+/u);
101+
102+ if (scheme !== "Bearer") {
103+ return {
104+ ok: false,
105+ reason: "invalid_authorization_scheme"
106+ };
107+ }
108+
109+ const token = rest.join(" ").trim();
110+
111+ if (token === "") {
112+ return {
113+ ok: false,
114+ reason: "empty_bearer_token"
115+ };
116+ }
117+
118+ return {
119+ ok: true,
120+ token
121+ };
122+}
123+
124+function buildHostOperationsAuthData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
125+ return {
126+ configured: snapshot.controlApi.hasSharedToken,
127+ env_var: "BAA_SHARED_TOKEN",
128+ header: HOST_OPERATIONS_AUTH_HEADER,
129+ mode: "bearer_shared_token",
130+ protected_routes: ["/v1/exec", "/v1/files/read", "/v1/files/write"],
131+ public_access: "anonymous_denied",
132+ uses_placeholder_token: snapshot.controlApi.usesPlaceholderToken
133+ };
134+}
135+
136+function buildHttpAuthData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
137+ return {
138+ mode: "mixed",
139+ default_routes: {
140+ access: "local_network",
141+ header_required: false
142+ },
143+ host_operations: buildHostOperationsAuthData(snapshot)
144+ };
145+}
146+
147+function buildHostOperationsNotes(snapshot: ConductorRuntimeApiSnapshot): string[] {
148+ const notes = [
149+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>.",
150+ "Missing or incorrect bearer tokens return a 401 JSON error and do not execute any host operation.",
151+ "These host-ops routes are not anonymously public, even if this listener is reverse-proxied or tunneled.",
152+ "These three routes still return the outer conductor success envelope; the inner operation result remains in data.",
153+ "Request bodies should prefer camelCase fields; timeout_ms, max_buffer_bytes and create_parents remain accepted aliases.",
154+ "These operations act only on the current node and do not hop through control-api-worker."
155+ ];
156+
157+ if (!snapshot.controlApi.hasSharedToken) {
158+ notes.push("BAA_SHARED_TOKEN is not configured on this node; host operations stay unavailable until it is set.");
159+ } else if (snapshot.controlApi.usesPlaceholderToken) {
160+ notes.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before exposing this surface.");
161+ }
162+
163+ return notes;
164+}
165+
166+function buildHostOperationsData(origin: string, snapshot: ConductorRuntimeApiSnapshot): JsonObject {
167 return {
168 enabled: true,
169+ auth: buildHostOperationsAuthData(snapshot),
170 contract: "HTTP success envelope data 直接嵌入 @baa-conductor/host-ops 的结构化 result union。",
171 semantics: {
172 cwd: "可选字符串;省略时使用 conductor-daemon 进程当前工作目录。",
173@@ -698,11 +827,18 @@ function buildHostOperationsData(origin: string): JsonObject {
174 timeoutMs: DEFAULT_EXEC_TIMEOUT_MS,
175 maxBufferBytes: DEFAULT_EXEC_MAX_BUFFER_BYTES
176 },
177- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
178- command: "printf 'hello from conductor'",
179- cwd: "/tmp",
180- timeoutMs: 2000
181- })
182+ curl: buildCurlExample(
183+ origin,
184+ LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
185+ {
186+ command: "printf 'hello from conductor'",
187+ cwd: "/tmp",
188+ timeoutMs: 2000
189+ },
190+ {
191+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
192+ }
193+ )
194 },
195 {
196 method: "POST",
197@@ -720,6 +856,9 @@ function buildHostOperationsData(origin: string): JsonObject {
198 path: "README.md",
199 cwd: "/Users/george/code/baa-conductor",
200 encoding: "utf8"
201+ },
202+ {
203+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
204 }
205 )
206 },
207@@ -745,15 +884,14 @@ function buildHostOperationsData(origin: string): JsonObject {
208 overwrite: true,
209 createParents: true,
210 encoding: "utf8"
211+ },
212+ {
213+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
214 }
215 )
216 }
217 ],
218- notes: [
219- "这三条路由总是返回外层 conductor success envelope;具体操作成功或失败看 data.ok。",
220- "请求体优先使用 host-ops 的 camelCase 字段;daemon 也接受 timeout_ms、max_buffer_bytes、create_parents 作为兼容别名。",
221- "这些操作只作用于当前本机节点,不会经过 control-api-worker。"
222- ]
223+ notes: buildHostOperationsNotes(snapshot)
224 };
225 }
226
227@@ -765,7 +903,15 @@ function describeRoute(route: LocalApiRouteDefinition): JsonObject {
228 kind: route.kind === "probe" ? "read" : route.kind,
229 implementation: "implemented",
230 summary: route.summary,
231- access: "local_network"
232+ access: isHostOperationsRoute(route) ? "bearer_shared_token" : "local_network",
233+ ...(isHostOperationsRoute(route)
234+ ? {
235+ auth: {
236+ header: HOST_OPERATIONS_AUTH_HEADER,
237+ required: true
238+ }
239+ }
240+ : {})
241 };
242 }
243
244@@ -822,7 +968,8 @@ function buildCapabilitiesData(
245
246 return {
247 deployment_mode: "single-node mini",
248- auth_mode: "local_network_only",
249+ auth: buildHttpAuthData(snapshot),
250+ auth_mode: "mixed",
251 repository_configured: true,
252 truth_source: "local sqlite control plane",
253 workflow: [
254@@ -844,24 +991,31 @@ function buildCapabilitiesData(
255 },
256 transports: {
257 http: {
258- auth_mode: "local_network_only",
259+ auth: buildHttpAuthData(snapshot),
260+ auth_mode: "mixed",
261 url: snapshot.controlApi.localApiBase ?? null
262 },
263 websocket: buildFirefoxWebSocketData(snapshot)
264 },
265- host_operations: buildHostOperationsData(origin)
266+ host_operations: buildHostOperationsData(origin, snapshot)
267 };
268 }
269
270 function buildCurlExample(
271 origin: string,
272 route: LocalApiRouteDefinition,
273- body?: JsonObject
274+ body?: JsonObject,
275+ options: {
276+ bearerTokenEnvVar?: string;
277+ } = {}
278 ): string {
279 const serializedBody = body ? JSON.stringify(body).replaceAll("'", "'\"'\"'") : null;
280- const payload = body
281- ? ` \\\n -H 'Content-Type: application/json' \\\n -d '${serializedBody}'`
282+ const authHeader = options.bearerTokenEnvVar
283+ ? ` \\\n -H "Authorization: Bearer \${${options.bearerTokenEnvVar}}"`
284 : "";
285+ const payload = body
286+ ? `${authHeader} \\\n -H 'Content-Type: application/json' \\\n -d '${serializedBody}'`
287+ : authHeader;
288 return `curl -X ${route.method} '${origin}${route.pathPattern}'${payload}`;
289 }
290
291@@ -879,10 +1033,12 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
292 summary: "single-node mini local daemon",
293 deployment_mode: "single-node mini",
294 topology: "No Cloudflare Worker or D1 control-plane hop is required for these routes.",
295- auth_mode: "local_network_only",
296+ auth: buildHttpAuthData(snapshot),
297+ auth_mode: "mixed",
298 truth_source: "local sqlite control plane",
299 origin
300 },
301+ auth: buildHttpAuthData(snapshot),
302 system,
303 websocket: buildFirefoxWebSocketData(snapshot),
304 describe_endpoints: {
305@@ -892,7 +1048,8 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
306 },
307 control: {
308 path: "/describe/control",
309- summary: "控制和本机 host-ops 入口;在 pause/resume/drain 或 exec/files/* 前先读。"
310+ summary:
311+ "控制和本机 host-ops 入口;在 pause/resume/drain 或 exec/files/* 前先读。/v1/exec 和 /v1/files/* 需要 shared token。"
312 }
313 },
314 endpoints: [
315@@ -906,7 +1063,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
316 }
317 ],
318 capabilities: buildCapabilitiesData(snapshot),
319- host_operations: buildHostOperationsData(origin),
320+ host_operations: buildHostOperationsData(origin, snapshot),
321 examples: [
322 {
323 title: "Read the business describe surface first",
324@@ -955,15 +1112,23 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
325 title: "Run a small local command",
326 method: "POST",
327 path: "/v1/exec",
328- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
329- command: "printf 'hello from conductor'",
330- cwd: "/tmp",
331- timeoutMs: 2000
332- })
333+ curl: buildCurlExample(
334+ origin,
335+ LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
336+ {
337+ command: "printf 'hello from conductor'",
338+ cwd: "/tmp",
339+ timeoutMs: 2000
340+ },
341+ {
342+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
343+ }
344+ )
345 }
346 ],
347 notes: [
348 "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
349+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
350 "These routes read and mutate the mini node's local truth source directly.",
351 "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
352 "The optional Firefox bridge WS reuses the same local listener and upgrades on /ws/firefox."
353@@ -991,6 +1156,8 @@ async function handleScopedDescribeRead(
354 audience: ["cli_ai", "web_ai", "mobile_web_ai"],
355 environment: {
356 deployment_mode: "single-node mini",
357+ auth: buildHttpAuthData(snapshot),
358+ auth_mode: "mixed",
359 truth_source: "local sqlite control plane",
360 origin
361 },
362@@ -1019,6 +1186,7 @@ async function handleScopedDescribeRead(
363 ],
364 notes: [
365 "This surface is intended to be enough for business-query discovery without reading external docs.",
366+ "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
367 "Control actions and host-level exec/file operations are intentionally excluded; use /describe/control."
368 ]
369 });
370@@ -1032,6 +1200,8 @@ async function handleScopedDescribeRead(
371 audience: ["cli_ai", "web_ai", "mobile_web_ai", "human_operator"],
372 environment: {
373 deployment_mode: "single-node mini",
374+ auth: buildHttpAuthData(snapshot),
375+ auth_mode: "mixed",
376 truth_source: "local sqlite control plane",
377 origin
378 },
379@@ -1044,7 +1214,8 @@ async function handleScopedDescribeRead(
380 ],
381 system,
382 websocket: buildFirefoxWebSocketData(snapshot),
383- host_operations: buildHostOperationsData(origin),
384+ auth: buildHttpAuthData(snapshot),
385+ host_operations: buildHostOperationsData(origin, snapshot),
386 endpoints: routes.map(describeRoute),
387 examples: [
388 {
389@@ -1067,28 +1238,43 @@ async function handleScopedDescribeRead(
390 title: "Run a small local command",
391 method: "POST",
392 path: "/v1/exec",
393- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
394- command: "printf 'hello from conductor'",
395- cwd: "/tmp",
396- timeoutMs: 2000
397- })
398+ curl: buildCurlExample(
399+ origin,
400+ LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
401+ {
402+ command: "printf 'hello from conductor'",
403+ cwd: "/tmp",
404+ timeoutMs: 2000
405+ },
406+ {
407+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
408+ }
409+ )
410 },
411 {
412 title: "Write a local file safely",
413 method: "POST",
414 path: "/v1/files/write",
415- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!, {
416- path: "tmp/conductor-note.txt",
417- cwd: "/Users/george/code/baa-conductor",
418- content: "hello from conductor",
419- overwrite: false,
420- createParents: true
421- })
422+ curl: buildCurlExample(
423+ origin,
424+ LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!,
425+ {
426+ path: "tmp/conductor-note.txt",
427+ cwd: "/Users/george/code/baa-conductor",
428+ content: "hello from conductor",
429+ overwrite: false,
430+ createParents: true
431+ },
432+ {
433+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
434+ }
435+ )
436 }
437 ],
438 notes: [
439 "This surface is intended to be enough for control discovery without reading external docs.",
440 "Business queries such as tasks and runs are intentionally excluded; use /describe/business.",
441+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
442 "Host operations return the structured host-ops union inside the outer conductor HTTP envelope."
443 ]
444 });
445@@ -1131,6 +1317,7 @@ async function handleCapabilitiesRead(
446 notes: [
447 "Read routes are safe for discovery and inspection.",
448 "POST /v1/system/* writes the local automation mode immediately.",
449+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
450 "POST /v1/exec and POST /v1/files/* return the structured host-ops union in data."
451 ]
452 });
453@@ -1144,6 +1331,102 @@ async function handleSystemStateRead(context: LocalApiRequestContext): Promise<C
454 );
455 }
456
457+function buildHostOperationsAuthError(
458+ route: LocalApiRouteDefinition,
459+ reason: SharedTokenAuthFailureReason
460+): LocalApiHttpError {
461+ const details: JsonObject = {
462+ auth_scheme: "Bearer",
463+ env_var: "BAA_SHARED_TOKEN",
464+ route_id: route.id
465+ };
466+
467+ switch (reason) {
468+ case "missing_authorization_header":
469+ return new LocalApiHttpError(
470+ 401,
471+ "unauthorized",
472+ `${route.method} ${route.pathPattern} requires ${HOST_OPERATIONS_AUTH_HEADER}.`,
473+ {
474+ ...details,
475+ reason
476+ },
477+ {
478+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
479+ }
480+ );
481+ case "invalid_authorization_scheme":
482+ return new LocalApiHttpError(
483+ 401,
484+ "unauthorized",
485+ `${route.method} ${route.pathPattern} requires a Bearer token in the Authorization header.`,
486+ {
487+ ...details,
488+ reason
489+ },
490+ {
491+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
492+ }
493+ );
494+ case "empty_bearer_token":
495+ return new LocalApiHttpError(
496+ 401,
497+ "unauthorized",
498+ `${route.method} ${route.pathPattern} requires a non-empty Bearer token.`,
499+ {
500+ ...details,
501+ reason
502+ },
503+ {
504+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
505+ }
506+ );
507+ case "invalid_token":
508+ return new LocalApiHttpError(
509+ 401,
510+ "unauthorized",
511+ `${route.method} ${route.pathPattern} received an invalid Bearer token.`,
512+ {
513+ ...details,
514+ reason
515+ },
516+ {
517+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
518+ }
519+ );
520+ }
521+}
522+
523+function authorizeRoute(route: LocalApiRouteDefinition, context: LocalApiRequestContext): void {
524+ if (!isHostOperationsRoute(route)) {
525+ return;
526+ }
527+
528+ const sharedToken = normalizeOptionalString(context.sharedToken);
529+
530+ if (sharedToken == null) {
531+ throw new LocalApiHttpError(
532+ 503,
533+ "shared_token_not_configured",
534+ `BAA_SHARED_TOKEN is not configured; ${route.method} ${route.pathPattern} is unavailable.`,
535+ {
536+ env_var: "BAA_SHARED_TOKEN",
537+ route_id: route.id
538+ }
539+ );
540+ }
541+
542+ const tokenResult = extractBearerToken(readHeaderValue(context.request, "authorization"));
543+
544+ if (!tokenResult.ok) {
545+ throw buildHostOperationsAuthError(route, tokenResult.reason);
546+ }
547+
548+ if (tokenResult.token !== sharedToken) {
549+ throw buildHostOperationsAuthError(route, "invalid_token");
550+ }
551+}
552+
553 export interface SetAutomationModeInput {
554 mode: AutomationMode;
555 reason?: string | null;
556@@ -1414,6 +1697,7 @@ async function dispatchRoute(
557 case "probe.runtime":
558 return buildSuccessEnvelope(context.requestId, 200, context.snapshotLoader() as unknown as JsonValue);
559 default:
560+ authorizeRoute(matchedRoute.route, context);
561 return dispatchBusinessRoute(matchedRoute.route.id, context, version);
562 }
563 }
564@@ -1532,6 +1816,7 @@ export async function handleConductorHttpRequest(
565 repository: context.repository,
566 request,
567 requestId,
568+ sharedToken: normalizeOptionalString(context.sharedToken),
569 snapshotLoader: context.snapshotLoader,
570 url
571 },
+9,
-1
1@@ -40,7 +40,7 @@
2 2. 如有需要,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
3 3. 如果是控制动作,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
4 4. 按需查看 `controllers`、`tasks`、`runs`
5-5. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`
6+5. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`,并准备 `Authorization: Bearer <BAA_SHARED_TOKEN>`
7 6. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
8 7. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
9
10@@ -88,6 +88,8 @@ host-ops 约定:
11
12 - 这三条路由的 HTTP 外层仍然是 `conductor-daemon` 统一 envelope
13 - 具体操作成功或失败看 `data.ok`
14+- 这三条路由统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>`
15+- `BAA_SHARED_TOKEN` 来自 daemon 启动配置;缺少或错误 token 时返回 `401` JSON 错误
16 - `cwd` 省略时使用 daemon 进程当前工作目录
17 - `path` 可为绝对路径,也可相对 `cwd`
18 - `timeoutMs` 仅用于 `/v1/exec`,默认 `30000`
19@@ -183,21 +185,27 @@ curl -X POST "${LOCAL_API_BASE}/v1/system/pause" \
20
21 ```bash
22 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
23+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
24 curl -X POST "${LOCAL_API_BASE}/v1/exec" \
25+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
26 -H 'Content-Type: application/json' \
27 -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
28 ```
29
30 ```bash
31 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
32+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
33 curl -X POST "${LOCAL_API_BASE}/v1/files/read" \
34+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
35 -H 'Content-Type: application/json' \
36 -d '{"path":"README.md","cwd":"/Users/george/code/baa-conductor","encoding":"utf8"}'
37 ```
38
39 ```bash
40 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
41+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
42 curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
43+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
44 -H 'Content-Type: application/json' \
45 -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
46 ```
+23,
-1
1@@ -34,6 +34,18 @@
2
3 不要在未读状态前直接写入控制动作或本机 host-ops。
4
5+## Host Ops 鉴权
6+
7+- `POST /v1/exec`
8+- `POST /v1/files/read`
9+- `POST /v1/files/write`
10+
11+这三条 host-ops 路由现在统一要求:
12+
13+- `Authorization: Bearer <BAA_SHARED_TOKEN>`
14+- 缺少 token 或 token 错误时返回明确的 `401` JSON 错误
15+- 即使通过 `https://conductor.makefile.so` 暴露,这三条路由也不再是匿名公网可用
16+
17 ## 当前可用控制类接口
18
19 ### 发现与诊断
20@@ -67,6 +79,12 @@
21 | `POST` | `/v1/files/read` | 读取当前节点文本文件 |
22 | `POST` | `/v1/files/write` | 写入当前节点文本文件,支持 `overwrite` / `createParents` |
23
24+鉴权要求:
25+
26+- header:`Authorization: Bearer <BAA_SHARED_TOKEN>`
27+- token 来源:daemon 启动时配置的 `BAA_SHARED_TOKEN`
28+- 未授权请求会在进入 host-ops 前直接返回 `401`
29+
30 输入语义:
31
32 - `cwd`:省略时使用 daemon 进程当前工作目录
33@@ -133,14 +151,18 @@ curl -X POST "${BASE_URL}/v1/system/drain" \
34
35 ```bash
36 BASE_URL="http://100.71.210.78:4317"
37+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
38 curl -X POST "${BASE_URL}/v1/exec" \
39+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
40 -H 'Content-Type: application/json' \
41 -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
42 ```
43
44 ```bash
45 BASE_URL="http://100.71.210.78:4317"
46+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
47 curl -X POST "${BASE_URL}/v1/files/write" \
48+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
49 -H 'Content-Type: application/json' \
50 -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
51 ```
52@@ -149,7 +171,7 @@ curl -X POST "${BASE_URL}/v1/files/write" \
53
54 ```text
55 先阅读控制类接口文档,再请求 /describe/control、/v1/capabilities、/v1/system/state。
56-只有在确认当前状态后,才执行 pause / resume / drain;如果要做本机 shell / 文件操作,也先看 host_operations。
57+只有在确认当前状态后,才执行 pause / resume / drain;如果要做本机 shell / 文件操作,也先看 host_operations,并带上 Authorization: Bearer <BAA_SHARED_TOKEN>。
58 ```
59
60 ## 当前边界
+69,
-7
1@@ -14,6 +14,15 @@
2 - 已挂到 `conductor-daemon` 本地 API
3 - 不再挂到单独 Worker / D1 兼容层
4
5+## Authentication
6+
7+这三条 HTTP 路由现在统一要求:
8+
9+- `Authorization: Bearer <BAA_SHARED_TOKEN>`
10+- token 来自 daemon 进程启动时配置的 `BAA_SHARED_TOKEN`
11+- 缺少 token 或 token 错误时,HTTP 层直接返回 `401` JSON 错误,不会进入实际 host operation
12+- 即使经由 `conductor.makefile.so`、Nginx 或 tunnel 转发,这三条路由也不是匿名公网接口
13+
14 ## Operations
15
16 | operation | request | success.result | failure.error |
17@@ -30,7 +39,8 @@
18 - `path`:可为绝对路径;相对路径会相对 `cwd` 解析。
19 - `timeoutMs`:仅 `exec` 使用。可选整数 `>= 0`,默认 `30000`;`0` 表示不启用超时。
20 - `maxBufferBytes`:仅 `exec` 使用。可选整数 `> 0`,默认 `10485760`。
21-- 在 macOS 上,`exec` 会对 `cwd` 和 `command` 里明显命中的 TCC 受保护目录做前置检查,避免后台进程访问 `Desktop` / `Documents` / `Downloads` 时无提示挂住 30 秒。当前覆盖 `~/Desktop`、`~/Documents`、`~/Downloads` 以及 `/Users/<user>/Desktop|Documents|Downloads` 这类直接字面量命中。
22+- `exec` 子进程默认不会继承 `conductor-daemon` 的完整环境变量。当前在 macOS / Linux 只透传最小集合:`PATH`、`HOME`、`USER`、`LOGNAME`、`LANG`、`TERM`、`TMPDIR`;在 Windows 只透传启动 shell 和常见可执行文件解析所需的最小集合(如 `ComSpec`、`PATH`、`PATHEXT`、`SystemRoot`、`TEMP`、`TMP`、`USERNAME`、`USERPROFILE`、`WINDIR`)。因此 `BAA_SHARED_TOKEN`、`*_TOKEN`、`*_SECRET`、`*_KEY`、`*_PASSWORD` 等 daemon 自身环境变量不会默认暴露给 `exec`。
23+- 在 macOS 上,`exec` 只会对显式传入的 `cwd` 做 TCC 受保护目录前置检查,避免后台进程把工作目录直接设到 `Desktop` / `Documents` / `Downloads` 时无提示挂住 30 秒。`command` 仍按 shell 字符串原样执行,这个预检不是安全边界,也不会尝试解析 `command` 里的变量、`cd`、命令替换或其他间接路径。
24 - `createParents`:仅 `files/write` 使用。可选布尔值,默认 `true`;会递归创建缺失父目录。
25 - `overwrite`:仅 `files/write` 使用。可选布尔值,默认 `true`;为 `false` 且目标文件已存在时返回 `FILE_ALREADY_EXISTS`。
26
27@@ -38,6 +48,25 @@
28
29 包级返回 union 仍保持不变。
30
31+`exec` 的失败返回现在也保证带完整的 `result` 结构,不再出现 `result: null` 或空对象:
32+
33+```json
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+如果子进程已经启动,则 `result` 会继续返回实际采集到的 `stdout` / `stderr` / `durationMs` / 时间戳。
49+
50 成功返回:
51
52 ```json
53@@ -124,25 +153,48 @@
54 - 外层 `ok` / `request_id` 属于 `conductor-daemon` HTTP 协议
55 - 内层 `data.ok` / `data.operation` / `data.error` 属于 `host-ops` 结构化结果
56
57+未授权示例:
58+
59+```json
60+{
61+ "ok": false,
62+ "request_id": "req_xxx",
63+ "error": "unauthorized",
64+ "message": "POST /v1/exec requires Authorization: Bearer <BAA_SHARED_TOKEN>.",
65+ "details": {
66+ "auth_scheme": "Bearer",
67+ "env_var": "BAA_SHARED_TOKEN",
68+ "route_id": "host.exec",
69+ "reason": "missing_authorization_header"
70+ }
71+}
72+```
73+
74 ## Minimal Curl
75
76 ```bash
77 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
78+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
79 curl -X POST "${LOCAL_API_BASE}/v1/exec" \
80+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
81 -H 'Content-Type: application/json' \
82 -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
83 ```
84
85 ```bash
86 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
87+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
88 curl -X POST "${LOCAL_API_BASE}/v1/files/read" \
89+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
90 -H 'Content-Type: application/json' \
91 -d '{"path":"README.md","cwd":"/Users/george/code/baa-conductor","encoding":"utf8"}'
92 ```
93
94 ```bash
95 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
96+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
97 curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
98+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
99 -H 'Content-Type: application/json' \
100 -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
101 ```
102@@ -160,30 +212,40 @@ curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
103
104 ## macOS TCC Fast-Fail
105
106-如果 `exec` 在 macOS 上检测到命令或 `cwd` 直接命中受 TCC 保护的用户目录,会在启动子进程前直接返回:
107+如果 `exec` 在 macOS 上检测到 `cwd` 落在受 TCC 保护的用户目录内,会在启动子进程前直接返回:
108
109 ```json
110 {
111 "ok": false,
112 "operation": "exec",
113 "input": {
114- "command": "ls ~/Desktop",
115- "cwd": "/tmp",
116+ "command": "pwd",
117+ "cwd": "/Users/george/Desktop/project",
118 "timeoutMs": 2000,
119 "maxBufferBytes": 10485760
120 },
121 "error": {
122 "code": "TCC_PERMISSION_DENIED",
123- "message": "Command references macOS TCC-protected path /Users/george/Desktop. Grant Full Disk Access to the Node.js binary running conductor-daemon (/opt/homebrew/bin/node) and retry.",
124+ "message": "Command cwd resolves inside macOS TCC-protected path /Users/george/Desktop. Grant Full Disk Access to the Node.js binary running conductor-daemon (/opt/homebrew/bin/node) and retry.",
125 "retryable": false,
126 "details": {
127- "accessPoint": "command",
128+ "accessPoint": "cwd",
129 "protectedPath": "/Users/george/Desktop",
130 "nodeBinary": "/opt/homebrew/bin/node",
131 "requiresFullDiskAccess": true
132 }
133+ },
134+ "result": {
135+ "stdout": "",
136+ "stderr": "",
137+ "exitCode": null,
138+ "signal": null,
139+ "durationMs": 0,
140+ "startedAt": null,
141+ "finishedAt": null,
142+ "timedOut": false
143 }
144 }
145 ```
146
147-当前这是基于命令字面量的快速预检,不会解析 shell 变量、命令替换或更复杂的间接路径展开。
148+当前这只是针对 `cwd` 的快速预检,用来减少已知的 TCC 挂起场景;真正是否允许访问仍由 macOS 自身权限决定。
+123,
-19
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, realpath, rm } from "node:fs/promises";
5 import { homedir, tmpdir } from "node:os";
6 import { join } from "node:path";
7 import test from "node:test";
8@@ -29,6 +29,46 @@ async function withMockedPlatform(platform, callback) {
9 }
10 }
11
12+async function withPatchedEnv(patch, callback) {
13+ const originalValues = new Map();
14+
15+ for (const [key, value] of Object.entries(patch)) {
16+ originalValues.set(key, process.env[key]);
17+
18+ if (value === undefined) {
19+ delete process.env[key];
20+ continue;
21+ }
22+
23+ process.env[key] = value;
24+ }
25+
26+ try {
27+ return await callback();
28+ } finally {
29+ for (const [key, value] of originalValues) {
30+ if (value === undefined) {
31+ delete process.env[key];
32+ continue;
33+ }
34+
35+ process.env[key] = value;
36+ }
37+ }
38+}
39+function assertEmptyExecResultShape(result) {
40+ assert.deepEqual(result, {
41+ stdout: "",
42+ stderr: "",
43+ exitCode: null,
44+ signal: null,
45+ durationMs: 0,
46+ startedAt: null,
47+ finishedAt: null,
48+ timedOut: false
49+ });
50+}
51+
52 test("executeCommand returns structured stdout for a successful command", { concurrency: false }, async () => {
53 const result = await executeCommand({
54 command: "printf 'host-ops-smoke'",
55@@ -52,31 +92,96 @@ test("executeCommand returns a structured failure for non-zero exit codes", { co
56 assert.equal(result.ok, false);
57 assert.equal(result.operation, "exec");
58 assert.equal(result.error.code, "EXEC_EXIT_NON_ZERO");
59- assert.equal(result.result?.exitCode, 7);
60- assert.equal(result.result?.stderr, "broken");
61+ assert.equal(result.result.exitCode, 7);
62+ assert.equal(result.result.stderr, "broken");
63+});
64+
65+test("executeCommand returns a complete empty exec result for INVALID_INPUT failures", { concurrency: false }, async () => {
66+ for (const request of [{ command: ["echo", "hello"] }, { command: "" }]) {
67+ const result = await executeCommand(request);
68+
69+ assert.equal(result.ok, false);
70+ assert.equal(result.operation, "exec");
71+ assert.equal(result.error.code, "INVALID_INPUT");
72+ assertEmptyExecResultShape(result.result);
73+ }
74 });
75
76 test(
77- "executeCommand fails fast for macOS TCC-protected command paths",
78+ "executeCommand keeps enough environment for common shell commands",
79+ { concurrency: false },
80+ async () => {
81+ const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-exec-env-"));
82+
83+ try {
84+ const resolvedDirectory = await realpath(directory);
85+ const result = await executeCommand({
86+ command: "pwd && ls . >/dev/null && node -v",
87+ cwd: directory,
88+ timeoutMs: 2_000
89+ });
90+
91+ assert.equal(result.ok, true);
92+
93+ const outputLines = result.result.stdout.trim().split("\n");
94+ assert.equal(outputLines[0], resolvedDirectory);
95+ assert.match(outputLines[1], /^v\d+\./);
96+ } finally {
97+ await rm(directory, { force: true, recursive: true });
98+ }
99+ }
100+);
101+
102+test(
103+ "executeCommand does not leak daemon secrets through child environment",
104+ { concurrency: false },
105+ async () => {
106+ const result = await withPatchedEnv(
107+ {
108+ BAA_SHARED_TOKEN: "shared-secret",
109+ GITHUB_TOKEN: "github-secret",
110+ OPENAI_API_KEY: "openai-secret"
111+ },
112+ () =>
113+ executeCommand({
114+ command: "node -e \"process.stdout.write(JSON.stringify(process.env))\"",
115+ timeoutMs: 2_000
116+ })
117+ );
118+
119+ assert.equal(result.ok, true);
120+
121+ const childEnv = JSON.parse(result.result.stdout);
122+
123+ assert.equal(childEnv.BAA_SHARED_TOKEN, undefined);
124+ assert.equal(childEnv.GITHUB_TOKEN, undefined);
125+ assert.equal(childEnv.OPENAI_API_KEY, undefined);
126+
127+ for (const key of ["PATH", "HOME", "USER", "LOGNAME", "LANG", "TERM", "TMPDIR"]) {
128+ const parentValue = process.env[key];
129+
130+ if (typeof parentValue === "string") {
131+ assert.equal(childEnv[key], parentValue);
132+ }
133+ }
134+ }
135+);
136+
137+test(
138+ "executeCommand does not preflight-block macOS TCC-looking command strings",
139 { concurrency: false },
140 async () => {
141 const result = await withMockedPlatform("darwin", () =>
142 executeCommand({
143- command: "ls /Users/example/Desktop/project",
144+ command: "printf '/Users/example/Desktop/project'",
145 cwd: "/tmp",
146 timeoutMs: 2_000
147 })
148 );
149
150- assert.equal(result.ok, false);
151+ assert.equal(result.ok, true);
152 assert.equal(result.operation, "exec");
153- assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
154- assert.equal(result.error.retryable, false);
155- assert.equal(result.error.details?.accessPoint, "command");
156- assert.equal(result.error.details?.protectedPath, "/Users/example/Desktop");
157- assert.equal(result.error.details?.requiresFullDiskAccess, true);
158- assert.match(result.error.message, /Full Disk Access/);
159- assert.equal(result.result, undefined);
160+ assert.equal(result.result.stdout, "/Users/example/Desktop/project");
161 }
162 );
163
164@@ -97,26 +202,25 @@ test(
165 assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
166 assert.equal(result.error.details?.accessPoint, "cwd");
167 assert.equal(result.error.details?.protectedPath, join(homedir(), "Downloads"));
168+ assertEmptyExecResultShape(result.result);
169 }
170 );
171
172 test(
173- "executeCommand detects ~/Documents style macOS TCC-protected paths",
174+ "executeCommand does not preflight-block ~/Documents style command strings",
175 { concurrency: false },
176 async () => {
177 const result = await withMockedPlatform("darwin", () =>
178 executeCommand({
179- command: "ls ~/Documents/project",
180+ command: "printf '~/Documents/project'",
181 cwd: "/tmp",
182 timeoutMs: 2_000
183 })
184 );
185
186- assert.equal(result.ok, false);
187+ assert.equal(result.ok, true);
188 assert.equal(result.operation, "exec");
189- assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
190- assert.equal(result.error.details?.accessPoint, "command");
191- assert.equal(result.error.details?.protectedPath, join(homedir(), "Documents"));
192+ assert.equal(result.result.stdout, "~/Documents/project");
193 }
194 );
195
+113,
-88
1@@ -21,10 +21,20 @@ export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
2 export const DEFAULT_EXEC_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
3 export const DEFAULT_TEXT_ENCODING = "utf8" as const;
4 const MACOS_TCC_PROTECTED_DIRECTORY_NAMES = ["Desktop", "Documents", "Downloads"] as const;
5-const MACOS_TCC_ABSOLUTE_PATH_PATTERN =
6- /(^|[\s"'`=:(|&;<>])(\/Users\/[^/\s"'`]+\/(?:Desktop|Documents|Downloads))(?=$|[\/\s"'`),:|&;<>])/u;
7-const MACOS_TCC_HOME_PATH_PATTERN =
8- /(^|[\s=:(|&;<>])(~\/(?:Desktop|Documents|Downloads))(?=$|[\/\s"'`),:|&;<>])/u;
9+const SAFE_EXEC_ENV_KEYS = ["HOME", "LANG", "LOGNAME", "PATH", "TERM", "TMPDIR", "USER"] as const;
10+const WINDOWS_SAFE_EXEC_ENV_KEYS = [
11+ "ComSpec",
12+ "HOMEDRIVE",
13+ "HOMEPATH",
14+ "PATH",
15+ "PATHEXT",
16+ "SystemRoot",
17+ "TEMP",
18+ "TMP",
19+ "USERNAME",
20+ "USERPROFILE",
21+ "WINDIR"
22+] as const;
23
24 export type HostOperationName = (typeof HOST_OPERATION_NAMES)[number];
25 export type HostOpErrorCode = (typeof HOST_OP_ERROR_CODES)[number];
26@@ -71,9 +81,9 @@ export interface ExecOperationInput {
27 export interface ExecOperationResult {
28 durationMs: number;
29 exitCode: number | null;
30- finishedAt: string;
31+ finishedAt: string | null;
32 signal: string | null;
33- startedAt: string;
34+ startedAt: string | null;
35 stderr: string;
36 stdout: string;
37 timedOut: boolean;
38@@ -84,11 +94,10 @@ export type ExecOperationSuccess = HostOperationSuccess<
39 ExecOperationInput,
40 ExecOperationResult
41 >;
42-export type ExecOperationFailure = HostOperationFailure<
43- "exec",
44- ExecOperationInput,
45- ExecOperationResult
46->;
47+export interface ExecOperationFailure
48+ extends HostOperationFailure<"exec", ExecOperationInput, ExecOperationResult> {
49+ result: ExecOperationResult;
50+}
51 export type ExecOperationResponse = ExecOperationSuccess | ExecOperationFailure;
52
53 export interface FileReadOperationRequest {
54@@ -190,7 +199,7 @@ interface RuntimeErrorLike {
55
56 interface MacOsTccBlockMatch {
57 protectedPath: string;
58- source: "command" | "cwd";
59+ source: "cwd";
60 }
61
62 interface NormalizedInputSuccess<TInput> {
63@@ -198,14 +207,14 @@ interface NormalizedInputSuccess<TInput> {
64 input: TInput;
65 }
66
67-interface NormalizedInputFailure<TOperation extends HostOperationName, TInput> {
68+interface NormalizedInputFailure<TFailure> {
69 ok: false;
70- response: HostOperationFailure<TOperation, TInput>;
71+ response: TFailure;
72 }
73
74-type NormalizedInputResult<TOperation extends HostOperationName, TInput> =
75+type NormalizedInputResult<TInput, TFailure> =
76 | NormalizedInputSuccess<TInput>
77- | NormalizedInputFailure<TOperation, TInput>;
78+ | NormalizedInputFailure<TFailure>;
79
80 function getDefaultCwd(): string {
81 if (typeof process === "object" && process !== null && typeof process.cwd === "function") {
82@@ -272,6 +281,36 @@ function createHostOpError(
83 return details === undefined ? { code, message, retryable } : { code, message, retryable, details };
84 }
85
86+function createEmptyExecResult(): ExecOperationResult {
87+ return {
88+ durationMs: 0,
89+ exitCode: null,
90+ finishedAt: null,
91+ signal: null,
92+ startedAt: null,
93+ stderr: "",
94+ stdout: "",
95+ timedOut: false
96+ };
97+}
98+
99+function createExecFailureResponse(
100+ input: ExecOperationInput,
101+ error: HostOpError
102+): ExecOperationFailure {
103+ return {
104+ ok: false,
105+ operation: "exec",
106+ input,
107+ error,
108+ result: createEmptyExecResult()
109+ };
110+}
111+
112+function normalizeExecCommandInput(command: unknown): string {
113+ return typeof command === "string" ? command : "";
114+}
115+
116 function resolveOperationPath(pathValue: string, cwd: string): string {
117 return isAbsolute(pathValue) ? pathValue : resolve(cwd, pathValue);
118 }
119@@ -284,6 +323,14 @@ function getProcessPlatform(): string {
120 return "";
121 }
122
123+function getProcessEnv(): Record<string, string | undefined> {
124+ if (typeof process === "object" && process !== null && typeof process.env === "object" && process.env !== null) {
125+ return process.env;
126+ }
127+
128+ return {};
129+}
130+
131 function getProcessExecPath(): string {
132 if (typeof process === "object" && process !== null && isNonEmptyString(process.execPath)) {
133 return process.execPath;
134@@ -292,6 +339,27 @@ function getProcessExecPath(): string {
135 return "node";
136 }
137
138+function getSafeExecEnvKeys(platform = getProcessPlatform()): readonly string[] {
139+ return platform === "win32" ? WINDOWS_SAFE_EXEC_ENV_KEYS : SAFE_EXEC_ENV_KEYS;
140+}
141+
142+function buildSafeExecEnv(
143+ platform = getProcessPlatform(),
144+ sourceEnv: Record<string, string | undefined> = getProcessEnv()
145+): Record<string, string | undefined> {
146+ const safeEnv: Record<string, string | undefined> = {};
147+
148+ for (const key of getSafeExecEnvKeys(platform)) {
149+ const value = sourceEnv[key];
150+
151+ if (typeof value === "string") {
152+ safeEnv[key] = value;
153+ }
154+ }
155+
156+ return safeEnv;
157+}
158+
159 function isMacOs(): boolean {
160 return getProcessPlatform() === "darwin";
161 }
162@@ -321,26 +389,6 @@ function findMacOsTccProtectedCwd(cwd: string, userHomeDirectory = homedir()): s
163 return null;
164 }
165
166-function findMacOsTccProtectedCommandPath(
167- command: string,
168- userHomeDirectory = homedir()
169-): string | null {
170- const absolutePathMatch = MACOS_TCC_ABSOLUTE_PATH_PATTERN.exec(command);
171- const homePathMatch = MACOS_TCC_HOME_PATH_PATTERN.exec(command);
172- const absolutePath = typeof absolutePathMatch?.[2] === "string" ? absolutePathMatch[2] : null;
173- const homePath = typeof homePathMatch?.[2] === "string" ? homePathMatch[2] : null;
174-
175- if (absolutePath === null && homePath === null) {
176- return null;
177- }
178-
179- if (absolutePath !== null && (homePathMatch === null || absolutePathMatch!.index <= homePathMatch.index)) {
180- return resolve(absolutePath);
181- }
182-
183- return resolve(userHomeDirectory, homePath!.slice(2));
184-}
185-
186 function findMacOsTccBlockMatch(input: ExecOperationInput): MacOsTccBlockMatch | null {
187 if (!isMacOs()) {
188 return null;
189@@ -355,16 +403,7 @@ function findMacOsTccBlockMatch(input: ExecOperationInput): MacOsTccBlockMatch |
190 };
191 }
192
193- const protectedCommandPath = findMacOsTccProtectedCommandPath(input.command);
194-
195- if (protectedCommandPath === null) {
196- return null;
197- }
198-
199- return {
200- protectedPath: protectedCommandPath,
201- source: "command"
202- };
203+ return null;
204 }
205
206 function createMacOsTccPermissionDeniedResponse(
207@@ -372,18 +411,12 @@ function createMacOsTccPermissionDeniedResponse(
208 blockMatch: MacOsTccBlockMatch
209 ): ExecOperationFailure {
210 const nodeBinary = getProcessExecPath();
211- const blockedVia =
212- blockMatch.source === "cwd"
213- ? `Command cwd resolves inside macOS TCC-protected path ${blockMatch.protectedPath}.`
214- : `Command references macOS TCC-protected path ${blockMatch.protectedPath}.`;
215
216- return {
217- ok: false,
218- operation: "exec",
219+ return createExecFailureResponse(
220 input,
221- error: createHostOpError(
222+ createHostOpError(
223 "TCC_PERMISSION_DENIED",
224- `${blockedVia} Grant Full Disk Access to the Node.js binary running conductor-daemon (${nodeBinary}) and retry.`,
225+ `Command cwd resolves inside macOS TCC-protected path ${blockMatch.protectedPath}. Grant Full Disk Access to the Node.js binary running conductor-daemon (${nodeBinary}) and retry.`,
226 false,
227 {
228 accessPoint: blockMatch.source,
229@@ -392,7 +425,7 @@ function createMacOsTccPermissionDeniedResponse(
230 requiresFullDiskAccess: true
231 }
232 )
233- };
234+ );
235 }
236
237 function getErrorLike(error: unknown): RuntimeErrorLike {
238@@ -425,23 +458,21 @@ function isRetryableFsErrorCode(code: string | undefined): boolean {
239
240 function normalizeExecRequest(
241 request: ExecOperationRequest
242-): NormalizedInputResult<"exec", ExecOperationInput> {
243+): NormalizedInputResult<ExecOperationInput, ExecOperationFailure> {
244 const cwd = normalizeCwd(request.cwd);
245
246 if (cwd === null) {
247 return {
248 ok: false,
249- response: {
250- ok: false,
251- operation: "exec",
252- input: {
253- command: request.command,
254+ response: createExecFailureResponse(
255+ {
256+ command: normalizeExecCommandInput(request.command),
257 cwd: getDefaultCwd(),
258 maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
259 timeoutMs: request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
260 },
261- error: createHostOpError("INVALID_INPUT", "exec.cwd must be a non-empty string when provided.", false)
262- }
263+ createHostOpError("INVALID_INPUT", "exec.cwd must be a non-empty string when provided.", false)
264+ )
265 };
266 }
267
268@@ -450,17 +481,15 @@ function normalizeExecRequest(
269 if (timeoutMs === null) {
270 return {
271 ok: false,
272- response: {
273- ok: false,
274- operation: "exec",
275- input: {
276- command: request.command,
277+ response: createExecFailureResponse(
278+ {
279+ command: normalizeExecCommandInput(request.command),
280 cwd,
281 maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
282 timeoutMs: request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
283 },
284- error: createHostOpError("INVALID_INPUT", "exec.timeoutMs must be an integer >= 0.", false)
285- }
286+ createHostOpError("INVALID_INPUT", "exec.timeoutMs must be an integer >= 0.", false)
287+ )
288 };
289 }
290
291@@ -472,34 +501,30 @@ function normalizeExecRequest(
292 if (maxBufferBytes === null) {
293 return {
294 ok: false,
295- response: {
296- ok: false,
297- operation: "exec",
298- input: {
299- command: request.command,
300+ response: createExecFailureResponse(
301+ {
302+ command: normalizeExecCommandInput(request.command),
303 cwd,
304 maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
305 timeoutMs
306 },
307- error: createHostOpError("INVALID_INPUT", "exec.maxBufferBytes must be an integer > 0.", false)
308- }
309+ createHostOpError("INVALID_INPUT", "exec.maxBufferBytes must be an integer > 0.", false)
310+ )
311 };
312 }
313
314 if (!isNonEmptyString(request.command)) {
315 return {
316 ok: false,
317- response: {
318- ok: false,
319- operation: "exec",
320- input: {
321- command: typeof request.command === "string" ? request.command : "",
322+ response: createExecFailureResponse(
323+ {
324+ command: normalizeExecCommandInput(request.command),
325 cwd,
326 maxBufferBytes,
327 timeoutMs
328 },
329- error: createHostOpError("INVALID_INPUT", "exec.command must be a non-empty string.", false)
330- }
331+ createHostOpError("INVALID_INPUT", "exec.command must be a non-empty string.", false)
332+ )
333 };
334 }
335
336@@ -516,7 +541,7 @@ function normalizeExecRequest(
337
338 function normalizeReadRequest(
339 request: FileReadOperationRequest
340-): NormalizedInputResult<"files/read", FileReadOperationInput> {
341+): NormalizedInputResult<FileReadOperationInput, FileReadOperationFailure> {
342 const cwd = normalizeCwd(request.cwd);
343
344 if (cwd === null) {
345@@ -581,7 +606,7 @@ function normalizeReadRequest(
346
347 function normalizeWriteRequest(
348 request: FileWriteOperationRequest
349-): NormalizedInputResult<"files/write", FileWriteOperationInput> {
350+): NormalizedInputResult<FileWriteOperationInput, FileWriteOperationFailure> {
351 const cwd = normalizeCwd(request.cwd);
352 const createParents = normalizeBoolean(request.createParents, true);
353 const overwrite = normalizeBoolean(request.overwrite, true);
354@@ -762,7 +787,7 @@ export async function executeCommand(request: ExecOperationRequest): Promise<Exe
355 normalized.input.command,
356 {
357 cwd: normalized.input.cwd,
358- env: process?.env ?? {},
359+ env: buildSafeExecEnv(),
360 maxBuffer: normalized.input.maxBufferBytes,
361 timeout: normalized.input.timeoutMs
362 },