baa-conductor

git clone 

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
M apps/conductor-daemon/package.json
+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 }
M apps/conductor-daemon/src/index.test.js
+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 () => {
M apps/conductor-daemon/src/local-api.ts
+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":
A coordination/tasks/T-C003.md
+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 层
M docs/api/README.md
+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+```
M docs/api/business-interfaces.md
+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)
M docs/api/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)
M docs/api/hand-shell-migration.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、运维脚本和文档默认目标
M docs/api/local-host-ops.md
+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`。
M packages/host-ops/package.json
+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 }
M packages/host-ops/src/index.test.js
+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+});
M packages/host-ops/src/index.ts
+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),
M packages/host-ops/tsconfig.json
+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   },