baa-conductor

git clone 

commit
c0a641f
parent
77573ae
author
im_wower
date
2026-03-22 22:41:08 +0800 CST
fix(conductor-daemon): require shared token for host ops
7 files changed,  +517, -124
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
+95, -80
  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@@ -674,8 +676,20 @@ test("handleConductorHttpRequest keeps degraded runtimes observable but not read
 27 });
 28 
 29 test("handleConductorHttpRequest serves the migrated local business endpoints from the local repository", async () => {
 30-  const { repository, snapshot } = await createLocalApiFixture();
 31+  const { repository, sharedToken, snapshot } = await createLocalApiFixture();
 32   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-local-host-http-"));
 33+  const authorizedHeaders = {
 34+    authorization: `Bearer ${sharedToken}`
 35+  };
 36+  const localApiContext = {
 37+    repository,
 38+    sharedToken,
 39+    snapshotLoader: () => snapshot
 40+  };
 41+  const versionedLocalApiContext = {
 42+    ...localApiContext,
 43+    version: "1.2.3"
 44+  };
 45 
 46   try {
 47     const describeResponse = await handleConductorHttpRequest(
 48@@ -683,11 +697,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 49         method: "GET",
 50         path: "/describe"
 51       },
 52-      {
 53-        repository,
 54-        snapshotLoader: () => snapshot,
 55-        version: "1.2.3"
 56-      }
 57+      versionedLocalApiContext
 58     );
 59     assert.equal(describeResponse.status, 200);
 60     const describePayload = parseJsonBody(describeResponse);
 61@@ -697,17 +707,15 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 62     assert.equal(describePayload.data.describe_endpoints.business.path, "/describe/business");
 63     assert.equal(describePayload.data.describe_endpoints.control.path, "/describe/control");
 64     assert.equal(describePayload.data.host_operations.enabled, true);
 65+    assert.equal(describePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
 66+    assert.equal(describePayload.data.host_operations.auth.configured, true);
 67 
 68     const businessDescribeResponse = await handleConductorHttpRequest(
 69       {
 70         method: "GET",
 71         path: "/describe/business"
 72       },
 73-      {
 74-        repository,
 75-        snapshotLoader: () => snapshot,
 76-        version: "1.2.3"
 77-      }
 78+      versionedLocalApiContext
 79     );
 80     assert.equal(businessDescribeResponse.status, 200);
 81     const businessDescribePayload = parseJsonBody(businessDescribeResponse);
 82@@ -721,11 +729,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 83         method: "GET",
 84         path: "/describe/control"
 85       },
 86-      {
 87-        repository,
 88-        snapshotLoader: () => snapshot,
 89-        version: "1.2.3"
 90-      }
 91+      versionedLocalApiContext
 92     );
 93     assert.equal(controlDescribeResponse.status, 200);
 94     const controlDescribePayload = parseJsonBody(controlDescribeResponse);
 95@@ -733,17 +737,14 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 96     assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
 97     assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
 98     assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
 99+    assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
100 
101     const healthResponse = await handleConductorHttpRequest(
102       {
103         method: "GET",
104         path: "/health"
105       },
106-      {
107-        repository,
108-        snapshotLoader: () => snapshot,
109-        version: "1.2.3"
110-      }
111+      versionedLocalApiContext
112     );
113     assert.equal(healthResponse.status, 200);
114     assert.equal(parseJsonBody(healthResponse).data.status, "ok");
115@@ -753,10 +754,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
116         method: "GET",
117         path: "/v1/controllers?limit=5"
118       },
119-      {
120-        repository,
121-        snapshotLoader: () => snapshot
122-      }
123+      localApiContext
124     );
125     const controllersPayload = parseJsonBody(controllersResponse);
126     assert.equal(controllersPayload.data.count, 1);
127@@ -768,10 +766,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
128         method: "GET",
129         path: "/v1/tasks?status=running&limit=5"
130       },
131-      {
132-        repository,
133-        snapshotLoader: () => snapshot
134-      }
135+      localApiContext
136     );
137     const tasksPayload = parseJsonBody(tasksResponse);
138     assert.equal(tasksPayload.data.count, 1);
139@@ -782,10 +777,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
140         method: "GET",
141         path: "/v1/tasks/task_demo"
142       },
143-      {
144-        repository,
145-        snapshotLoader: () => snapshot
146-      }
147+      localApiContext
148     );
149     assert.equal(parseJsonBody(taskResponse).data.task_id, "task_demo");
150 
151@@ -794,10 +786,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
152         method: "GET",
153         path: "/v1/tasks/task_demo/logs?limit=10"
154       },
155-      {
156-        repository,
157-        snapshotLoader: () => snapshot
158-      }
159+      localApiContext
160     );
161     const taskLogsPayload = parseJsonBody(taskLogsResponse);
162     assert.equal(taskLogsPayload.data.task_id, "task_demo");
163@@ -809,10 +798,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
164         method: "GET",
165         path: "/v1/runs?limit=5"
166       },
167-      {
168-        repository,
169-        snapshotLoader: () => snapshot
170-      }
171+      localApiContext
172     );
173     const runsPayload = parseJsonBody(runsResponse);
174     assert.equal(runsPayload.data.count, 1);
175@@ -823,10 +809,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
176         method: "GET",
177         path: "/v1/runs/run_demo"
178       },
179-      {
180-        repository,
181-        snapshotLoader: () => snapshot
182-      }
183+      localApiContext
184     );
185     assert.equal(parseJsonBody(runResponse).data.run_id, "run_demo");
186 
187@@ -839,15 +822,46 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
188         method: "POST",
189         path: "/v1/system/pause"
190       },
191-      {
192-        repository,
193-        snapshotLoader: () => snapshot
194-      }
195+      localApiContext
196     );
197     assert.equal(pauseResponse.status, 200);
198     assert.equal(parseJsonBody(pauseResponse).data.mode, "paused");
199     assert.equal((await repository.getAutomationState())?.mode, "paused");
200 
201+    const missingTokenExecResponse = await handleConductorHttpRequest(
202+      {
203+        body: JSON.stringify({
204+          command: "printf 'host-http-ok'",
205+          cwd: hostOpsDir,
206+          timeoutMs: 2000
207+        }),
208+        method: "POST",
209+        path: "/v1/exec"
210+      },
211+      localApiContext
212+    );
213+    assert.equal(missingTokenExecResponse.status, 401);
214+    const missingTokenExecPayload = parseJsonBody(missingTokenExecResponse);
215+    assert.equal(missingTokenExecPayload.error, "unauthorized");
216+
217+    const invalidTokenReadResponse = await handleConductorHttpRequest(
218+      {
219+        body: JSON.stringify({
220+          path: "notes/demo.txt",
221+          cwd: hostOpsDir
222+        }),
223+        headers: {
224+          authorization: "Bearer wrong-token"
225+        },
226+        method: "POST",
227+        path: "/v1/files/read"
228+      },
229+      localApiContext
230+    );
231+    assert.equal(invalidTokenReadResponse.status, 401);
232+    const invalidTokenReadPayload = parseJsonBody(invalidTokenReadResponse);
233+    assert.equal(invalidTokenReadPayload.error, "unauthorized");
234+
235     const writeResponse = await handleConductorHttpRequest(
236       {
237         body: JSON.stringify({
238@@ -857,13 +871,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
239           overwrite: false,
240           createParents: true
241         }),
242+        headers: authorizedHeaders,
243         method: "POST",
244         path: "/v1/files/write"
245       },
246-      {
247-        repository,
248-        snapshotLoader: () => snapshot
249-      }
250+      localApiContext
251     );
252     assert.equal(writeResponse.status, 200);
253     const writePayload = parseJsonBody(writeResponse);
254@@ -877,13 +889,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
255           path: "notes/demo.txt",
256           cwd: hostOpsDir
257         }),
258+        headers: authorizedHeaders,
259         method: "POST",
260         path: "/v1/files/read"
261       },
262-      {
263-        repository,
264-        snapshotLoader: () => snapshot
265-      }
266+      localApiContext
267     );
268     assert.equal(readResponse.status, 200);
269     const readPayload = parseJsonBody(readResponse);
270@@ -898,13 +908,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
271           content: "should not overwrite",
272           overwrite: false
273         }),
274+        headers: authorizedHeaders,
275         method: "POST",
276         path: "/v1/files/write"
277       },
278-      {
279-        repository,
280-        snapshotLoader: () => snapshot
281-      }
282+      localApiContext
283     );
284     assert.equal(duplicateWriteResponse.status, 200);
285     const duplicateWritePayload = parseJsonBody(duplicateWriteResponse);
286@@ -918,13 +926,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
287           cwd: hostOpsDir,
288           timeoutMs: 2000
289         }),
290+        headers: authorizedHeaders,
291         method: "POST",
292         path: "/v1/exec"
293       },
294-      {
295-        repository,
296-        snapshotLoader: () => snapshot
297-      }
298+      localApiContext
299     );
300     assert.equal(execResponse.status, 200);
301     const execPayload = parseJsonBody(execResponse);
302@@ -937,10 +943,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
303         method: "GET",
304         path: "/v1/system/state"
305       },
306-      {
307-        repository,
308-        snapshotLoader: () => snapshot
309-      }
310+      localApiContext
311     );
312     assert.equal(parseJsonBody(systemStateResponse).data.mode, "paused");
313   } finally {
314@@ -979,6 +982,10 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
315   assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
316 
317   const baseUrl = snapshot.controlApi.localApiBase;
318+  const hostOpsHeaders = {
319+    authorization: "Bearer replace-me",
320+    "content-type": "application/json"
321+  };
322 
323   const healthResponse = await fetch(`${baseUrl}/healthz`);
324   assert.equal(healthResponse.status, 200);
325@@ -1048,8 +1055,9 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
326   const controlDescribePayload = await controlDescribeResponse.json();
327   assert.equal(controlDescribePayload.data.surface, "control");
328   assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
329+  assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
330 
331-  const execResponse = await fetch(`${baseUrl}/v1/exec`, {
332+  const unauthorizedExecResponse = await fetch(`${baseUrl}/v1/exec`, {
333     method: "POST",
334     headers: {
335       "content-type": "application/json"
336@@ -1060,6 +1068,19 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
337       timeoutMs: 2000
338     })
339   });
340+  assert.equal(unauthorizedExecResponse.status, 401);
341+  const unauthorizedExecPayload = await unauthorizedExecResponse.json();
342+  assert.equal(unauthorizedExecPayload.error, "unauthorized");
343+
344+  const execResponse = await fetch(`${baseUrl}/v1/exec`, {
345+    method: "POST",
346+    headers: hostOpsHeaders,
347+    body: JSON.stringify({
348+      command: "printf 'runtime-host-op'",
349+      cwd: hostOpsDir,
350+      timeoutMs: 2000
351+    })
352+  });
353   assert.equal(execResponse.status, 200);
354   const execPayload = await execResponse.json();
355   assert.equal(execPayload.data.ok, true);
356@@ -1067,9 +1088,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
357 
358   const writeResponse = await fetch(`${baseUrl}/v1/files/write`, {
359     method: "POST",
360-    headers: {
361-      "content-type": "application/json"
362-    },
363+    headers: hostOpsHeaders,
364     body: JSON.stringify({
365       path: "runtime/demo.txt",
366       cwd: hostOpsDir,
367@@ -1085,9 +1104,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
368 
369   const duplicateWriteResponse = await fetch(`${baseUrl}/v1/files/write`, {
370     method: "POST",
371-    headers: {
372-      "content-type": "application/json"
373-    },
374+    headers: hostOpsHeaders,
375     body: JSON.stringify({
376       path: "runtime/demo.txt",
377       cwd: hostOpsDir,
378@@ -1102,9 +1119,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
379 
380   const readResponse = await fetch(`${baseUrl}/v1/files/read`, {
381     method: "POST",
382-    headers: {
383-      "content-type": "application/json"
384-    },
385+    headers: hostOpsHeaders,
386     body: JSON.stringify({
387       path: "runtime/demo.txt",
388       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
+32, -0
 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@@ -124,25 +133,48 @@
18 - 外层 `ok` / `request_id` 属于 `conductor-daemon` HTTP 协议
19 - 内层 `data.ok` / `data.operation` / `data.error` 属于 `host-ops` 结构化结果
20 
21+未授权示例:
22+
23+```json
24+{
25+  "ok": false,
26+  "request_id": "req_xxx",
27+  "error": "unauthorized",
28+  "message": "POST /v1/exec requires Authorization: Bearer <BAA_SHARED_TOKEN>.",
29+  "details": {
30+    "auth_scheme": "Bearer",
31+    "env_var": "BAA_SHARED_TOKEN",
32+    "route_id": "host.exec",
33+    "reason": "missing_authorization_header"
34+  }
35+}
36+```
37+
38 ## Minimal Curl
39 
40 ```bash
41 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
42+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
43 curl -X POST "${LOCAL_API_BASE}/v1/exec" \
44+  -H "Authorization: Bearer ${SHARED_TOKEN}" \
45   -H 'Content-Type: application/json' \
46   -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
47 ```
48 
49 ```bash
50 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
51+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
52 curl -X POST "${LOCAL_API_BASE}/v1/files/read" \
53+  -H "Authorization: Bearer ${SHARED_TOKEN}" \
54   -H 'Content-Type: application/json' \
55   -d '{"path":"README.md","cwd":"/Users/george/code/baa-conductor","encoding":"utf8"}'
56 ```
57 
58 ```bash
59 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
60+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
61 curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
62+  -H "Authorization: Bearer ${SHARED_TOKEN}" \
63   -H 'Content-Type: application/json' \
64   -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
65 ```