- 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
+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 错误
+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
+25,
-0
1@@ -533,11 +533,31 @@ async function readIncomingRequestBody(request: IncomingMessage): Promise<string
2 });
3 }
4
5+function normalizeIncomingRequestHeaders(
6+ headers: IncomingMessage["headers"]
7+): Record<string, string | undefined> {
8+ const normalized: Record<string, string | undefined> = {};
9+
10+ for (const [name, value] of Object.entries(headers)) {
11+ if (typeof value === "string") {
12+ normalized[name] = value;
13+ continue;
14+ }
15+
16+ if (Array.isArray(value)) {
17+ normalized[name] = value.join(", ");
18+ }
19+ }
20+
21+ return normalized;
22+}
23+
24 class ConductorLocalHttpServer {
25 private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
26 private readonly localApiBase: string;
27 private readonly now: () => number;
28 private readonly repository: ControlPlaneRepository;
29+ private readonly sharedToken: string | null;
30 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
31 private readonly version: string | null;
32 private resolvedBaseUrl: string;
33@@ -547,12 +567,14 @@ class ConductorLocalHttpServer {
34 localApiBase: string,
35 repository: ControlPlaneRepository,
36 snapshotLoader: () => ConductorRuntimeSnapshot,
37+ sharedToken: string | null,
38 version: string | null,
39 now: () => number
40 ) {
41 this.localApiBase = localApiBase;
42 this.now = now;
43 this.repository = repository;
44+ this.sharedToken = sharedToken;
45 this.snapshotLoader = snapshotLoader;
46 this.version = version;
47 this.resolvedBaseUrl = localApiBase;
48@@ -583,11 +605,13 @@ class ConductorLocalHttpServer {
49 const payload = await handleConductorLocalHttpRequest(
50 {
51 body: await readIncomingRequestBody(request),
52+ headers: normalizeIncomingRequestHeaders(request.headers),
53 method: request.method ?? "GET",
54 path: request.url ?? "/"
55 },
56 {
57 repository: this.repository,
58+ sharedToken: this.sharedToken,
59 snapshotLoader: this.snapshotLoader,
60 version: this.version
61 }
62@@ -1772,6 +1796,7 @@ export class ConductorRuntime {
63 this.config.localApiBase,
64 this.localControlPlane.repository,
65 () => this.getRuntimeSnapshot(),
66+ this.config.sharedToken,
67 this.config.version,
68 this.now
69 );
+324,
-39
1@@ -36,10 +36,18 @@ const DEFAULT_LOG_LIMIT = 200;
2 const MAX_LIST_LIMIT = 100;
3 const MAX_LOG_LIMIT = 500;
4 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
5+const HOST_OPERATIONS_ROUTE_IDS = new Set(["host.exec", "host.files.read", "host.files.write"]);
6+const HOST_OPERATIONS_AUTH_HEADER = "Authorization: Bearer <BAA_SHARED_TOKEN>";
7+const HOST_OPERATIONS_WWW_AUTHENTICATE = 'Bearer realm="baa-conductor-host-ops"';
8
9 type LocalApiRouteMethod = "GET" | "POST";
10 type LocalApiRouteKind = "probe" | "read" | "write";
11 type LocalApiDescribeSurface = "business" | "control";
12+type SharedTokenAuthFailureReason =
13+ | "empty_bearer_token"
14+ | "invalid_authorization_scheme"
15+ | "invalid_token"
16+ | "missing_authorization_header";
17
18 interface LocalApiRouteDefinition {
19 id: string;
20@@ -61,6 +69,7 @@ interface LocalApiRequestContext {
21 repository: ControlPlaneRepository | null;
22 request: ConductorHttpRequest;
23 requestId: string;
24+ sharedToken: string | null;
25 snapshotLoader: () => ConductorRuntimeApiSnapshot;
26 url: URL;
27 }
28@@ -96,6 +105,7 @@ export interface ConductorRuntimeApiSnapshot {
29 export interface ConductorLocalApiContext {
30 now?: () => number;
31 repository: ControlPlaneRepository | null;
32+ sharedToken?: string | null;
33 snapshotLoader: () => ConductorRuntimeApiSnapshot;
34 version?: string | null;
35 }
36@@ -309,6 +319,15 @@ function isJsonObject(value: JsonValue | null): value is JsonObject {
37 return value !== null && typeof value === "object" && !Array.isArray(value);
38 }
39
40+function normalizeOptionalString(value: string | null | undefined): string | null {
41+ if (value == null) {
42+ return null;
43+ }
44+
45+ const normalized = value.trim();
46+ return normalized === "" ? null : normalized;
47+}
48+
49 function buildSuccessEnvelope(
50 requestId: string,
51 status: number,
52@@ -674,9 +693,119 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
53 };
54 }
55
56-function buildHostOperationsData(origin: string): JsonObject {
57+function isHostOperationsRoute(route: LocalApiRouteDefinition): boolean {
58+ return HOST_OPERATIONS_ROUTE_IDS.has(route.id);
59+}
60+
61+function readHeaderValue(request: ConductorHttpRequest, headerName: string): string | undefined {
62+ const headers = request.headers;
63+
64+ if (headers == null) {
65+ return undefined;
66+ }
67+
68+ const exactMatch = headers[headerName];
69+
70+ if (typeof exactMatch === "string") {
71+ return exactMatch;
72+ }
73+
74+ const normalizedHeaderName = headerName.toLowerCase();
75+ const lowerCaseMatch = headers[normalizedHeaderName];
76+
77+ if (typeof lowerCaseMatch === "string") {
78+ return lowerCaseMatch;
79+ }
80+
81+ for (const [name, value] of Object.entries(headers)) {
82+ if (name.toLowerCase() === normalizedHeaderName && typeof value === "string") {
83+ return value;
84+ }
85+ }
86+
87+ return undefined;
88+}
89+
90+function extractBearerToken(
91+ authorizationHeader: string | undefined
92+): { ok: true; token: string } | { ok: false; reason: SharedTokenAuthFailureReason } {
93+ if (!authorizationHeader) {
94+ return {
95+ ok: false,
96+ reason: "missing_authorization_header"
97+ };
98+ }
99+
100+ const [scheme, ...rest] = authorizationHeader.trim().split(/\s+/u);
101+
102+ if (scheme !== "Bearer") {
103+ return {
104+ ok: false,
105+ reason: "invalid_authorization_scheme"
106+ };
107+ }
108+
109+ const token = rest.join(" ").trim();
110+
111+ if (token === "") {
112+ return {
113+ ok: false,
114+ reason: "empty_bearer_token"
115+ };
116+ }
117+
118+ return {
119+ ok: true,
120+ token
121+ };
122+}
123+
124+function buildHostOperationsAuthData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
125+ return {
126+ configured: snapshot.controlApi.hasSharedToken,
127+ env_var: "BAA_SHARED_TOKEN",
128+ header: HOST_OPERATIONS_AUTH_HEADER,
129+ mode: "bearer_shared_token",
130+ protected_routes: ["/v1/exec", "/v1/files/read", "/v1/files/write"],
131+ public_access: "anonymous_denied",
132+ uses_placeholder_token: snapshot.controlApi.usesPlaceholderToken
133+ };
134+}
135+
136+function buildHttpAuthData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
137+ return {
138+ mode: "mixed",
139+ default_routes: {
140+ access: "local_network",
141+ header_required: false
142+ },
143+ host_operations: buildHostOperationsAuthData(snapshot)
144+ };
145+}
146+
147+function buildHostOperationsNotes(snapshot: ConductorRuntimeApiSnapshot): string[] {
148+ const notes = [
149+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>.",
150+ "Missing or incorrect bearer tokens return a 401 JSON error and do not execute any host operation.",
151+ "These host-ops routes are not anonymously public, even if this listener is reverse-proxied or tunneled.",
152+ "These three routes still return the outer conductor success envelope; the inner operation result remains in data.",
153+ "Request bodies should prefer camelCase fields; timeout_ms, max_buffer_bytes and create_parents remain accepted aliases.",
154+ "These operations act only on the current node and do not hop through control-api-worker."
155+ ];
156+
157+ if (!snapshot.controlApi.hasSharedToken) {
158+ notes.push("BAA_SHARED_TOKEN is not configured on this node; host operations stay unavailable until it is set.");
159+ } else if (snapshot.controlApi.usesPlaceholderToken) {
160+ notes.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before exposing this surface.");
161+ }
162+
163+ return notes;
164+}
165+
166+function buildHostOperationsData(origin: string, snapshot: ConductorRuntimeApiSnapshot): JsonObject {
167 return {
168 enabled: true,
169+ auth: buildHostOperationsAuthData(snapshot),
170 contract: "HTTP success envelope data 直接嵌入 @baa-conductor/host-ops 的结构化 result union。",
171 semantics: {
172 cwd: "可选字符串;省略时使用 conductor-daemon 进程当前工作目录。",
173@@ -698,11 +827,18 @@ function buildHostOperationsData(origin: string): JsonObject {
174 timeoutMs: DEFAULT_EXEC_TIMEOUT_MS,
175 maxBufferBytes: DEFAULT_EXEC_MAX_BUFFER_BYTES
176 },
177- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
178- command: "printf 'hello from conductor'",
179- cwd: "/tmp",
180- timeoutMs: 2000
181- })
182+ curl: buildCurlExample(
183+ origin,
184+ LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
185+ {
186+ command: "printf 'hello from conductor'",
187+ cwd: "/tmp",
188+ timeoutMs: 2000
189+ },
190+ {
191+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
192+ }
193+ )
194 },
195 {
196 method: "POST",
197@@ -720,6 +856,9 @@ function buildHostOperationsData(origin: string): JsonObject {
198 path: "README.md",
199 cwd: "/Users/george/code/baa-conductor",
200 encoding: "utf8"
201+ },
202+ {
203+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
204 }
205 )
206 },
207@@ -745,15 +884,14 @@ function buildHostOperationsData(origin: string): JsonObject {
208 overwrite: true,
209 createParents: true,
210 encoding: "utf8"
211+ },
212+ {
213+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
214 }
215 )
216 }
217 ],
218- notes: [
219- "这三条路由总是返回外层 conductor success envelope;具体操作成功或失败看 data.ok。",
220- "请求体优先使用 host-ops 的 camelCase 字段;daemon 也接受 timeout_ms、max_buffer_bytes、create_parents 作为兼容别名。",
221- "这些操作只作用于当前本机节点,不会经过 control-api-worker。"
222- ]
223+ notes: buildHostOperationsNotes(snapshot)
224 };
225 }
226
227@@ -765,7 +903,15 @@ function describeRoute(route: LocalApiRouteDefinition): JsonObject {
228 kind: route.kind === "probe" ? "read" : route.kind,
229 implementation: "implemented",
230 summary: route.summary,
231- access: "local_network"
232+ access: isHostOperationsRoute(route) ? "bearer_shared_token" : "local_network",
233+ ...(isHostOperationsRoute(route)
234+ ? {
235+ auth: {
236+ header: HOST_OPERATIONS_AUTH_HEADER,
237+ required: true
238+ }
239+ }
240+ : {})
241 };
242 }
243
244@@ -822,7 +968,8 @@ function buildCapabilitiesData(
245
246 return {
247 deployment_mode: "single-node mini",
248- auth_mode: "local_network_only",
249+ auth: buildHttpAuthData(snapshot),
250+ auth_mode: "mixed",
251 repository_configured: true,
252 truth_source: "local sqlite control plane",
253 workflow: [
254@@ -844,24 +991,31 @@ function buildCapabilitiesData(
255 },
256 transports: {
257 http: {
258- auth_mode: "local_network_only",
259+ auth: buildHttpAuthData(snapshot),
260+ auth_mode: "mixed",
261 url: snapshot.controlApi.localApiBase ?? null
262 },
263 websocket: buildFirefoxWebSocketData(snapshot)
264 },
265- host_operations: buildHostOperationsData(origin)
266+ host_operations: buildHostOperationsData(origin, snapshot)
267 };
268 }
269
270 function buildCurlExample(
271 origin: string,
272 route: LocalApiRouteDefinition,
273- body?: JsonObject
274+ body?: JsonObject,
275+ options: {
276+ bearerTokenEnvVar?: string;
277+ } = {}
278 ): string {
279 const serializedBody = body ? JSON.stringify(body).replaceAll("'", "'\"'\"'") : null;
280- const payload = body
281- ? ` \\\n -H 'Content-Type: application/json' \\\n -d '${serializedBody}'`
282+ const authHeader = options.bearerTokenEnvVar
283+ ? ` \\\n -H "Authorization: Bearer \${${options.bearerTokenEnvVar}}"`
284 : "";
285+ const payload = body
286+ ? `${authHeader} \\\n -H 'Content-Type: application/json' \\\n -d '${serializedBody}'`
287+ : authHeader;
288 return `curl -X ${route.method} '${origin}${route.pathPattern}'${payload}`;
289 }
290
291@@ -879,10 +1033,12 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
292 summary: "single-node mini local daemon",
293 deployment_mode: "single-node mini",
294 topology: "No Cloudflare Worker or D1 control-plane hop is required for these routes.",
295- auth_mode: "local_network_only",
296+ auth: buildHttpAuthData(snapshot),
297+ auth_mode: "mixed",
298 truth_source: "local sqlite control plane",
299 origin
300 },
301+ auth: buildHttpAuthData(snapshot),
302 system,
303 websocket: buildFirefoxWebSocketData(snapshot),
304 describe_endpoints: {
305@@ -892,7 +1048,8 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
306 },
307 control: {
308 path: "/describe/control",
309- summary: "控制和本机 host-ops 入口;在 pause/resume/drain 或 exec/files/* 前先读。"
310+ summary:
311+ "控制和本机 host-ops 入口;在 pause/resume/drain 或 exec/files/* 前先读。/v1/exec 和 /v1/files/* 需要 shared token。"
312 }
313 },
314 endpoints: [
315@@ -906,7 +1063,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
316 }
317 ],
318 capabilities: buildCapabilitiesData(snapshot),
319- host_operations: buildHostOperationsData(origin),
320+ host_operations: buildHostOperationsData(origin, snapshot),
321 examples: [
322 {
323 title: "Read the business describe surface first",
324@@ -955,15 +1112,23 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
325 title: "Run a small local command",
326 method: "POST",
327 path: "/v1/exec",
328- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
329- command: "printf 'hello from conductor'",
330- cwd: "/tmp",
331- timeoutMs: 2000
332- })
333+ curl: buildCurlExample(
334+ origin,
335+ LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
336+ {
337+ command: "printf 'hello from conductor'",
338+ cwd: "/tmp",
339+ timeoutMs: 2000
340+ },
341+ {
342+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
343+ }
344+ )
345 }
346 ],
347 notes: [
348 "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
349+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
350 "These routes read and mutate the mini node's local truth source directly.",
351 "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
352 "The optional Firefox bridge WS reuses the same local listener and upgrades on /ws/firefox."
353@@ -991,6 +1156,8 @@ async function handleScopedDescribeRead(
354 audience: ["cli_ai", "web_ai", "mobile_web_ai"],
355 environment: {
356 deployment_mode: "single-node mini",
357+ auth: buildHttpAuthData(snapshot),
358+ auth_mode: "mixed",
359 truth_source: "local sqlite control plane",
360 origin
361 },
362@@ -1019,6 +1186,7 @@ async function handleScopedDescribeRead(
363 ],
364 notes: [
365 "This surface is intended to be enough for business-query discovery without reading external docs.",
366+ "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
367 "Control actions and host-level exec/file operations are intentionally excluded; use /describe/control."
368 ]
369 });
370@@ -1032,6 +1200,8 @@ async function handleScopedDescribeRead(
371 audience: ["cli_ai", "web_ai", "mobile_web_ai", "human_operator"],
372 environment: {
373 deployment_mode: "single-node mini",
374+ auth: buildHttpAuthData(snapshot),
375+ auth_mode: "mixed",
376 truth_source: "local sqlite control plane",
377 origin
378 },
379@@ -1044,7 +1214,8 @@ async function handleScopedDescribeRead(
380 ],
381 system,
382 websocket: buildFirefoxWebSocketData(snapshot),
383- host_operations: buildHostOperationsData(origin),
384+ auth: buildHttpAuthData(snapshot),
385+ host_operations: buildHostOperationsData(origin, snapshot),
386 endpoints: routes.map(describeRoute),
387 examples: [
388 {
389@@ -1067,28 +1238,43 @@ async function handleScopedDescribeRead(
390 title: "Run a small local command",
391 method: "POST",
392 path: "/v1/exec",
393- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
394- command: "printf 'hello from conductor'",
395- cwd: "/tmp",
396- timeoutMs: 2000
397- })
398+ curl: buildCurlExample(
399+ origin,
400+ LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
401+ {
402+ command: "printf 'hello from conductor'",
403+ cwd: "/tmp",
404+ timeoutMs: 2000
405+ },
406+ {
407+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
408+ }
409+ )
410 },
411 {
412 title: "Write a local file safely",
413 method: "POST",
414 path: "/v1/files/write",
415- curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!, {
416- path: "tmp/conductor-note.txt",
417- cwd: "/Users/george/code/baa-conductor",
418- content: "hello from conductor",
419- overwrite: false,
420- createParents: true
421- })
422+ curl: buildCurlExample(
423+ origin,
424+ LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!,
425+ {
426+ path: "tmp/conductor-note.txt",
427+ cwd: "/Users/george/code/baa-conductor",
428+ content: "hello from conductor",
429+ overwrite: false,
430+ createParents: true
431+ },
432+ {
433+ bearerTokenEnvVar: "BAA_SHARED_TOKEN"
434+ }
435+ )
436 }
437 ],
438 notes: [
439 "This surface is intended to be enough for control discovery without reading external docs.",
440 "Business queries such as tasks and runs are intentionally excluded; use /describe/business.",
441+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
442 "Host operations return the structured host-ops union inside the outer conductor HTTP envelope."
443 ]
444 });
445@@ -1131,6 +1317,7 @@ async function handleCapabilitiesRead(
446 notes: [
447 "Read routes are safe for discovery and inspection.",
448 "POST /v1/system/* writes the local automation mode immediately.",
449+ "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
450 "POST /v1/exec and POST /v1/files/* return the structured host-ops union in data."
451 ]
452 });
453@@ -1144,6 +1331,102 @@ async function handleSystemStateRead(context: LocalApiRequestContext): Promise<C
454 );
455 }
456
457+function buildHostOperationsAuthError(
458+ route: LocalApiRouteDefinition,
459+ reason: SharedTokenAuthFailureReason
460+): LocalApiHttpError {
461+ const details: JsonObject = {
462+ auth_scheme: "Bearer",
463+ env_var: "BAA_SHARED_TOKEN",
464+ route_id: route.id
465+ };
466+
467+ switch (reason) {
468+ case "missing_authorization_header":
469+ return new LocalApiHttpError(
470+ 401,
471+ "unauthorized",
472+ `${route.method} ${route.pathPattern} requires ${HOST_OPERATIONS_AUTH_HEADER}.`,
473+ {
474+ ...details,
475+ reason
476+ },
477+ {
478+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
479+ }
480+ );
481+ case "invalid_authorization_scheme":
482+ return new LocalApiHttpError(
483+ 401,
484+ "unauthorized",
485+ `${route.method} ${route.pathPattern} requires a Bearer token in the Authorization header.`,
486+ {
487+ ...details,
488+ reason
489+ },
490+ {
491+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
492+ }
493+ );
494+ case "empty_bearer_token":
495+ return new LocalApiHttpError(
496+ 401,
497+ "unauthorized",
498+ `${route.method} ${route.pathPattern} requires a non-empty Bearer token.`,
499+ {
500+ ...details,
501+ reason
502+ },
503+ {
504+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
505+ }
506+ );
507+ case "invalid_token":
508+ return new LocalApiHttpError(
509+ 401,
510+ "unauthorized",
511+ `${route.method} ${route.pathPattern} received an invalid Bearer token.`,
512+ {
513+ ...details,
514+ reason
515+ },
516+ {
517+ "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
518+ }
519+ );
520+ }
521+}
522+
523+function authorizeRoute(route: LocalApiRouteDefinition, context: LocalApiRequestContext): void {
524+ if (!isHostOperationsRoute(route)) {
525+ return;
526+ }
527+
528+ const sharedToken = normalizeOptionalString(context.sharedToken);
529+
530+ if (sharedToken == null) {
531+ throw new LocalApiHttpError(
532+ 503,
533+ "shared_token_not_configured",
534+ `BAA_SHARED_TOKEN is not configured; ${route.method} ${route.pathPattern} is unavailable.`,
535+ {
536+ env_var: "BAA_SHARED_TOKEN",
537+ route_id: route.id
538+ }
539+ );
540+ }
541+
542+ const tokenResult = extractBearerToken(readHeaderValue(context.request, "authorization"));
543+
544+ if (!tokenResult.ok) {
545+ throw buildHostOperationsAuthError(route, tokenResult.reason);
546+ }
547+
548+ if (tokenResult.token !== sharedToken) {
549+ throw buildHostOperationsAuthError(route, "invalid_token");
550+ }
551+}
552+
553 export interface SetAutomationModeInput {
554 mode: AutomationMode;
555 reason?: string | null;
556@@ -1414,6 +1697,7 @@ async function dispatchRoute(
557 case "probe.runtime":
558 return buildSuccessEnvelope(context.requestId, 200, context.snapshotLoader() as unknown as JsonValue);
559 default:
560+ authorizeRoute(matchedRoute.route, context);
561 return dispatchBusinessRoute(matchedRoute.route.id, context, version);
562 }
563 }
564@@ -1532,6 +1816,7 @@ export async function handleConductorHttpRequest(
565 repository: context.repository,
566 request,
567 requestId,
568+ sharedToken: normalizeOptionalString(context.sharedToken),
569 snapshotLoader: context.snapshotLoader,
570 url
571 },
+9,
-1
1@@ -40,7 +40,7 @@
2 2. 如有需要,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
3 3. 如果是控制动作,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
4 4. 按需查看 `controllers`、`tasks`、`runs`
5-5. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`
6+5. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`,并准备 `Authorization: Bearer <BAA_SHARED_TOKEN>`
7 6. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
8 7. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
9
10@@ -88,6 +88,8 @@ host-ops 约定:
11
12 - 这三条路由的 HTTP 外层仍然是 `conductor-daemon` 统一 envelope
13 - 具体操作成功或失败看 `data.ok`
14+- 这三条路由统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>`
15+- `BAA_SHARED_TOKEN` 来自 daemon 启动配置;缺少或错误 token 时返回 `401` JSON 错误
16 - `cwd` 省略时使用 daemon 进程当前工作目录
17 - `path` 可为绝对路径,也可相对 `cwd`
18 - `timeoutMs` 仅用于 `/v1/exec`,默认 `30000`
19@@ -183,21 +185,27 @@ curl -X POST "${LOCAL_API_BASE}/v1/system/pause" \
20
21 ```bash
22 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
23+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
24 curl -X POST "${LOCAL_API_BASE}/v1/exec" \
25+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
26 -H 'Content-Type: application/json' \
27 -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
28 ```
29
30 ```bash
31 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
32+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
33 curl -X POST "${LOCAL_API_BASE}/v1/files/read" \
34+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
35 -H 'Content-Type: application/json' \
36 -d '{"path":"README.md","cwd":"/Users/george/code/baa-conductor","encoding":"utf8"}'
37 ```
38
39 ```bash
40 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
41+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
42 curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
43+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
44 -H 'Content-Type: application/json' \
45 -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
46 ```
+23,
-1
1@@ -34,6 +34,18 @@
2
3 不要在未读状态前直接写入控制动作或本机 host-ops。
4
5+## Host Ops 鉴权
6+
7+- `POST /v1/exec`
8+- `POST /v1/files/read`
9+- `POST /v1/files/write`
10+
11+这三条 host-ops 路由现在统一要求:
12+
13+- `Authorization: Bearer <BAA_SHARED_TOKEN>`
14+- 缺少 token 或 token 错误时返回明确的 `401` JSON 错误
15+- 即使通过 `https://conductor.makefile.so` 暴露,这三条路由也不再是匿名公网可用
16+
17 ## 当前可用控制类接口
18
19 ### 发现与诊断
20@@ -67,6 +79,12 @@
21 | `POST` | `/v1/files/read` | 读取当前节点文本文件 |
22 | `POST` | `/v1/files/write` | 写入当前节点文本文件,支持 `overwrite` / `createParents` |
23
24+鉴权要求:
25+
26+- header:`Authorization: Bearer <BAA_SHARED_TOKEN>`
27+- token 来源:daemon 启动时配置的 `BAA_SHARED_TOKEN`
28+- 未授权请求会在进入 host-ops 前直接返回 `401`
29+
30 输入语义:
31
32 - `cwd`:省略时使用 daemon 进程当前工作目录
33@@ -133,14 +151,18 @@ curl -X POST "${BASE_URL}/v1/system/drain" \
34
35 ```bash
36 BASE_URL="http://100.71.210.78:4317"
37+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
38 curl -X POST "${BASE_URL}/v1/exec" \
39+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
40 -H 'Content-Type: application/json' \
41 -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
42 ```
43
44 ```bash
45 BASE_URL="http://100.71.210.78:4317"
46+SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
47 curl -X POST "${BASE_URL}/v1/files/write" \
48+ -H "Authorization: Bearer ${SHARED_TOKEN}" \
49 -H 'Content-Type: application/json' \
50 -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
51 ```
52@@ -149,7 +171,7 @@ curl -X POST "${BASE_URL}/v1/files/write" \
53
54 ```text
55 先阅读控制类接口文档,再请求 /describe/control、/v1/capabilities、/v1/system/state。
56-只有在确认当前状态后,才执行 pause / resume / drain;如果要做本机 shell / 文件操作,也先看 host_operations。
57+只有在确认当前状态后,才执行 pause / resume / drain;如果要做本机 shell / 文件操作,也先看 host_operations,并带上 Authorization: Bearer <BAA_SHARED_TOKEN>。
58 ```
59
60 ## 当前边界
+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 ```