- commit
- 70f8a64
- parent
- 88ae94b
- author
- im_wower
- date
- 2026-03-22 18:33:25 +0800 CST
feat(conductor-daemon): expose local host ops over http
13 files changed,
+1031,
-236
+4,
-3
1@@ -4,13 +4,14 @@
2 "type": "module",
3 "main": "dist/index.js",
4 "dependencies": {
5- "@baa-conductor/db": "workspace:*"
6+ "@baa-conductor/db": "workspace:*",
7+ "@baa-conductor/host-ops": "workspace:*"
8 },
9 "scripts": {
10- "build": "pnpm -C ../.. -F @baa-conductor/db build && pnpm exec tsc -p tsconfig.json",
11+ "build": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm exec tsc -p tsconfig.json",
12 "dev": "pnpm run build && node dist/index.js",
13 "start": "node dist/index.js",
14 "test": "pnpm run build && node --test src/index.test.js",
15- "typecheck": "pnpm -C ../.. -F @baa-conductor/db build && pnpm exec tsc --noEmit -p tsconfig.json"
16+ "typecheck": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm exec tsc --noEmit -p tsconfig.json"
17 }
18 }
+344,
-177
1@@ -675,189 +675,285 @@ test("handleConductorHttpRequest keeps degraded runtimes observable but not read
2
3 test("handleConductorHttpRequest serves the migrated local business endpoints from the local repository", async () => {
4 const { repository, snapshot } = await createLocalApiFixture();
5+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-local-host-http-"));
6
7- const describeResponse = await handleConductorHttpRequest(
8- {
9- method: "GET",
10- path: "/describe"
11- },
12- {
13- repository,
14- snapshotLoader: () => snapshot,
15- version: "1.2.3"
16- }
17- );
18- assert.equal(describeResponse.status, 200);
19- const describePayload = parseJsonBody(describeResponse);
20- assert.equal(describePayload.ok, true);
21- assert.equal(describePayload.data.name, "baa-conductor-daemon");
22- assert.equal(describePayload.data.system.mode, "running");
23- assert.equal(describePayload.data.describe_endpoints.business.path, "/describe/business");
24- assert.equal(describePayload.data.describe_endpoints.control.path, "/describe/control");
25-
26- const businessDescribeResponse = await handleConductorHttpRequest(
27- {
28- method: "GET",
29- path: "/describe/business"
30- },
31- {
32- repository,
33- snapshotLoader: () => snapshot,
34- version: "1.2.3"
35- }
36- );
37- assert.equal(businessDescribeResponse.status, 200);
38- const businessDescribePayload = parseJsonBody(businessDescribeResponse);
39- assert.equal(businessDescribePayload.data.surface, "business");
40- assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/tasks/u);
41- assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
42-
43- const controlDescribeResponse = await handleConductorHttpRequest(
44- {
45- method: "GET",
46- path: "/describe/control"
47- },
48- {
49- repository,
50- snapshotLoader: () => snapshot,
51- version: "1.2.3"
52- }
53- );
54- assert.equal(controlDescribeResponse.status, 200);
55- const controlDescribePayload = parseJsonBody(controlDescribeResponse);
56- assert.equal(controlDescribePayload.data.surface, "control");
57- assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
58- assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
59-
60- const healthResponse = await handleConductorHttpRequest(
61- {
62- method: "GET",
63- path: "/health"
64- },
65- {
66- repository,
67- snapshotLoader: () => snapshot,
68- version: "1.2.3"
69- }
70- );
71- assert.equal(healthResponse.status, 200);
72- assert.equal(parseJsonBody(healthResponse).data.status, "ok");
73-
74- const controllersResponse = await handleConductorHttpRequest(
75- {
76- method: "GET",
77- path: "/v1/controllers?limit=5"
78- },
79- {
80- repository,
81- snapshotLoader: () => snapshot
82- }
83- );
84- const controllersPayload = parseJsonBody(controllersResponse);
85- assert.equal(controllersPayload.data.count, 1);
86- assert.equal(controllersPayload.data.controllers[0].controller_id, "mini-main");
87- assert.equal(controllersPayload.data.controllers[0].is_leader, true);
88-
89- const tasksResponse = await handleConductorHttpRequest(
90- {
91- method: "GET",
92- path: "/v1/tasks?status=running&limit=5"
93- },
94- {
95- repository,
96- snapshotLoader: () => snapshot
97- }
98- );
99- const tasksPayload = parseJsonBody(tasksResponse);
100- assert.equal(tasksPayload.data.count, 1);
101- assert.equal(tasksPayload.data.tasks[0].task_id, "task_demo");
102-
103- const taskResponse = await handleConductorHttpRequest(
104- {
105- method: "GET",
106- path: "/v1/tasks/task_demo"
107- },
108- {
109- repository,
110- snapshotLoader: () => snapshot
111- }
112- );
113- assert.equal(parseJsonBody(taskResponse).data.task_id, "task_demo");
114-
115- const taskLogsResponse = await handleConductorHttpRequest(
116- {
117- method: "GET",
118- path: "/v1/tasks/task_demo/logs?limit=10"
119- },
120- {
121- repository,
122- snapshotLoader: () => snapshot
123- }
124- );
125- const taskLogsPayload = parseJsonBody(taskLogsResponse);
126- assert.equal(taskLogsPayload.data.task_id, "task_demo");
127- assert.equal(taskLogsPayload.data.entries.length, 1);
128- assert.equal(taskLogsPayload.data.entries[0].message, "hello from local api");
129-
130- const runsResponse = await handleConductorHttpRequest(
131- {
132- method: "GET",
133- path: "/v1/runs?limit=5"
134- },
135- {
136- repository,
137- snapshotLoader: () => snapshot
138- }
139- );
140- const runsPayload = parseJsonBody(runsResponse);
141- assert.equal(runsPayload.data.count, 1);
142- assert.equal(runsPayload.data.runs[0].run_id, "run_demo");
143-
144- const runResponse = await handleConductorHttpRequest(
145- {
146- method: "GET",
147- path: "/v1/runs/run_demo"
148- },
149- {
150- repository,
151- snapshotLoader: () => snapshot
152- }
153- );
154- assert.equal(parseJsonBody(runResponse).data.run_id, "run_demo");
155-
156- const pauseResponse = await handleConductorHttpRequest(
157- {
158- body: JSON.stringify({
159- reason: "human_clicked_pause",
160- requested_by: "test"
161- }),
162- method: "POST",
163- path: "/v1/system/pause"
164- },
165- {
166- repository,
167- snapshotLoader: () => snapshot
168- }
169- );
170- assert.equal(pauseResponse.status, 200);
171- assert.equal(parseJsonBody(pauseResponse).data.mode, "paused");
172- assert.equal((await repository.getAutomationState())?.mode, "paused");
173+ try {
174+ const describeResponse = await handleConductorHttpRequest(
175+ {
176+ method: "GET",
177+ path: "/describe"
178+ },
179+ {
180+ repository,
181+ snapshotLoader: () => snapshot,
182+ version: "1.2.3"
183+ }
184+ );
185+ assert.equal(describeResponse.status, 200);
186+ const describePayload = parseJsonBody(describeResponse);
187+ assert.equal(describePayload.ok, true);
188+ assert.equal(describePayload.data.name, "baa-conductor-daemon");
189+ assert.equal(describePayload.data.system.mode, "running");
190+ assert.equal(describePayload.data.describe_endpoints.business.path, "/describe/business");
191+ assert.equal(describePayload.data.describe_endpoints.control.path, "/describe/control");
192+ assert.equal(describePayload.data.host_operations.enabled, true);
193+
194+ const businessDescribeResponse = await handleConductorHttpRequest(
195+ {
196+ method: "GET",
197+ path: "/describe/business"
198+ },
199+ {
200+ repository,
201+ snapshotLoader: () => snapshot,
202+ version: "1.2.3"
203+ }
204+ );
205+ assert.equal(businessDescribeResponse.status, 200);
206+ const businessDescribePayload = parseJsonBody(businessDescribeResponse);
207+ assert.equal(businessDescribePayload.data.surface, "business");
208+ assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/tasks/u);
209+ assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
210+ assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/exec/u);
211+
212+ const controlDescribeResponse = await handleConductorHttpRequest(
213+ {
214+ method: "GET",
215+ path: "/describe/control"
216+ },
217+ {
218+ repository,
219+ snapshotLoader: () => snapshot,
220+ version: "1.2.3"
221+ }
222+ );
223+ assert.equal(controlDescribeResponse.status, 200);
224+ const controlDescribePayload = parseJsonBody(controlDescribeResponse);
225+ assert.equal(controlDescribePayload.data.surface, "control");
226+ assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
227+ assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
228+ assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
229+
230+ const healthResponse = await handleConductorHttpRequest(
231+ {
232+ method: "GET",
233+ path: "/health"
234+ },
235+ {
236+ repository,
237+ snapshotLoader: () => snapshot,
238+ version: "1.2.3"
239+ }
240+ );
241+ assert.equal(healthResponse.status, 200);
242+ assert.equal(parseJsonBody(healthResponse).data.status, "ok");
243+
244+ const controllersResponse = await handleConductorHttpRequest(
245+ {
246+ method: "GET",
247+ path: "/v1/controllers?limit=5"
248+ },
249+ {
250+ repository,
251+ snapshotLoader: () => snapshot
252+ }
253+ );
254+ const controllersPayload = parseJsonBody(controllersResponse);
255+ assert.equal(controllersPayload.data.count, 1);
256+ assert.equal(controllersPayload.data.controllers[0].controller_id, "mini-main");
257+ assert.equal(controllersPayload.data.controllers[0].is_leader, true);
258+
259+ const tasksResponse = await handleConductorHttpRequest(
260+ {
261+ method: "GET",
262+ path: "/v1/tasks?status=running&limit=5"
263+ },
264+ {
265+ repository,
266+ snapshotLoader: () => snapshot
267+ }
268+ );
269+ const tasksPayload = parseJsonBody(tasksResponse);
270+ assert.equal(tasksPayload.data.count, 1);
271+ assert.equal(tasksPayload.data.tasks[0].task_id, "task_demo");
272+
273+ const taskResponse = await handleConductorHttpRequest(
274+ {
275+ method: "GET",
276+ path: "/v1/tasks/task_demo"
277+ },
278+ {
279+ repository,
280+ snapshotLoader: () => snapshot
281+ }
282+ );
283+ assert.equal(parseJsonBody(taskResponse).data.task_id, "task_demo");
284
285- const systemStateResponse = await handleConductorHttpRequest(
286- {
287- method: "GET",
288- path: "/v1/system/state"
289- },
290- {
291- repository,
292- snapshotLoader: () => snapshot
293- }
294- );
295- assert.equal(parseJsonBody(systemStateResponse).data.mode, "paused");
296+ const taskLogsResponse = await handleConductorHttpRequest(
297+ {
298+ method: "GET",
299+ path: "/v1/tasks/task_demo/logs?limit=10"
300+ },
301+ {
302+ repository,
303+ snapshotLoader: () => snapshot
304+ }
305+ );
306+ const taskLogsPayload = parseJsonBody(taskLogsResponse);
307+ assert.equal(taskLogsPayload.data.task_id, "task_demo");
308+ assert.equal(taskLogsPayload.data.entries.length, 1);
309+ assert.equal(taskLogsPayload.data.entries[0].message, "hello from local api");
310+
311+ const runsResponse = await handleConductorHttpRequest(
312+ {
313+ method: "GET",
314+ path: "/v1/runs?limit=5"
315+ },
316+ {
317+ repository,
318+ snapshotLoader: () => snapshot
319+ }
320+ );
321+ const runsPayload = parseJsonBody(runsResponse);
322+ assert.equal(runsPayload.data.count, 1);
323+ assert.equal(runsPayload.data.runs[0].run_id, "run_demo");
324+
325+ const runResponse = await handleConductorHttpRequest(
326+ {
327+ method: "GET",
328+ path: "/v1/runs/run_demo"
329+ },
330+ {
331+ repository,
332+ snapshotLoader: () => snapshot
333+ }
334+ );
335+ assert.equal(parseJsonBody(runResponse).data.run_id, "run_demo");
336+
337+ const pauseResponse = await handleConductorHttpRequest(
338+ {
339+ body: JSON.stringify({
340+ reason: "human_clicked_pause",
341+ requested_by: "test"
342+ }),
343+ method: "POST",
344+ path: "/v1/system/pause"
345+ },
346+ {
347+ repository,
348+ snapshotLoader: () => snapshot
349+ }
350+ );
351+ assert.equal(pauseResponse.status, 200);
352+ assert.equal(parseJsonBody(pauseResponse).data.mode, "paused");
353+ assert.equal((await repository.getAutomationState())?.mode, "paused");
354+
355+ const writeResponse = await handleConductorHttpRequest(
356+ {
357+ body: JSON.stringify({
358+ path: "notes/demo.txt",
359+ cwd: hostOpsDir,
360+ content: "hello from local host ops",
361+ overwrite: false,
362+ createParents: true
363+ }),
364+ method: "POST",
365+ path: "/v1/files/write"
366+ },
367+ {
368+ repository,
369+ snapshotLoader: () => snapshot
370+ }
371+ );
372+ assert.equal(writeResponse.status, 200);
373+ const writePayload = parseJsonBody(writeResponse);
374+ assert.equal(writePayload.data.ok, true);
375+ assert.equal(writePayload.data.operation, "files/write");
376+ assert.equal(writePayload.data.result.created, true);
377+
378+ const readResponse = await handleConductorHttpRequest(
379+ {
380+ body: JSON.stringify({
381+ path: "notes/demo.txt",
382+ cwd: hostOpsDir
383+ }),
384+ method: "POST",
385+ path: "/v1/files/read"
386+ },
387+ {
388+ repository,
389+ snapshotLoader: () => snapshot
390+ }
391+ );
392+ assert.equal(readResponse.status, 200);
393+ const readPayload = parseJsonBody(readResponse);
394+ assert.equal(readPayload.data.ok, true);
395+ assert.equal(readPayload.data.result.content, "hello from local host ops");
396+
397+ const duplicateWriteResponse = await handleConductorHttpRequest(
398+ {
399+ body: JSON.stringify({
400+ path: "notes/demo.txt",
401+ cwd: hostOpsDir,
402+ content: "should not overwrite",
403+ overwrite: false
404+ }),
405+ method: "POST",
406+ path: "/v1/files/write"
407+ },
408+ {
409+ repository,
410+ snapshotLoader: () => snapshot
411+ }
412+ );
413+ assert.equal(duplicateWriteResponse.status, 200);
414+ const duplicateWritePayload = parseJsonBody(duplicateWriteResponse);
415+ assert.equal(duplicateWritePayload.data.ok, false);
416+ assert.equal(duplicateWritePayload.data.error.code, "FILE_ALREADY_EXISTS");
417+
418+ const execResponse = await handleConductorHttpRequest(
419+ {
420+ body: JSON.stringify({
421+ command: "printf 'host-http-ok'",
422+ cwd: hostOpsDir,
423+ timeoutMs: 2000
424+ }),
425+ method: "POST",
426+ path: "/v1/exec"
427+ },
428+ {
429+ repository,
430+ snapshotLoader: () => snapshot
431+ }
432+ );
433+ assert.equal(execResponse.status, 200);
434+ const execPayload = parseJsonBody(execResponse);
435+ assert.equal(execPayload.data.ok, true);
436+ assert.equal(execPayload.data.operation, "exec");
437+ assert.equal(execPayload.data.result.stdout, "host-http-ok");
438+
439+ const systemStateResponse = await handleConductorHttpRequest(
440+ {
441+ method: "GET",
442+ path: "/v1/system/state"
443+ },
444+ {
445+ repository,
446+ snapshotLoader: () => snapshot
447+ }
448+ );
449+ assert.equal(parseJsonBody(systemStateResponse).data.mode, "paused");
450+ } finally {
451+ rmSync(hostOpsDir, {
452+ force: true,
453+ recursive: true
454+ });
455+ }
456 });
457
458 test("ConductorRuntime serves health and migrated local API endpoints over HTTP", async () => {
459 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-"));
460+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-host-"));
461 const runtime = new ConductorRuntime(
462 {
463 nodeId: "mini-main",
464@@ -951,6 +1047,73 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
465 assert.equal(controlDescribeResponse.status, 200);
466 const controlDescribePayload = await controlDescribeResponse.json();
467 assert.equal(controlDescribePayload.data.surface, "control");
468+ assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
469+
470+ const execResponse = await fetch(`${baseUrl}/v1/exec`, {
471+ method: "POST",
472+ headers: {
473+ "content-type": "application/json"
474+ },
475+ body: JSON.stringify({
476+ command: "printf 'runtime-host-op'",
477+ cwd: hostOpsDir,
478+ timeoutMs: 2000
479+ })
480+ });
481+ assert.equal(execResponse.status, 200);
482+ const execPayload = await execResponse.json();
483+ assert.equal(execPayload.data.ok, true);
484+ assert.equal(execPayload.data.result.stdout, "runtime-host-op");
485+
486+ const writeResponse = await fetch(`${baseUrl}/v1/files/write`, {
487+ method: "POST",
488+ headers: {
489+ "content-type": "application/json"
490+ },
491+ body: JSON.stringify({
492+ path: "runtime/demo.txt",
493+ cwd: hostOpsDir,
494+ content: "hello from runtime host ops",
495+ overwrite: false,
496+ createParents: true
497+ })
498+ });
499+ assert.equal(writeResponse.status, 200);
500+ const writePayload = await writeResponse.json();
501+ assert.equal(writePayload.data.ok, true);
502+ assert.equal(writePayload.data.result.created, true);
503+
504+ const duplicateWriteResponse = await fetch(`${baseUrl}/v1/files/write`, {
505+ method: "POST",
506+ headers: {
507+ "content-type": "application/json"
508+ },
509+ body: JSON.stringify({
510+ path: "runtime/demo.txt",
511+ cwd: hostOpsDir,
512+ content: "should not overwrite",
513+ overwrite: false
514+ })
515+ });
516+ assert.equal(duplicateWriteResponse.status, 200);
517+ const duplicateWritePayload = await duplicateWriteResponse.json();
518+ assert.equal(duplicateWritePayload.data.ok, false);
519+ assert.equal(duplicateWritePayload.data.error.code, "FILE_ALREADY_EXISTS");
520+
521+ const readResponse = await fetch(`${baseUrl}/v1/files/read`, {
522+ method: "POST",
523+ headers: {
524+ "content-type": "application/json"
525+ },
526+ body: JSON.stringify({
527+ path: "runtime/demo.txt",
528+ cwd: hostOpsDir
529+ })
530+ });
531+ assert.equal(readResponse.status, 200);
532+ const readPayload = await readResponse.json();
533+ assert.equal(readPayload.data.ok, true);
534+ assert.equal(readPayload.data.result.content, "hello from runtime host ops");
535
536 const stoppedSnapshot = await runtime.stop();
537 assert.equal(stoppedSnapshot.runtime.started, false);
538@@ -958,6 +1121,10 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
539 force: true,
540 recursive: true
541 });
542+ rmSync(hostOpsDir, {
543+ force: true,
544+ recursive: true
545+ });
546 });
547
548 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
+249,
-9
1@@ -13,6 +13,16 @@ import {
2 type TaskRunRecord,
3 type TaskStatus
4 } from "../../../packages/db/dist/index.js";
5+import {
6+ DEFAULT_EXEC_MAX_BUFFER_BYTES,
7+ DEFAULT_EXEC_TIMEOUT_MS,
8+ executeCommand,
9+ readTextFile,
10+ writeTextFile,
11+ type ExecOperationRequest,
12+ type FileReadOperationRequest,
13+ type FileWriteOperationRequest
14+} from "../../../packages/host-ops/dist/index.js";
15
16 import {
17 jsonResponse,
18@@ -205,6 +215,27 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
19 pathPattern: "/v1/system/drain",
20 summary: "把 automation 切到 draining"
21 },
22+ {
23+ id: "host.exec",
24+ kind: "write",
25+ method: "POST",
26+ pathPattern: "/v1/exec",
27+ summary: "执行本机 shell 命令并返回结构化 stdout/stderr"
28+ },
29+ {
30+ id: "host.files.read",
31+ kind: "write",
32+ method: "POST",
33+ pathPattern: "/v1/files/read",
34+ summary: "读取本机文本文件并返回结构化内容与元数据"
35+ },
36+ {
37+ id: "host.files.write",
38+ kind: "write",
39+ method: "POST",
40+ pathPattern: "/v1/files/write",
41+ summary: "写入本机文本文件并返回结构化结果"
42+ },
43 {
44 id: "controllers.list",
45 kind: "read",
46@@ -359,6 +390,52 @@ function readOptionalStringField(body: JsonObject, fieldName: string): string |
47 return normalized === "" ? undefined : normalized;
48 }
49
50+function readBodyField(body: JsonObject, ...fieldNames: string[]): JsonValue | undefined {
51+ for (const fieldName of fieldNames) {
52+ if (Object.prototype.hasOwnProperty.call(body, fieldName)) {
53+ return body[fieldName];
54+ }
55+ }
56+
57+ return undefined;
58+}
59+
60+function buildExecOperationRequest(body: JsonObject): ExecOperationRequest {
61+ return {
62+ command: readBodyField(body, "command") as ExecOperationRequest["command"],
63+ cwd: readBodyField(body, "cwd") as ExecOperationRequest["cwd"],
64+ maxBufferBytes: readBodyField(
65+ body,
66+ "maxBufferBytes",
67+ "max_buffer_bytes"
68+ ) as ExecOperationRequest["maxBufferBytes"],
69+ timeoutMs: readBodyField(body, "timeoutMs", "timeout_ms") as ExecOperationRequest["timeoutMs"]
70+ };
71+}
72+
73+function buildFileReadOperationRequest(body: JsonObject): FileReadOperationRequest {
74+ return {
75+ cwd: readBodyField(body, "cwd") as FileReadOperationRequest["cwd"],
76+ encoding: readBodyField(body, "encoding") as FileReadOperationRequest["encoding"],
77+ path: readBodyField(body, "path") as FileReadOperationRequest["path"]
78+ };
79+}
80+
81+function buildFileWriteOperationRequest(body: JsonObject): FileWriteOperationRequest {
82+ return {
83+ content: readBodyField(body, "content") as FileWriteOperationRequest["content"],
84+ createParents: readBodyField(
85+ body,
86+ "createParents",
87+ "create_parents"
88+ ) as FileWriteOperationRequest["createParents"],
89+ cwd: readBodyField(body, "cwd") as FileWriteOperationRequest["cwd"],
90+ encoding: readBodyField(body, "encoding") as FileWriteOperationRequest["encoding"],
91+ overwrite: readBodyField(body, "overwrite") as FileWriteOperationRequest["overwrite"],
92+ path: readBodyField(body, "path") as FileWriteOperationRequest["path"]
93+ };
94+}
95+
96 function readPositiveIntegerQuery(
97 url: URL,
98 fieldName: string,
99@@ -597,6 +674,89 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
100 };
101 }
102
103+function buildHostOperationsData(origin: string): JsonObject {
104+ return {
105+ enabled: true,
106+ contract: "HTTP success envelope data 直接嵌入 @baa-conductor/host-ops 的结构化 result union。",
107+ semantics: {
108+ cwd: "可选字符串;省略时使用 conductor-daemon 进程当前工作目录。",
109+ path: "files/read 和 files/write 的 path 可以是绝对路径,也可以是相对 cwd 的路径。",
110+ timeoutMs: `仅 /v1/exec 使用;可选整数 >= 0,默认 ${DEFAULT_EXEC_TIMEOUT_MS},0 表示不启用超时。`,
111+ maxBufferBytes: `仅 /v1/exec 使用;可选整数 > 0,默认 ${DEFAULT_EXEC_MAX_BUFFER_BYTES}。`,
112+ overwrite: "仅 /v1/files/write 使用;可选布尔值,默认 true。false 时如果目标文件已存在,会返回 FILE_ALREADY_EXISTS。",
113+ createParents: "仅 /v1/files/write 使用;可选布尔值,默认 true。true 时会递归创建缺失父目录。",
114+ encoding: "当前只支持 utf8。"
115+ },
116+ operations: [
117+ {
118+ method: "POST",
119+ path: "/v1/exec",
120+ summary: "执行本机 shell 命令。",
121+ request_body: {
122+ command: "必填,非空字符串。",
123+ cwd: "可选,命令执行目录。",
124+ timeoutMs: DEFAULT_EXEC_TIMEOUT_MS,
125+ maxBufferBytes: DEFAULT_EXEC_MAX_BUFFER_BYTES
126+ },
127+ curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
128+ command: "printf 'hello from conductor'",
129+ cwd: "/tmp",
130+ timeoutMs: 2000
131+ })
132+ },
133+ {
134+ method: "POST",
135+ path: "/v1/files/read",
136+ summary: "读取本机文本文件。",
137+ request_body: {
138+ path: "README.md",
139+ cwd: "/Users/george/code/baa-conductor",
140+ encoding: "utf8"
141+ },
142+ curl: buildCurlExample(
143+ origin,
144+ LOCAL_API_ROUTES.find((route) => route.id === "host.files.read")!,
145+ {
146+ path: "README.md",
147+ cwd: "/Users/george/code/baa-conductor",
148+ encoding: "utf8"
149+ }
150+ )
151+ },
152+ {
153+ method: "POST",
154+ path: "/v1/files/write",
155+ summary: "写入本机文本文件。",
156+ request_body: {
157+ path: "tmp/conductor-note.txt",
158+ cwd: "/Users/george/code/baa-conductor",
159+ content: "hello from conductor",
160+ overwrite: true,
161+ createParents: true,
162+ encoding: "utf8"
163+ },
164+ curl: buildCurlExample(
165+ origin,
166+ LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!,
167+ {
168+ path: "tmp/conductor-note.txt",
169+ cwd: "/Users/george/code/baa-conductor",
170+ content: "hello from conductor",
171+ overwrite: true,
172+ createParents: true,
173+ encoding: "utf8"
174+ }
175+ )
176+ }
177+ ],
178+ notes: [
179+ "这三条路由总是返回外层 conductor success envelope;具体操作成功或失败看 data.ok。",
180+ "请求体优先使用 host-ops 的 camelCase 字段;daemon 也接受 timeout_ms、max_buffer_bytes、create_parents 作为兼容别名。",
181+ "这些操作只作用于当前本机节点,不会经过 control-api-worker。"
182+ ]
183+ };
184+}
185+
186 function describeRoute(route: LocalApiRouteDefinition): JsonObject {
187 return {
188 id: route.id,
189@@ -641,6 +801,9 @@ function routeBelongsToSurface(
190 "system.pause",
191 "system.resume",
192 "system.drain",
193+ "host.exec",
194+ "host.files.read",
195+ "host.files.write",
196 "probe.healthz",
197 "probe.readyz",
198 "probe.rolez",
199@@ -652,6 +815,7 @@ function buildCapabilitiesData(
200 snapshot: ConductorRuntimeApiSnapshot,
201 surface: LocalApiDescribeSurface | "all" = "all"
202 ): JsonObject {
203+ const origin = snapshot.controlApi.localApiBase ?? "http://127.0.0.1";
204 const exposedRoutes = LOCAL_API_ROUTES.filter((route) =>
205 surface === "all" ? route.exposeInDescribe !== false : routeBelongsToSurface(route, surface)
206 );
207@@ -666,7 +830,8 @@ function buildCapabilitiesData(
208 "GET /v1/capabilities",
209 "GET /v1/system/state",
210 "GET /v1/tasks or /v1/runs",
211- "Use POST system routes only when a write is intended"
212+ "GET /describe/control if local shell/file access is needed",
213+ "Use POST system routes or host operations only when a write/exec is intended"
214 ],
215 read_endpoints: exposedRoutes.filter((route) => route.kind === "read").map(describeRoute),
216 write_endpoints: exposedRoutes.filter((route) => route.kind === "write").map(describeRoute),
217@@ -683,7 +848,8 @@ function buildCapabilitiesData(
218 url: snapshot.controlApi.localApiBase ?? null
219 },
220 websocket: buildFirefoxWebSocketData(snapshot)
221- }
222+ },
223+ host_operations: buildHostOperationsData(origin)
224 };
225 }
226
227@@ -692,8 +858,9 @@ function buildCurlExample(
228 route: LocalApiRouteDefinition,
229 body?: JsonObject
230 ): string {
231+ const serializedBody = body ? JSON.stringify(body).replaceAll("'", "'\"'\"'") : null;
232 const payload = body
233- ? ` \\\n -H 'Content-Type: application/json' \\\n -d '${JSON.stringify(body)}'`
234+ ? ` \\\n -H 'Content-Type: application/json' \\\n -d '${serializedBody}'`
235 : "";
236 return `curl -X ${route.method} '${origin}${route.pathPattern}'${payload}`;
237 }
238@@ -725,7 +892,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
239 },
240 control: {
241 path: "/describe/control",
242- summary: "控制动作入口;在 pause/resume/drain 前先读。"
243+ summary: "控制和本机 host-ops 入口;在 pause/resume/drain 或 exec/files/* 前先读。"
244 }
245 },
246 endpoints: [
247@@ -739,6 +906,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
248 }
249 ],
250 capabilities: buildCapabilitiesData(snapshot),
251+ host_operations: buildHostOperationsData(origin),
252 examples: [
253 {
254 title: "Read the business describe surface first",
255@@ -782,6 +950,16 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
256 reason: "manual_pause",
257 source: "local_control_surface"
258 })
259+ },
260+ {
261+ title: "Run a small local command",
262+ method: "POST",
263+ path: "/v1/exec",
264+ curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
265+ command: "printf 'hello from conductor'",
266+ cwd: "/tmp",
267+ timeoutMs: 2000
268+ })
269 }
270 ],
271 notes: [
272@@ -820,7 +998,7 @@ async function handleScopedDescribeRead(
273 "GET /describe/business",
274 "Optionally GET /v1/capabilities",
275 "Use read-only routes such as /v1/controllers, /v1/tasks and /v1/runs",
276- "Do not assume /v1/exec, /v1/files/read, /v1/files/write or POST /v1/tasks are live unless explicitly listed"
277+ "Use /describe/control if a local shell or file operation is intended"
278 ],
279 system,
280 websocket: buildFirefoxWebSocketData(snapshot),
281@@ -841,7 +1019,7 @@ async function handleScopedDescribeRead(
282 ],
283 notes: [
284 "This surface is intended to be enough for business-query discovery without reading external docs.",
285- "Control actions are intentionally excluded; use /describe/control for pause/resume/drain."
286+ "Control actions and host-level exec/file operations are intentionally excluded; use /describe/control."
287 ]
288 });
289 }
290@@ -861,10 +1039,12 @@ async function handleScopedDescribeRead(
291 "GET /describe/control",
292 "Optionally GET /v1/capabilities",
293 "GET /v1/system/state",
294- "Only then decide whether to call pause, resume or drain"
295+ "Read host_operations if a local shell/file action is intended",
296+ "Only then decide whether to call pause, resume, drain or a host operation"
297 ],
298 system,
299 websocket: buildFirefoxWebSocketData(snapshot),
300+ host_operations: buildHostOperationsData(origin),
301 endpoints: routes.map(describeRoute),
302 examples: [
303 {
304@@ -882,11 +1062,34 @@ async function handleScopedDescribeRead(
305 reason: "manual_pause",
306 source: "local_control_surface"
307 })
308+ },
309+ {
310+ title: "Run a small local command",
311+ method: "POST",
312+ path: "/v1/exec",
313+ curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!, {
314+ command: "printf 'hello from conductor'",
315+ cwd: "/tmp",
316+ timeoutMs: 2000
317+ })
318+ },
319+ {
320+ title: "Write a local file safely",
321+ method: "POST",
322+ path: "/v1/files/write",
323+ curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!, {
324+ path: "tmp/conductor-note.txt",
325+ cwd: "/Users/george/code/baa-conductor",
326+ content: "hello from conductor",
327+ overwrite: false,
328+ createParents: true
329+ })
330 }
331 ],
332 notes: [
333 "This surface is intended to be enough for control discovery without reading external docs.",
334- "Business queries such as tasks and runs are intentionally excluded; use /describe/business."
335+ "Business queries such as tasks and runs are intentionally excluded; use /describe/business.",
336+ "Host operations return the structured host-ops union inside the outer conductor HTTP envelope."
337 ]
338 });
339 }
340@@ -927,7 +1130,8 @@ async function handleCapabilitiesRead(
341 system: await buildSystemStateData(repository),
342 notes: [
343 "Read routes are safe for discovery and inspection.",
344- "POST /v1/system/* writes the local automation mode immediately."
345+ "POST /v1/system/* writes the local automation mode immediately.",
346+ "POST /v1/exec and POST /v1/files/* return the structured host-ops union in data."
347 ]
348 });
349 }
350@@ -989,6 +1193,36 @@ async function handleSystemMutation(
351 );
352 }
353
354+async function handleHostExec(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
355+ const body = readBodyObject(context.request, true);
356+
357+ return buildSuccessEnvelope(
358+ context.requestId,
359+ 200,
360+ (await executeCommand(buildExecOperationRequest(body))) as unknown as JsonValue
361+ );
362+}
363+
364+async function handleHostFileRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
365+ const body = readBodyObject(context.request, true);
366+
367+ return buildSuccessEnvelope(
368+ context.requestId,
369+ 200,
370+ (await readTextFile(buildFileReadOperationRequest(body))) as unknown as JsonValue
371+ );
372+}
373+
374+async function handleHostFileWrite(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
375+ const body = readBodyObject(context.request, true);
376+
377+ return buildSuccessEnvelope(
378+ context.requestId,
379+ 200,
380+ (await writeTextFile(buildFileWriteOperationRequest(body))) as unknown as JsonValue
381+ );
382+}
383+
384 async function handleControllersList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
385 const repository = requireRepository(context.repository);
386 const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
387@@ -1140,6 +1374,12 @@ async function dispatchBusinessRoute(
388 return handleSystemMutation(context, "running");
389 case "system.drain":
390 return handleSystemMutation(context, "draining");
391+ case "host.exec":
392+ return handleHostExec(context);
393+ case "host.files.read":
394+ return handleHostFileRead(context);
395+ case "host.files.write":
396+ return handleHostFileWrite(context);
397 case "controllers.list":
398 return handleControllersList(context);
399 case "tasks.list":
+105,
-0
1@@ -0,0 +1,105 @@
2+---
3+task_id: T-C003
4+title: 把 host-ops 接到 HTTP
5+status: review
6+branch: feat/conductor-host-ops-http
7+repo: /Users/george/code/baa-conductor
8+base_ref: main@0cdbd8a
9+depends_on: []
10+write_scope:
11+ - packages/host-ops/**
12+ - apps/conductor-daemon/**
13+ - docs/api/**
14+updated_at: 2026-03-22
15+---
16+
17+# 把 host-ops 接到 HTTP
18+
19+## 目标
20+
21+把 `@baa-conductor/host-ops` 真正挂到 `conductor-daemon` 的本地 HTTP 面,提供:
22+
23+- `POST /v1/exec`
24+- `POST /v1/files/read`
25+- `POST /v1/files/write`
26+
27+## 本任务包含
28+
29+- 为 `conductor-daemon` 增加 host-ops HTTP 路由
30+- 明确 `cwd`、`timeoutMs`、`path`、`overwrite`、`createParents` 等输入语义
31+- 保持结构化返回,复用 `@baa-conductor/host-ops` 的 success/failure union
32+- 补充最小集成测试和 API 文档
33+
34+## 本任务不包含
35+
36+- 不修改 Firefox 插件
37+- 不修改 `control-api-worker`
38+- 不把这些能力暴露到公网控制面
39+
40+## 建议起始文件
41+
42+- `packages/host-ops/src/index.ts`
43+- `apps/conductor-daemon/src/local-api.ts`
44+- `apps/conductor-daemon/src/index.test.js`
45+- `docs/api/local-host-ops.md`
46+- `docs/api/README.md`
47+
48+## 交付物
49+
50+- `conductor-daemon` 上线的本地 host-ops HTTP 接口
51+- 更新后的 host-ops 合同与 API 文档
52+- 已回写状态的任务卡
53+
54+## 验收
55+
56+- `conductor-daemon` / `host-ops` 相关 typecheck / build / test 通过
57+- 至少给出最小 curl 示例和本地验证
58+- `git diff --check` 通过
59+
60+## files_changed
61+
62+- `coordination/tasks/T-C003.md`
63+- `packages/host-ops/package.json`
64+- `packages/host-ops/tsconfig.json`
65+- `packages/host-ops/src/index.ts`
66+- `packages/host-ops/src/index.test.js`
67+- `apps/conductor-daemon/package.json`
68+- `apps/conductor-daemon/src/local-api.ts`
69+- `apps/conductor-daemon/src/index.test.js`
70+- `docs/api/README.md`
71+- `docs/api/local-host-ops.md`
72+- `docs/api/business-interfaces.md`
73+- `docs/api/control-interfaces.md`
74+- `docs/api/hand-shell-migration.md`
75+
76+## commands_run
77+
78+- `npx --yes pnpm -C /Users/george/Desktop/baa-conductor-host-ops-http install`
79+- `npx --yes pnpm -C /Users/george/Desktop/baa-conductor-host-ops-http -F @baa-conductor/host-ops typecheck`
80+- `npx --yes pnpm -C /Users/george/Desktop/baa-conductor-host-ops-http -F @baa-conductor/host-ops build`
81+- `npx --yes pnpm -C /Users/george/Desktop/baa-conductor-host-ops-http -F @baa-conductor/host-ops test`
82+- `npx --yes pnpm -C /Users/george/Desktop/baa-conductor-host-ops-http -F @baa-conductor/conductor-daemon typecheck`
83+- `npx --yes pnpm -C /Users/george/Desktop/baa-conductor-host-ops-http -F @baa-conductor/conductor-daemon build`
84+- `npx --yes pnpm -C /Users/george/Desktop/baa-conductor-host-ops-http -F @baa-conductor/conductor-daemon test`
85+- `curl -X POST http://127.0.0.1:43219/v1/exec ...`
86+- `curl -X POST http://127.0.0.1:43219/v1/files/write ...`
87+- `curl -X POST http://127.0.0.1:43219/v1/files/read ...`
88+- `git -C /Users/george/Desktop/baa-conductor-host-ops-http diff --check`
89+
90+## result
91+
92+- `conductor-daemon` 新增 `POST /v1/exec`、`POST /v1/files/read`、`POST /v1/files/write`,直接复用 `@baa-conductor/host-ops`
93+- HTTP 外层继续使用统一 success envelope;具体 host-op 成功或失败通过 `data.ok` 返回结构化 union
94+- `files/write` 增加 `overwrite` 语义和 `FILE_ALREADY_EXISTS` 结构化错误;`createParents` / `overwrite` 都做了运行时校验
95+- `/describe`、`/describe/control`、`/v1/capabilities` 现在会显式暴露 host-ops 能力、curl 示例和输入语义
96+- 已补 package 级测试、handler 级测试和真实 HTTP listener 集成测试,并做过一次独立 curl 冒烟验证
97+
98+## risks
99+
100+- 当前 host-ops 只在 `conductor-daemon` 本地 listener 上可用,没有额外鉴权层;依赖现有 local-network-only 部署边界
101+- HTTP 外层 `ok` 始终表示 conductor 路由处理成功;调用方必须继续检查 `data.ok` 才能判断 host-op 本身是否成功
102+
103+## next_handoff
104+
105+- 如果后续需要把同一能力暴露到远端控制面,再单独设计 `control-api-worker` 的鉴权、限流和审计策略
106+- 如果后续需要更强文件写保护,可以继续补路径白名单或 workspace sandbox,而不是把权限逻辑塞进当前最小 host-ops 层
+42,
-3
1@@ -27,7 +27,7 @@
2
3 | 服务 | 地址 | 说明 |
4 | --- | --- | --- |
5-| conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/runs |
6+| conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/runs/host-ops |
7 | conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
8 | status-api | `https://conductor.makefile.so` | 只读状态 JSON 和 HTML 视图 |
9 | control-api | `https://control-api.makefile.so` | 仍可保留给遗留/远端控制面合同,但不再是下列业务接口的真相源 |
10@@ -40,8 +40,9 @@
11 2. 如有需要,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
12 3. 如果是控制动作,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
13 4. 按需查看 `controllers`、`tasks`、`runs`
14-5. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain`
15-6. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
16+5. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`
17+6. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
18+7. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
19
20 如果是给 AI 写操作说明,优先引用:
21
22@@ -75,6 +76,23 @@
23 - 成功时直接返回最新 system state,而不是只回一个 ack
24 - 这样 HTTP 客户端和 WS `action_request` 都能复用同一份状态合同
25
26+### 本机 Host Ops 接口
27+
28+| 方法 | 路径 | 说明 |
29+| --- | --- | --- |
30+| `POST` | `/v1/exec` | 在本机执行 shell 命令,返回结构化 stdout/stderr/result union |
31+| `POST` | `/v1/files/read` | 读取本机文本文件,返回内容和元数据 |
32+| `POST` | `/v1/files/write` | 写入本机文本文件,支持 `overwrite` / `createParents` |
33+
34+host-ops 约定:
35+
36+- 这三条路由的 HTTP 外层仍然是 `conductor-daemon` 统一 envelope
37+- 具体操作成功或失败看 `data.ok`
38+- `cwd` 省略时使用 daemon 进程当前工作目录
39+- `path` 可为绝对路径,也可相对 `cwd`
40+- `timeoutMs` 仅用于 `/v1/exec`,默认 `30000`
41+- `overwrite` 仅用于 `/v1/files/write`,默认 `true`
42+
43 ### 只读业务接口
44
45 | 方法 | 路径 | 说明 |
46@@ -162,3 +180,24 @@ curl -X POST "${LOCAL_API_BASE}/v1/system/pause" \
47 -H 'Content-Type: application/json' \
48 -d '{"requested_by":"human_operator","reason":"manual_pause"}'
49 ```
50+
51+```bash
52+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
53+curl -X POST "${LOCAL_API_BASE}/v1/exec" \
54+ -H 'Content-Type: application/json' \
55+ -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
56+```
57+
58+```bash
59+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
60+curl -X POST "${LOCAL_API_BASE}/v1/files/read" \
61+ -H 'Content-Type: application/json' \
62+ -d '{"path":"README.md","cwd":"/Users/george/code/baa-conductor","encoding":"utf8"}'
63+```
64+
65+```bash
66+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
67+curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
68+ -H 'Content-Type: application/json' \
69+ -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
70+```
+9,
-8
1@@ -38,7 +38,7 @@
2 2. 再调 `GET /describe/business`
3 3. 再调 `GET /v1/capabilities`
4 4. 只使用 `capabilities` 明确声明存在的接口
5-5. 不要假设 `/v1/exec`、`/v1/files/read`、`/v1/files/write`、`POST /v1/tasks` 一定已经上线
6+5. 如果目的是本机 shell 或文件操作,切到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
7
8 ## 当前已经可用的业务类接口
9
10@@ -60,18 +60,18 @@
11 | `GET` | `/v1/runs?limit=20` | 查看运行列表 |
12 | `GET` | `/v1/runs/:run_id` | 查看单个运行详情 |
13
14-## 当前不要假设已经可用的业务写接口
15+## 当前不在业务面讨论的写接口
16
17-这些能力已经有迁移方向,但当前不要让网页版 AI、CLI AI 在没有再次确认 `/describe/business` 和 `/v1/capabilities` 的情况下直接调用:
18+下面这些能力里,`/v1/exec`、`/v1/files/read`、`/v1/files/write` 已经在本地 control / host-ops 面可用,但它们不属于“业务查询”接口:
19
20 | 方法 | 路径 | 当前状态 |
21 | --- | --- | --- |
22-| `POST` | `/v1/exec` | 迁移中,不要默认假设已上线 |
23-| `POST` | `/v1/files/read` | 迁移中,不要默认假设已上线 |
24-| `POST` | `/v1/files/write` | 迁移中,不要默认假设已上线 |
25+| `POST` | `/v1/exec` | 已可用;请改读 `control-interfaces.md` 和 `/describe/control` |
26+| `POST` | `/v1/files/read` | 已可用;请改读 `control-interfaces.md` 和 `/describe/control` |
27+| `POST` | `/v1/files/write` | 已可用;请改读 `control-interfaces.md` 和 `/describe/control` |
28 | `POST` | `/v1/tasks` | 迁移中,不要默认假设已上线 |
29
30-如果未来这些接口上线,应以 `/describe` 和 `/v1/capabilities` 的实际返回为准。
31+是否真的应该调用,仍然以 `/describe` 和 `/v1/capabilities` 的实际返回为准。
32
33 ## 推荐给 AI 的调用顺序
34
35@@ -117,11 +117,12 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
36
37 ```text
38 先阅读业务类接口文档,再请求 /describe/business 和 /v1/capabilities。
39-只有在确认接口存在后,才调用具体业务接口;不要假设写接口已经上线。
40+只有在确认接口存在后,才调用具体业务接口;如果要做本机 shell 或文件操作,改读 /describe/control。
41 ```
42
43 ## 当前边界
44
45 - 业务类接口当前以“只读查询”为主
46 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
47+- 本机能力接口 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 也不在本文件讨论范围内
48 - 控制类接口见 [`control-interfaces.md`](./control-interfaces.md)
+36,
-4
1@@ -29,9 +29,10 @@
2 2. 调 `GET /describe/control`
3 3. 如有需要,再调 `GET /v1/capabilities`
4 4. 调 `GET /v1/system/state`
5-5. 确认当前模式和动作目标后,再执行 `pause` / `resume` / `drain`
6+5. 如果要做本机 shell / 文件操作,再看 `host_operations`
7+6. 确认当前模式和动作目标后,再执行 `pause` / `resume` / `drain` 或 host-ops
8
9-不要在未读状态前直接写入控制动作。
10+不要在未读状态前直接写入控制动作或本机 host-ops。
11
12 ## 当前可用控制类接口
13
14@@ -58,6 +59,21 @@
15 | `POST` | `/v1/system/resume` | 切到 `running` |
16 | `POST` | `/v1/system/drain` | 切到 `draining` |
17
18+### 本机 Host Ops
19+
20+| 方法 | 路径 | 作用 |
21+| --- | --- | --- |
22+| `POST` | `/v1/exec` | 在当前节点执行 shell 命令,返回结构化 stdout/stderr |
23+| `POST` | `/v1/files/read` | 读取当前节点文本文件 |
24+| `POST` | `/v1/files/write` | 写入当前节点文本文件,支持 `overwrite` / `createParents` |
25+
26+输入语义:
27+
28+- `cwd`:省略时使用 daemon 进程当前工作目录
29+- `path`:可为绝对路径,也可相对 `cwd`
30+- `timeoutMs`:仅 `/v1/exec` 使用,默认 `30000`
31+- `overwrite`:仅 `/v1/files/write` 使用,默认 `true`
32+
33 ### 低层诊断
34
35 | 方法 | 路径 | 作用 |
36@@ -113,16 +129,32 @@ curl -X POST "${BASE_URL}/v1/system/drain" \
37 -d '{"requested_by":"web_ai","reason":"manual_drain"}'
38 ```
39
40+### 需要时再执行本机 host-ops
41+
42+```bash
43+BASE_URL="http://100.71.210.78:4317"
44+curl -X POST "${BASE_URL}/v1/exec" \
45+ -H 'Content-Type: application/json' \
46+ -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
47+```
48+
49+```bash
50+BASE_URL="http://100.71.210.78:4317"
51+curl -X POST "${BASE_URL}/v1/files/write" \
52+ -H 'Content-Type: application/json' \
53+ -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
54+```
55+
56 ## 给 CLI / 网页版 AI 的推荐提示
57
58 ```text
59 先阅读控制类接口文档,再请求 /describe/control、/v1/capabilities、/v1/system/state。
60-只有在确认当前状态后,才执行 pause / resume / drain。
61+只有在确认当前状态后,才执行 pause / resume / drain;如果要做本机 shell / 文件操作,也先看 host_operations。
62 ```
63
64 ## 当前边界
65
66 - 当前控制面是单节点 `mini`
67 - 控制动作默认作用于当前唯一活动节点
68-- 业务查询和未来的本机能力接口不在本文件内讨论
69+- 业务查询不在本文件讨论范围内
70 - 业务类接口见 [`business-interfaces.md`](./business-interfaces.md)
+9,
-1
1@@ -140,7 +140,7 @@
2 当前进度:
3
4 - 底层合同与最小 Node 实现已经落到 `packages/host-ops`
5-- 还没有正式挂到 `conductor-daemon`
6+- 已经正式挂到 `conductor-daemon` 本地 HTTP 面
7 - 还没有对外暴露成 `control-api` 路由
8
9 说明:
10@@ -239,6 +239,14 @@
11
12 - 把 hand / shell 的本机能力和异步任务体验统一进 `baa-conductor`
13
14+当前状态:
15+
16+- `POST /v1/exec`
17+- `POST /v1/files/read`
18+- `POST /v1/files/write`
19+
20+已经在本地 `conductor-daemon` 上线;`POST /v1/tasks` 和详情类接口仍按原计划推进。
21+
22 ### 第四批
23
24 - 切浏览器、CLI、运维脚本和文档默认目标
+91,
-20
1@@ -1,13 +1,18 @@
2 # Local Host Ops Contract
3
4-`@baa-conductor/host-ops` 是给后续 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 准备的本地能力层基础包。
5+`@baa-conductor/host-ops` 现在已经作为本机能力层挂到 `conductor-daemon` 的本地 HTTP 面:
6+
7+- `POST /v1/exec`
8+- `POST /v1/files/read`
9+- `POST /v1/files/write`
10
11 当前状态:
12
13 - 已有最小 Node 实现
14 - 已有结构化输入输出合同
15-- 已有 smoke test
16-- 还没有挂到 `conductor-daemon` / `control-api`
17+- 已有 package smoke / HTTP 集成测试
18+- 已挂到 `conductor-daemon` 本地 API
19+- 仍然没有挂到 `control-api-worker`
20
21 ## Operations
22
23@@ -15,12 +20,23 @@
24 | --- | --- | --- | --- |
25 | `exec` | `command`, `cwd?`, `timeoutMs?`, `maxBufferBytes?` | `stdout`, `stderr`, `exitCode`, `signal`, `durationMs`, `startedAt`, `finishedAt`, `timedOut` | `INVALID_INPUT`, `EXEC_TIMEOUT`, `EXEC_EXIT_NON_ZERO`, `EXEC_OUTPUT_LIMIT`, `EXEC_FAILED` |
26 | `files/read` | `path`, `cwd?`, `encoding?` | `absolutePath`, `content`, `sizeBytes`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `FILE_NOT_FOUND`, `NOT_A_FILE`, `FILE_READ_FAILED` |
27-| `files/write` | `path`, `content`, `cwd?`, `encoding?`, `createParents?` | `absolutePath`, `bytesWritten`, `created`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `NOT_A_FILE`, `FILE_WRITE_FAILED` |
28+| `files/write` | `path`, `content`, `cwd?`, `encoding?`, `createParents?`, `overwrite?` | `absolutePath`, `bytesWritten`, `created`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `FILE_ALREADY_EXISTS`, `NOT_A_FILE`, `FILE_WRITE_FAILED` |
29
30 当前文本编码只支持 `utf8`。
31
32+## Input Semantics
33+
34+- `cwd`:可选字符串。省略时使用 `conductor-daemon` 进程当前工作目录。
35+- `path`:可为绝对路径;相对路径会相对 `cwd` 解析。
36+- `timeoutMs`:仅 `exec` 使用。可选整数 `>= 0`,默认 `30000`;`0` 表示不启用超时。
37+- `maxBufferBytes`:仅 `exec` 使用。可选整数 `> 0`,默认 `10485760`。
38+- `createParents`:仅 `files/write` 使用。可选布尔值,默认 `true`;会递归创建缺失父目录。
39+- `overwrite`:仅 `files/write` 使用。可选布尔值,默认 `true`;为 `false` 且目标文件已存在时返回 `FILE_ALREADY_EXISTS`。
40+
41 ## Response Shape
42
43+包级返回 union 仍保持不变。
44+
45 成功返回:
46
47 ```json
48@@ -47,34 +63,89 @@
49 ```json
50 {
51 "ok": false,
52- "operation": "exec",
53+ "operation": "files/write",
54 "input": {
55- "command": "sh -c \"exit 7\"",
56+ "path": "tmp/demo.txt",
57 "cwd": "/Users/george/code/baa-conductor",
58- "timeoutMs": 30000,
59- "maxBufferBytes": 10485760
60+ "content": "hello",
61+ "encoding": "utf8",
62+ "createParents": true,
63+ "overwrite": false
64 },
65 "error": {
66- "code": "EXEC_EXIT_NON_ZERO",
67- "message": "Command exited with code 7.",
68+ "code": "FILE_ALREADY_EXISTS",
69+ "message": "File already exists at /Users/george/code/baa-conductor/tmp/demo.txt and overwrite=false.",
70 "retryable": false,
71 "details": {
72- "exitCode": 7
73+ "overwrite": false
74 }
75 },
76 "result": {
77- "stdout": "",
78- "stderr": "",
79- "exitCode": 7,
80- "signal": null,
81- "durationMs": 18,
82- "startedAt": "2026-03-22T09:10:00.000Z",
83- "finishedAt": "2026-03-22T09:10:00.018Z",
84- "timedOut": false
85+ "absolutePath": "/Users/george/code/baa-conductor/tmp/demo.txt",
86+ "encoding": "utf8"
87+ }
88+}
89+```
90+
91+## HTTP Envelope
92+
93+`conductor-daemon` 的 HTTP 返回外层仍然使用统一 envelope:
94+
95+```json
96+{
97+ "ok": true,
98+ "request_id": "req_xxx",
99+ "data": {
100+ "ok": true,
101+ "operation": "exec",
102+ "input": {
103+ "command": "printf 'hello'",
104+ "cwd": "/tmp",
105+ "timeoutMs": 2000,
106+ "maxBufferBytes": 10485760
107+ },
108+ "result": {
109+ "stdout": "hello",
110+ "stderr": "",
111+ "exitCode": 0,
112+ "signal": null,
113+ "durationMs": 12,
114+ "startedAt": "2026-03-22T09:10:00.000Z",
115+ "finishedAt": "2026-03-22T09:10:00.012Z",
116+ "timedOut": false
117+ }
118 }
119 }
120 ```
121
122+也就是说:
123+
124+- 外层 `ok` / `request_id` 属于 `conductor-daemon` HTTP 协议
125+- 内层 `data.ok` / `data.operation` / `data.error` 属于 `host-ops` 结构化结果
126+
127+## Minimal Curl
128+
129+```bash
130+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
131+curl -X POST "${LOCAL_API_BASE}/v1/exec" \
132+ -H 'Content-Type: application/json' \
133+ -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
134+```
135+
136+```bash
137+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
138+curl -X POST "${LOCAL_API_BASE}/v1/files/read" \
139+ -H 'Content-Type: application/json' \
140+ -d '{"path":"README.md","cwd":"/Users/george/code/baa-conductor","encoding":"utf8"}'
141+```
142+
143+```bash
144+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
145+curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
146+ -H 'Content-Type: application/json' \
147+ -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
148+```
149+
150 ## Package API
151
152 导出函数:
153@@ -84,4 +155,4 @@
154 - `writeTextFile(request)`
155 - `runHostOperation(request)`
156
157-它们全部返回结构化 result union,不依赖 HTTP 层。
158+它们全部返回结构化 result union,不依赖 HTTP 层;HTTP 层只是把这份 union 包进 `data`。
+5,
-4
1@@ -2,14 +2,15 @@
2 "name": "@baa-conductor/host-ops",
3 "private": true,
4 "type": "module",
5+ "main": "dist/index.js",
6 "exports": {
7- ".": "./src/index.ts"
8+ ".": "./dist/index.js"
9 },
10- "types": "./src/index.ts",
11+ "types": "dist/index.d.ts",
12 "scripts": {
13- "build": "pnpm exec tsc --noEmit -p tsconfig.json",
14+ "build": "pnpm exec tsc -p tsconfig.json",
15 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
16- "test": "node --test --experimental-strip-types src/index.test.js",
17+ "test": "pnpm run build && node --test --experimental-strip-types src/index.test.js",
18 "smoke": "pnpm run test"
19 }
20 }
+30,
-0
1@@ -78,3 +78,33 @@ test("writeTextFile and readTextFile roundtrip content with metadata", async ()
2 await rm(directory, { force: true, recursive: true });
3 }
4 });
5+
6+test("writeTextFile can reject overwriting an existing file with a structured failure", async () => {
7+ const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-overwrite-"));
8+ const targetFile = join(directory, "hello.txt");
9+
10+ try {
11+ const firstWrite = await writeTextFile({
12+ path: targetFile,
13+ content: "first version"
14+ });
15+
16+ assert.equal(firstWrite.ok, true);
17+
18+ const secondWrite = await writeTextFile({
19+ path: targetFile,
20+ content: "second version",
21+ overwrite: false
22+ });
23+
24+ assert.equal(secondWrite.ok, false);
25+ assert.equal(secondWrite.operation, "files/write");
26+ assert.equal(secondWrite.error.code, "FILE_ALREADY_EXISTS");
27+ assert.equal(secondWrite.result?.absolutePath, targetFile);
28+
29+ const persistedContent = await readFile(targetFile, "utf8");
30+ assert.equal(persistedContent, "first version");
31+ } finally {
32+ await rm(directory, { force: true, recursive: true });
33+ }
34+});
+106,
-7
1@@ -9,6 +9,7 @@ export const HOST_OP_ERROR_CODES = [
2 "EXEC_EXIT_NON_ZERO",
3 "EXEC_OUTPUT_LIMIT",
4 "EXEC_FAILED",
5+ "FILE_ALREADY_EXISTS",
6 "FILE_NOT_FOUND",
7 "NOT_A_FILE",
8 "FILE_READ_FAILED",
9@@ -125,6 +126,7 @@ export interface FileWriteOperationRequest {
10 createParents?: boolean;
11 cwd?: string;
12 encoding?: string;
13+ overwrite?: boolean;
14 path: string;
15 }
16
17@@ -133,6 +135,7 @@ export interface FileWriteOperationInput {
18 createParents: boolean;
19 cwd: string;
20 encoding: HostOpTextEncoding;
21+ overwrite: boolean;
22 path: string;
23 }
24
25@@ -240,6 +243,14 @@ function normalizePositiveInteger(value: unknown, defaultValue: number, allowZer
26 return value > 0 ? value : null;
27 }
28
29+function normalizeBoolean(value: unknown, defaultValue: boolean): boolean | null {
30+ if (value === undefined) {
31+ return defaultValue;
32+ }
33+
34+ return typeof value === "boolean" ? value : null;
35+}
36+
37 function createHostOpError(
38 code: HostOpErrorCode,
39 message: string,
40@@ -441,6 +452,8 @@ function normalizeWriteRequest(
41 request: FileWriteOperationRequest
42 ): NormalizedInputResult<"files/write", FileWriteOperationInput> {
43 const cwd = normalizeCwd(request.cwd);
44+ const createParents = normalizeBoolean(request.createParents, true);
45+ const overwrite = normalizeBoolean(request.overwrite, true);
46
47 if (cwd === null) {
48 return {
49@@ -450,9 +463,10 @@ function normalizeWriteRequest(
50 operation: "files/write",
51 input: {
52 content: request.content,
53- createParents: request.createParents ?? true,
54+ createParents: createParents ?? true,
55 cwd: getDefaultCwd(),
56 encoding: DEFAULT_TEXT_ENCODING,
57+ overwrite: overwrite ?? true,
58 path: request.path
59 },
60 error: createHostOpError("INVALID_INPUT", "files/write.cwd must be a non-empty string when provided.", false)
61@@ -470,9 +484,10 @@ function normalizeWriteRequest(
62 operation: "files/write",
63 input: {
64 content: request.content,
65- createParents: request.createParents ?? true,
66+ createParents: createParents ?? true,
67 cwd,
68 encoding: DEFAULT_TEXT_ENCODING,
69+ overwrite: overwrite ?? true,
70 path: request.path
71 },
72 error: createHostOpError("INVALID_INPUT", "files/write.encoding currently only supports utf8.", false)
73@@ -480,6 +495,52 @@ function normalizeWriteRequest(
74 };
75 }
76
77+ if (createParents === null) {
78+ return {
79+ ok: false,
80+ response: {
81+ ok: false,
82+ operation: "files/write",
83+ input: {
84+ content: request.content,
85+ createParents: true,
86+ cwd,
87+ encoding,
88+ overwrite: overwrite ?? true,
89+ path: request.path
90+ },
91+ error: createHostOpError(
92+ "INVALID_INPUT",
93+ "files/write.createParents must be a boolean when provided.",
94+ false
95+ )
96+ }
97+ };
98+ }
99+
100+ if (overwrite === null) {
101+ return {
102+ ok: false,
103+ response: {
104+ ok: false,
105+ operation: "files/write",
106+ input: {
107+ content: request.content,
108+ createParents,
109+ cwd,
110+ encoding,
111+ overwrite: true,
112+ path: request.path
113+ },
114+ error: createHostOpError(
115+ "INVALID_INPUT",
116+ "files/write.overwrite must be a boolean when provided.",
117+ false
118+ )
119+ }
120+ };
121+ }
122+
123 if (!isNonEmptyString(request.path)) {
124 return {
125 ok: false,
126@@ -488,9 +549,10 @@ function normalizeWriteRequest(
127 operation: "files/write",
128 input: {
129 content: request.content,
130- createParents: request.createParents ?? true,
131+ createParents,
132 cwd,
133 encoding,
134+ overwrite,
135 path: typeof request.path === "string" ? request.path : ""
136 },
137 error: createHostOpError("INVALID_INPUT", "files/write.path must be a non-empty string.", false)
138@@ -506,9 +568,10 @@ function normalizeWriteRequest(
139 operation: "files/write",
140 input: {
141 content: "",
142- createParents: request.createParents ?? true,
143+ createParents,
144 cwd,
145 encoding,
146+ overwrite,
147 path: request.path
148 },
149 error: createHostOpError("INVALID_INPUT", "files/write.content must be a string.", false)
150@@ -520,9 +583,10 @@ function normalizeWriteRequest(
151 ok: true,
152 input: {
153 content: request.content,
154- createParents: request.createParents ?? true,
155+ createParents,
156 cwd,
157 encoding,
158+ overwrite,
159 path: request.path
160 }
161 };
162@@ -734,8 +798,33 @@ export async function writeTextFile(
163 let created = true;
164
165 try {
166- await stat(absolutePath);
167+ const existingStats = await stat(absolutePath);
168 created = false;
169+
170+ if (!existingStats.isFile()) {
171+ return {
172+ ok: false,
173+ operation: "files/write",
174+ input: normalized.input,
175+ error: createHostOpError("NOT_A_FILE", `Expected a file path at ${absolutePath}.`, false),
176+ result: buildFsFailureResult(absolutePath, normalized.input.encoding)
177+ };
178+ }
179+
180+ if (!normalized.input.overwrite) {
181+ return {
182+ ok: false,
183+ operation: "files/write",
184+ input: normalized.input,
185+ error: createHostOpError(
186+ "FILE_ALREADY_EXISTS",
187+ `File already exists at ${absolutePath} and overwrite=false.`,
188+ false,
189+ { overwrite: normalized.input.overwrite }
190+ ),
191+ result: buildFsFailureResult(absolutePath, normalized.input.encoding)
192+ };
193+ }
194 } catch (error) {
195 if (getErrorCode(error) !== "ENOENT") {
196 created = false;
197@@ -747,7 +836,10 @@ export async function writeTextFile(
198 await mkdir(dirname(absolutePath), { recursive: true });
199 }
200
201- await writeFile(absolutePath, normalized.input.content, normalized.input.encoding);
202+ await writeFile(absolutePath, normalized.input.content, {
203+ encoding: normalized.input.encoding,
204+ flag: normalized.input.overwrite ? "w" : "wx"
205+ });
206 const fileStats = await stat(absolutePath);
207
208 return {
209@@ -767,6 +859,13 @@ export async function writeTextFile(
210 const errorPayload =
211 errorCode === "EISDIR"
212 ? createHostOpError("NOT_A_FILE", `Expected a file path at ${absolutePath}.`, false)
213+ : errorCode === "EEXIST"
214+ ? createHostOpError(
215+ "FILE_ALREADY_EXISTS",
216+ `File already exists at ${absolutePath} and overwrite=false.`,
217+ false,
218+ { overwrite: normalized.input.overwrite }
219+ )
220 : createHostOpError(
221 "FILE_WRITE_FAILED",
222 getErrorMessage(error),
+1,
-0
1@@ -1,6 +1,7 @@
2 {
3 "extends": "../../tsconfig.base.json",
4 "compilerOptions": {
5+ "declaration": true,
6 "rootDir": "src",
7 "outDir": "dist"
8 },