baa-conductor

git clone 

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
M README.md
+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 错误
M apps/conductor-daemon/src/index.test.js
+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
M apps/conductor-daemon/src/index.ts
+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           );
M apps/conductor-daemon/src/local-api.ts
+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       },
M docs/api/README.md
+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 ```
M docs/api/control-interfaces.md
+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 ## 当前边界
M docs/api/local-host-ops.md
+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 自身权限决定。
M packages/host-ops/src/index.test.js
+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 
M packages/host-ops/src/index.ts
+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       },