baa-conductor

git clone 

commit
62f2f48
parent
ac0514a
author
im_wower
date
2026-03-22 17:42:08 +0800 CST
feat(host-ops): add local host operations package
8 files changed,  +1154, -0
A coordination/tasks/T-L004.md
+95, -0
 1@@ -0,0 +1,95 @@
 2+---
 3+task_id: T-L004
 4+title: 本机能力层基础包
 5+status: review
 6+branch: feat/local-host-ops-package
 7+repo: /Users/george/code/baa-conductor
 8+base_ref: main@c9e1441
 9+depends_on:
10+  - T-L003
11+write_scope:
12+  - packages/host-ops/**
13+  - docs/api/**
14+updated_at: 2026-03-22
15+---
16+
17+# 本机能力层基础包
18+
19+## 目标
20+
21+参考 `baa-shell`,在当前项目里落一个本地能力层包,承接:
22+
23+- `exec`
24+- `files/read`
25+- `files/write`
26+
27+这一步先不要求把接口直接挂到 `conductor-daemon` 上,先把底层能力和结构化返回做出来。
28+
29+## 本任务包含
30+
31+- 新建 `@baa-conductor/host-ops`
32+- 补 `exec` / `files/read` / `files/write` 的结构化合同
33+- 补最小 Node 实现
34+- 补最小 smoke / test
35+- 更新迁移文档,标记 host-ops 已经落地为后续 HTTP 接口基础层
36+
37+## 本任务不包含
38+
39+- 不把接口直接挂到 `conductor-daemon`
40+- 不修改 Firefox 插件
41+- 不恢复 Cloudflare Worker 路径
42+
43+## 建议起始文件
44+
45+- `packages/host-ops/package.json`
46+- `packages/host-ops/src/index.ts`
47+- `packages/host-ops/src/index.test.js`
48+- `docs/api/local-host-ops.md`
49+- `docs/api/hand-shell-migration.md`
50+
51+## 交付物
52+
53+- `@baa-conductor/host-ops` 包
54+- `docs/api/local-host-ops.md`
55+- 已回写状态的任务卡
56+
57+## 验收
58+
59+- `@baa-conductor/host-ops` typecheck / build / test 通过
60+- `git diff --check` 通过
61+
62+## files_changed
63+
64+- `coordination/tasks/T-L004.md`
65+- `packages/host-ops/package.json`
66+- `packages/host-ops/tsconfig.json`
67+- `packages/host-ops/src/index.ts`
68+- `packages/host-ops/src/index.test.js`
69+- `packages/host-ops/src/node-shims.d.ts`
70+- `docs/api/local-host-ops.md`
71+- `docs/api/hand-shell-migration.md`
72+
73+## commands_run
74+
75+- `npx --yes pnpm --filter @baa-conductor/host-ops typecheck`
76+- `npx --yes pnpm --filter @baa-conductor/host-ops build`
77+- `npx --yes pnpm --filter @baa-conductor/host-ops test`
78+- `git diff --check`
79+
80+## result
81+
82+- 新增 `@baa-conductor/host-ops`,提供 `executeCommand`、`readTextFile`、`writeTextFile`、`runHostOperation`
83+- 为 `exec` / `files/read` / `files/write` 定义了结构化 success / failure contract
84+- 增加了最小 Node 实现和 smoke test
85+- 增加 `docs/api/local-host-ops.md` 作为后续 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 的合同文档
86+- 在迁移文档里记录 host-ops 已经落地,但尚未挂到 `conductor-daemon`
87+
88+## risks
89+
90+- 目前只是底层能力层,HTTP 路由还没接到 `conductor-daemon`
91+- 现在没有额外权限边界;后续挂 HTTP 时还需要明确路径限制和输入校验策略
92+
93+## next_handoff
94+
95+- 把 `@baa-conductor/host-ops` 接到 `conductor-daemon` 的 `/v1/exec`、`/v1/files/read`、`/v1/files/write`
96+- 给这些接口补 curl 示例和最小集成 smoke
M docs/api/hand-shell-migration.md
+6, -0
 1@@ -137,6 +137,12 @@
 2 - `POST /v1/files/read`
 3 - `POST /v1/files/write`
 4 
 5+当前进度:
 6+
 7+- 底层合同与最小 Node 实现已经落到 `packages/host-ops`
 8+- 还没有正式挂到 `conductor-daemon`
 9+- 还没有对外暴露成 `control-api` 路由
10+
11 说明:
12 
13 - 这层承接 `baa-shell` 的“小而直接”的使用体验
A docs/api/local-host-ops.md
+87, -0
 1@@ -0,0 +1,87 @@
 2+# Local Host Ops Contract
 3+
 4+`@baa-conductor/host-ops` 是给后续 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 准备的本地能力层基础包。
 5+
 6+当前状态:
 7+
 8+- 已有最小 Node 实现
 9+- 已有结构化输入输出合同
10+- 已有 smoke test
11+- 还没有挂到 `conductor-daemon` / `control-api`
12+
13+## Operations
14+
15+| operation | request | success.result | failure.error |
16+| --- | --- | --- | --- |
17+| `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` |
18+| `files/read` | `path`, `cwd?`, `encoding?` | `absolutePath`, `content`, `sizeBytes`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `FILE_NOT_FOUND`, `NOT_A_FILE`, `FILE_READ_FAILED` |
19+| `files/write` | `path`, `content`, `cwd?`, `encoding?`, `createParents?` | `absolutePath`, `bytesWritten`, `created`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `NOT_A_FILE`, `FILE_WRITE_FAILED` |
20+
21+当前文本编码只支持 `utf8`。
22+
23+## Response Shape
24+
25+成功返回:
26+
27+```json
28+{
29+  "ok": true,
30+  "operation": "files/read",
31+  "input": {
32+    "path": "README.md",
33+    "cwd": "/Users/george/code/baa-conductor",
34+    "encoding": "utf8"
35+  },
36+  "result": {
37+    "absolutePath": "/Users/george/code/baa-conductor/README.md",
38+    "content": "# baa-conductor\n...",
39+    "sizeBytes": 2783,
40+    "modifiedAt": "2026-03-22T09:01:00.000Z",
41+    "encoding": "utf8"
42+  }
43+}
44+```
45+
46+失败返回:
47+
48+```json
49+{
50+  "ok": false,
51+  "operation": "exec",
52+  "input": {
53+    "command": "sh -c \"exit 7\"",
54+    "cwd": "/Users/george/code/baa-conductor",
55+    "timeoutMs": 30000,
56+    "maxBufferBytes": 10485760
57+  },
58+  "error": {
59+    "code": "EXEC_EXIT_NON_ZERO",
60+    "message": "Command exited with code 7.",
61+    "retryable": false,
62+    "details": {
63+      "exitCode": 7
64+    }
65+  },
66+  "result": {
67+    "stdout": "",
68+    "stderr": "",
69+    "exitCode": 7,
70+    "signal": null,
71+    "durationMs": 18,
72+    "startedAt": "2026-03-22T09:10:00.000Z",
73+    "finishedAt": "2026-03-22T09:10:00.018Z",
74+    "timedOut": false
75+  }
76+}
77+```
78+
79+## Package API
80+
81+导出函数:
82+
83+- `executeCommand(request)`
84+- `readTextFile(request)`
85+- `writeTextFile(request)`
86+- `runHostOperation(request)`
87+
88+它们全部返回结构化 result union,不依赖 HTTP 层。
A packages/host-ops/package.json
+15, -0
 1@@ -0,0 +1,15 @@
 2+{
 3+  "name": "@baa-conductor/host-ops",
 4+  "private": true,
 5+  "type": "module",
 6+  "exports": {
 7+    ".": "./src/index.ts"
 8+  },
 9+  "types": "./src/index.ts",
10+  "scripts": {
11+    "build": "pnpm exec tsc --noEmit -p tsconfig.json",
12+    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
13+    "test": "node --test --experimental-strip-types src/index.test.js",
14+    "smoke": "pnpm run test"
15+  }
16+}
A packages/host-ops/src/index.test.js
+80, -0
 1@@ -0,0 +1,80 @@
 2+import assert from "node:assert/strict";
 3+import { mkdtemp, readFile, rm } from "node:fs/promises";
 4+import { tmpdir } from "node:os";
 5+import { join } from "node:path";
 6+import test from "node:test";
 7+
 8+import {
 9+  executeCommand,
10+  readTextFile,
11+  runHostOperation,
12+  writeTextFile
13+} from "./index.ts";
14+
15+test("executeCommand returns structured stdout for a successful command", async () => {
16+  const result = await executeCommand({
17+    command: "printf 'host-ops-smoke'",
18+    timeoutMs: 2_000
19+  });
20+
21+  assert.equal(result.ok, true);
22+  assert.equal(result.operation, "exec");
23+  assert.equal(result.result.stdout, "host-ops-smoke");
24+  assert.equal(result.result.stderr, "");
25+  assert.equal(result.result.exitCode, 0);
26+  assert.equal(result.result.timedOut, false);
27+});
28+
29+test("executeCommand returns a structured failure for non-zero exit codes", async () => {
30+  const result = await executeCommand({
31+    command: "sh -c \"printf broken >&2; exit 7\"",
32+    timeoutMs: 2_000
33+  });
34+
35+  assert.equal(result.ok, false);
36+  assert.equal(result.operation, "exec");
37+  assert.equal(result.error.code, "EXEC_EXIT_NON_ZERO");
38+  assert.equal(result.result?.exitCode, 7);
39+  assert.equal(result.result?.stderr, "broken");
40+});
41+
42+test("writeTextFile and readTextFile roundtrip content with metadata", async () => {
43+  const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-"));
44+  const targetFile = join(directory, "nested", "hello.txt");
45+
46+  try {
47+    const writeResult = await writeTextFile({
48+      path: targetFile,
49+      content: "hello local host ops"
50+    });
51+
52+    assert.equal(writeResult.ok, true);
53+    assert.equal(writeResult.operation, "files/write");
54+    assert.equal(writeResult.result.created, true);
55+    assert.equal(writeResult.result.bytesWritten > 0, true);
56+
57+    const readResult = await readTextFile({
58+      path: targetFile
59+    });
60+
61+    assert.equal(readResult.ok, true);
62+    assert.equal(readResult.operation, "files/read");
63+    assert.equal(readResult.result.content, "hello local host ops");
64+    assert.equal(readResult.result.absolutePath, targetFile);
65+    assert.equal(readResult.result.sizeBytes, 20);
66+
67+    const dispatcherResult = await runHostOperation({
68+      operation: "files/read",
69+      path: targetFile
70+    });
71+
72+    assert.equal(dispatcherResult.ok, true);
73+    assert.equal(dispatcherResult.operation, "files/read");
74+    assert.equal(dispatcherResult.result.content, "hello local host ops");
75+
76+    const persistedContent = await readFile(targetFile, "utf8");
77+    assert.equal(persistedContent, "hello local host ops");
78+  } finally {
79+    await rm(directory, { force: true, recursive: true });
80+  }
81+});
A packages/host-ops/src/index.ts
+796, -0
  1@@ -0,0 +1,796 @@
  2+import { exec } from "node:child_process";
  3+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
  4+import { dirname, isAbsolute, resolve } from "node:path";
  5+
  6+export const HOST_OPERATION_NAMES = ["exec", "files/read", "files/write"] as const;
  7+export const HOST_OP_ERROR_CODES = [
  8+  "INVALID_INPUT",
  9+  "EXEC_TIMEOUT",
 10+  "EXEC_EXIT_NON_ZERO",
 11+  "EXEC_OUTPUT_LIMIT",
 12+  "EXEC_FAILED",
 13+  "FILE_NOT_FOUND",
 14+  "NOT_A_FILE",
 15+  "FILE_READ_FAILED",
 16+  "FILE_WRITE_FAILED"
 17+] as const;
 18+export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
 19+export const DEFAULT_EXEC_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
 20+export const DEFAULT_TEXT_ENCODING = "utf8" as const;
 21+
 22+export type HostOperationName = (typeof HOST_OPERATION_NAMES)[number];
 23+export type HostOpErrorCode = (typeof HOST_OP_ERROR_CODES)[number];
 24+export type HostOpScalar = boolean | number | null | string;
 25+export type HostOpTextEncoding = typeof DEFAULT_TEXT_ENCODING;
 26+export type HostOpErrorDetails = Record<string, HostOpScalar>;
 27+
 28+export interface HostOpError {
 29+  code: HostOpErrorCode;
 30+  message: string;
 31+  retryable: boolean;
 32+  details?: HostOpErrorDetails;
 33+}
 34+
 35+export interface HostOperationSuccess<TOperation extends HostOperationName, TInput, TResult> {
 36+  ok: true;
 37+  operation: TOperation;
 38+  input: TInput;
 39+  result: TResult;
 40+}
 41+
 42+export interface HostOperationFailure<TOperation extends HostOperationName, TInput, TResult = never> {
 43+  ok: false;
 44+  operation: TOperation;
 45+  input: TInput;
 46+  error: HostOpError;
 47+  result?: TResult;
 48+}
 49+
 50+export interface ExecOperationRequest {
 51+  command: string;
 52+  cwd?: string;
 53+  timeoutMs?: number;
 54+  maxBufferBytes?: number;
 55+}
 56+
 57+export interface ExecOperationInput {
 58+  command: string;
 59+  cwd: string;
 60+  timeoutMs: number;
 61+  maxBufferBytes: number;
 62+}
 63+
 64+export interface ExecOperationResult {
 65+  durationMs: number;
 66+  exitCode: number | null;
 67+  finishedAt: string;
 68+  signal: string | null;
 69+  startedAt: string;
 70+  stderr: string;
 71+  stdout: string;
 72+  timedOut: boolean;
 73+}
 74+
 75+export type ExecOperationSuccess = HostOperationSuccess<
 76+  "exec",
 77+  ExecOperationInput,
 78+  ExecOperationResult
 79+>;
 80+export type ExecOperationFailure = HostOperationFailure<
 81+  "exec",
 82+  ExecOperationInput,
 83+  ExecOperationResult
 84+>;
 85+export type ExecOperationResponse = ExecOperationSuccess | ExecOperationFailure;
 86+
 87+export interface FileReadOperationRequest {
 88+  cwd?: string;
 89+  encoding?: string;
 90+  path: string;
 91+}
 92+
 93+export interface FileReadOperationInput {
 94+  cwd: string;
 95+  encoding: HostOpTextEncoding;
 96+  path: string;
 97+}
 98+
 99+export interface FileReadOperationResult {
100+  absolutePath: string;
101+  content: string;
102+  encoding: HostOpTextEncoding;
103+  modifiedAt: string;
104+  sizeBytes: number;
105+}
106+
107+export interface FileReadOperationFailureResult {
108+  absolutePath: string;
109+  encoding: HostOpTextEncoding;
110+}
111+
112+export type FileReadOperationSuccess = HostOperationSuccess<
113+  "files/read",
114+  FileReadOperationInput,
115+  FileReadOperationResult
116+>;
117+export type FileReadOperationFailure = HostOperationFailure<
118+  "files/read",
119+  FileReadOperationInput,
120+  FileReadOperationFailureResult
121+>;
122+export type FileReadOperationResponse = FileReadOperationSuccess | FileReadOperationFailure;
123+
124+export interface FileWriteOperationRequest {
125+  content: string;
126+  createParents?: boolean;
127+  cwd?: string;
128+  encoding?: string;
129+  path: string;
130+}
131+
132+export interface FileWriteOperationInput {
133+  content: string;
134+  createParents: boolean;
135+  cwd: string;
136+  encoding: HostOpTextEncoding;
137+  path: string;
138+}
139+
140+export interface FileWriteOperationResult {
141+  absolutePath: string;
142+  bytesWritten: number;
143+  created: boolean;
144+  encoding: HostOpTextEncoding;
145+  modifiedAt: string;
146+}
147+
148+export interface FileWriteOperationFailureResult {
149+  absolutePath: string;
150+  encoding: HostOpTextEncoding;
151+}
152+
153+export type FileWriteOperationSuccess = HostOperationSuccess<
154+  "files/write",
155+  FileWriteOperationInput,
156+  FileWriteOperationResult
157+>;
158+export type FileWriteOperationFailure = HostOperationFailure<
159+  "files/write",
160+  FileWriteOperationInput,
161+  FileWriteOperationFailureResult
162+>;
163+export type FileWriteOperationResponse = FileWriteOperationSuccess | FileWriteOperationFailure;
164+
165+export type HostOperationRequest =
166+  | ({ operation: "exec" } & ExecOperationRequest)
167+  | ({ operation: "files/read" } & FileReadOperationRequest)
168+  | ({ operation: "files/write" } & FileWriteOperationRequest);
169+
170+export type HostOperationResponse =
171+  | ExecOperationResponse
172+  | FileReadOperationResponse
173+  | FileWriteOperationResponse;
174+
175+interface RuntimeErrorLike {
176+  code?: number | string;
177+  killed?: boolean;
178+  message?: string;
179+  signal?: string | null;
180+}
181+
182+interface NormalizedInputSuccess<TInput> {
183+  ok: true;
184+  input: TInput;
185+}
186+
187+interface NormalizedInputFailure<TOperation extends HostOperationName, TInput> {
188+  ok: false;
189+  response: HostOperationFailure<TOperation, TInput>;
190+}
191+
192+type NormalizedInputResult<TOperation extends HostOperationName, TInput> =
193+  | NormalizedInputSuccess<TInput>
194+  | NormalizedInputFailure<TOperation, TInput>;
195+
196+function getDefaultCwd(): string {
197+  if (typeof process === "object" && process !== null && typeof process.cwd === "function") {
198+    return process.cwd();
199+  }
200+
201+  return ".";
202+}
203+
204+function isNonEmptyString(value: unknown): value is string {
205+  return typeof value === "string" && value.trim() !== "";
206+}
207+
208+function normalizeCwd(value: unknown): string | null {
209+  if (value === undefined) {
210+    return getDefaultCwd();
211+  }
212+
213+  return isNonEmptyString(value) ? value : null;
214+}
215+
216+function normalizeEncoding(value: unknown): HostOpTextEncoding | null {
217+  if (value === undefined) {
218+    return DEFAULT_TEXT_ENCODING;
219+  }
220+
221+  if (value === "utf8" || value === "utf-8") {
222+    return DEFAULT_TEXT_ENCODING;
223+  }
224+
225+  return null;
226+}
227+
228+function normalizePositiveInteger(value: unknown, defaultValue: number, allowZero = false): number | null {
229+  if (value === undefined) {
230+    return defaultValue;
231+  }
232+
233+  if (typeof value !== "number" || !Number.isInteger(value)) {
234+    return null;
235+  }
236+
237+  if (allowZero) {
238+    return value >= 0 ? value : null;
239+  }
240+
241+  return value > 0 ? value : null;
242+}
243+
244+function createHostOpError(
245+  code: HostOpErrorCode,
246+  message: string,
247+  retryable: boolean,
248+  details?: HostOpErrorDetails
249+): HostOpError {
250+  return details === undefined ? { code, message, retryable } : { code, message, retryable, details };
251+}
252+
253+function resolveOperationPath(pathValue: string, cwd: string): string {
254+  return isAbsolute(pathValue) ? pathValue : resolve(cwd, pathValue);
255+}
256+
257+function getErrorLike(error: unknown): RuntimeErrorLike {
258+  if (typeof error === "object" && error !== null) {
259+    return error as RuntimeErrorLike;
260+  }
261+
262+  return {};
263+}
264+
265+function getErrorCode(error: unknown): string | undefined {
266+  const value = getErrorLike(error).code;
267+
268+  return typeof value === "string" ? value : undefined;
269+}
270+
271+function getErrorMessage(error: unknown): string {
272+  if (error instanceof Error && error.message.trim() !== "") {
273+    return error.message;
274+  }
275+
276+  const value = getErrorLike(error).message;
277+
278+  return typeof value === "string" && value.trim() !== "" ? value : "Unknown host operation failure.";
279+}
280+
281+function isRetryableFsErrorCode(code: string | undefined): boolean {
282+  return code === "EAGAIN" || code === "EBUSY" || code === "EMFILE" || code === "ENFILE" || code === "ETXTBSY";
283+}
284+
285+function normalizeExecRequest(
286+  request: ExecOperationRequest
287+): NormalizedInputResult<"exec", ExecOperationInput> {
288+  const cwd = normalizeCwd(request.cwd);
289+
290+  if (cwd === null) {
291+    return {
292+      ok: false,
293+      response: {
294+        ok: false,
295+        operation: "exec",
296+        input: {
297+          command: request.command,
298+          cwd: getDefaultCwd(),
299+          maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
300+          timeoutMs: request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
301+        },
302+        error: createHostOpError("INVALID_INPUT", "exec.cwd must be a non-empty string when provided.", false)
303+      }
304+    };
305+  }
306+
307+  const timeoutMs = normalizePositiveInteger(request.timeoutMs, DEFAULT_EXEC_TIMEOUT_MS, true);
308+
309+  if (timeoutMs === null) {
310+    return {
311+      ok: false,
312+      response: {
313+        ok: false,
314+        operation: "exec",
315+        input: {
316+          command: request.command,
317+          cwd,
318+          maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
319+          timeoutMs: request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
320+        },
321+        error: createHostOpError("INVALID_INPUT", "exec.timeoutMs must be an integer >= 0.", false)
322+      }
323+    };
324+  }
325+
326+  const maxBufferBytes = normalizePositiveInteger(
327+    request.maxBufferBytes,
328+    DEFAULT_EXEC_MAX_BUFFER_BYTES
329+  );
330+
331+  if (maxBufferBytes === null) {
332+    return {
333+      ok: false,
334+      response: {
335+        ok: false,
336+        operation: "exec",
337+        input: {
338+          command: request.command,
339+          cwd,
340+          maxBufferBytes: request.maxBufferBytes ?? DEFAULT_EXEC_MAX_BUFFER_BYTES,
341+          timeoutMs
342+        },
343+        error: createHostOpError("INVALID_INPUT", "exec.maxBufferBytes must be an integer > 0.", false)
344+      }
345+    };
346+  }
347+
348+  if (!isNonEmptyString(request.command)) {
349+    return {
350+      ok: false,
351+      response: {
352+        ok: false,
353+        operation: "exec",
354+        input: {
355+          command: typeof request.command === "string" ? request.command : "",
356+          cwd,
357+          maxBufferBytes,
358+          timeoutMs
359+        },
360+        error: createHostOpError("INVALID_INPUT", "exec.command must be a non-empty string.", false)
361+      }
362+    };
363+  }
364+
365+  return {
366+    ok: true,
367+    input: {
368+      command: request.command,
369+      cwd,
370+      maxBufferBytes,
371+      timeoutMs
372+    }
373+  };
374+}
375+
376+function normalizeReadRequest(
377+  request: FileReadOperationRequest
378+): NormalizedInputResult<"files/read", FileReadOperationInput> {
379+  const cwd = normalizeCwd(request.cwd);
380+
381+  if (cwd === null) {
382+    return {
383+      ok: false,
384+      response: {
385+        ok: false,
386+        operation: "files/read",
387+        input: {
388+          cwd: getDefaultCwd(),
389+          encoding: DEFAULT_TEXT_ENCODING,
390+          path: request.path
391+        },
392+        error: createHostOpError("INVALID_INPUT", "files/read.cwd must be a non-empty string when provided.", false)
393+      }
394+    };
395+  }
396+
397+  const encoding = normalizeEncoding(request.encoding);
398+
399+  if (encoding === null) {
400+    return {
401+      ok: false,
402+      response: {
403+        ok: false,
404+        operation: "files/read",
405+        input: {
406+          cwd,
407+          encoding: DEFAULT_TEXT_ENCODING,
408+          path: request.path
409+        },
410+        error: createHostOpError("INVALID_INPUT", "files/read.encoding currently only supports utf8.", false)
411+      }
412+    };
413+  }
414+
415+  if (!isNonEmptyString(request.path)) {
416+    return {
417+      ok: false,
418+      response: {
419+        ok: false,
420+        operation: "files/read",
421+        input: {
422+          cwd,
423+          encoding,
424+          path: typeof request.path === "string" ? request.path : ""
425+        },
426+        error: createHostOpError("INVALID_INPUT", "files/read.path must be a non-empty string.", false)
427+      }
428+    };
429+  }
430+
431+  return {
432+    ok: true,
433+    input: {
434+      cwd,
435+      encoding,
436+      path: request.path
437+    }
438+  };
439+}
440+
441+function normalizeWriteRequest(
442+  request: FileWriteOperationRequest
443+): NormalizedInputResult<"files/write", FileWriteOperationInput> {
444+  const cwd = normalizeCwd(request.cwd);
445+
446+  if (cwd === null) {
447+    return {
448+      ok: false,
449+      response: {
450+        ok: false,
451+        operation: "files/write",
452+        input: {
453+          content: request.content,
454+          createParents: request.createParents ?? true,
455+          cwd: getDefaultCwd(),
456+          encoding: DEFAULT_TEXT_ENCODING,
457+          path: request.path
458+        },
459+        error: createHostOpError("INVALID_INPUT", "files/write.cwd must be a non-empty string when provided.", false)
460+      }
461+    };
462+  }
463+
464+  const encoding = normalizeEncoding(request.encoding);
465+
466+  if (encoding === null) {
467+    return {
468+      ok: false,
469+      response: {
470+        ok: false,
471+        operation: "files/write",
472+        input: {
473+          content: request.content,
474+          createParents: request.createParents ?? true,
475+          cwd,
476+          encoding: DEFAULT_TEXT_ENCODING,
477+          path: request.path
478+        },
479+        error: createHostOpError("INVALID_INPUT", "files/write.encoding currently only supports utf8.", false)
480+      }
481+    };
482+  }
483+
484+  if (!isNonEmptyString(request.path)) {
485+    return {
486+      ok: false,
487+      response: {
488+        ok: false,
489+        operation: "files/write",
490+        input: {
491+          content: request.content,
492+          createParents: request.createParents ?? true,
493+          cwd,
494+          encoding,
495+          path: typeof request.path === "string" ? request.path : ""
496+        },
497+        error: createHostOpError("INVALID_INPUT", "files/write.path must be a non-empty string.", false)
498+      }
499+    };
500+  }
501+
502+  if (typeof request.content !== "string") {
503+    return {
504+      ok: false,
505+      response: {
506+        ok: false,
507+        operation: "files/write",
508+        input: {
509+          content: "",
510+          createParents: request.createParents ?? true,
511+          cwd,
512+          encoding,
513+          path: request.path
514+        },
515+        error: createHostOpError("INVALID_INPUT", "files/write.content must be a string.", false)
516+      }
517+    };
518+  }
519+
520+  return {
521+    ok: true,
522+    input: {
523+      content: request.content,
524+      createParents: request.createParents ?? true,
525+      cwd,
526+      encoding,
527+      path: request.path
528+    }
529+  };
530+}
531+
532+function buildFsFailureResult(
533+  absolutePath: string,
534+  encoding: HostOpTextEncoding
535+): FileReadOperationFailureResult | FileWriteOperationFailureResult {
536+  return {
537+    absolutePath,
538+    encoding
539+  };
540+}
541+
542+function getModifiedAt(fileMtimeMs: number): string {
543+  return new Date(fileMtimeMs).toISOString();
544+}
545+
546+function getUtf8ByteLength(value: string): number {
547+  return new TextEncoder().encode(value).byteLength;
548+}
549+
550+export async function executeCommand(request: ExecOperationRequest): Promise<ExecOperationResponse> {
551+  const normalized = normalizeExecRequest(request);
552+
553+  if (!normalized.ok) {
554+    return normalized.response;
555+  }
556+
557+  const startedAt = new Date().toISOString();
558+  const startedMs = Date.now();
559+
560+  return new Promise((resolveResponse) => {
561+    exec(
562+      normalized.input.command,
563+      {
564+        cwd: normalized.input.cwd,
565+        env: process?.env ?? {},
566+        maxBuffer: normalized.input.maxBufferBytes,
567+        timeout: normalized.input.timeoutMs
568+      },
569+      (error: RuntimeErrorLike | null, stdout: string, stderr: string) => {
570+        const finishedAt = new Date().toISOString();
571+        const runtimeError = getErrorLike(error);
572+        const exitCode =
573+          error === null ? 0 : typeof runtimeError.code === "number" ? runtimeError.code : null;
574+        const signal = typeof runtimeError.signal === "string" ? runtimeError.signal : null;
575+        const result: ExecOperationResult = {
576+          durationMs: Date.now() - startedMs,
577+          exitCode,
578+          finishedAt,
579+          signal,
580+          startedAt,
581+          stderr,
582+          stdout,
583+          timedOut: runtimeError.killed === true
584+        };
585+
586+        if (error === null) {
587+          resolveResponse({
588+            ok: true,
589+            operation: "exec",
590+            input: normalized.input,
591+            result
592+          });
593+          return;
594+        }
595+
596+        if (runtimeError.killed === true) {
597+          resolveResponse({
598+            ok: false,
599+            operation: "exec",
600+            input: normalized.input,
601+            error: createHostOpError(
602+              "EXEC_TIMEOUT",
603+              `Command timed out after ${normalized.input.timeoutMs}ms.`,
604+              true,
605+              { timeoutMs: normalized.input.timeoutMs }
606+            ),
607+            result
608+          });
609+          return;
610+        }
611+
612+        if (runtimeError.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
613+          resolveResponse({
614+            ok: false,
615+            operation: "exec",
616+            input: normalized.input,
617+            error: createHostOpError(
618+              "EXEC_OUTPUT_LIMIT",
619+              `Command output exceeded maxBufferBytes=${normalized.input.maxBufferBytes}.`,
620+              false,
621+              { maxBufferBytes: normalized.input.maxBufferBytes }
622+            ),
623+            result
624+          });
625+          return;
626+        }
627+
628+        if (typeof runtimeError.code === "number") {
629+          resolveResponse({
630+            ok: false,
631+            operation: "exec",
632+            input: normalized.input,
633+            error: createHostOpError(
634+              "EXEC_EXIT_NON_ZERO",
635+              `Command exited with code ${runtimeError.code}.`,
636+              false,
637+              { exitCode: runtimeError.code }
638+            ),
639+            result
640+          });
641+          return;
642+        }
643+
644+        const errorCode = getErrorCode(error);
645+
646+        resolveResponse({
647+          ok: false,
648+          operation: "exec",
649+          input: normalized.input,
650+          error: createHostOpError(
651+            "EXEC_FAILED",
652+            getErrorMessage(error),
653+            isRetryableFsErrorCode(errorCode),
654+            errorCode === undefined ? undefined : { errorCode }
655+          ),
656+          result
657+        });
658+      }
659+    );
660+  });
661+}
662+
663+export async function readTextFile(
664+  request: FileReadOperationRequest
665+): Promise<FileReadOperationResponse> {
666+  const normalized = normalizeReadRequest(request);
667+
668+  if (!normalized.ok) {
669+    return normalized.response;
670+  }
671+
672+  const absolutePath = resolveOperationPath(normalized.input.path, normalized.input.cwd);
673+
674+  try {
675+    const fileStats = await stat(absolutePath);
676+
677+    if (!fileStats.isFile()) {
678+      return {
679+        ok: false,
680+        operation: "files/read",
681+        input: normalized.input,
682+        error: createHostOpError("NOT_A_FILE", `Expected a file at ${absolutePath}.`, false),
683+        result: buildFsFailureResult(absolutePath, normalized.input.encoding)
684+      };
685+    }
686+
687+    const content = await readFile(absolutePath, normalized.input.encoding);
688+
689+    return {
690+      ok: true,
691+      operation: "files/read",
692+      input: normalized.input,
693+      result: {
694+        absolutePath,
695+        content,
696+        encoding: normalized.input.encoding,
697+        modifiedAt: getModifiedAt(fileStats.mtimeMs),
698+        sizeBytes: fileStats.size
699+      }
700+    };
701+  } catch (error) {
702+    const errorCode = getErrorCode(error);
703+    const errorPayload =
704+      errorCode === "ENOENT"
705+        ? createHostOpError("FILE_NOT_FOUND", `File not found: ${absolutePath}.`, false)
706+        : errorCode === "EISDIR"
707+          ? createHostOpError("NOT_A_FILE", `Expected a file at ${absolutePath}.`, false)
708+          : createHostOpError(
709+              "FILE_READ_FAILED",
710+              getErrorMessage(error),
711+              isRetryableFsErrorCode(errorCode),
712+              errorCode === undefined ? undefined : { errorCode }
713+            );
714+
715+    return {
716+      ok: false,
717+      operation: "files/read",
718+      input: normalized.input,
719+      error: errorPayload,
720+      result: buildFsFailureResult(absolutePath, normalized.input.encoding)
721+    };
722+  }
723+}
724+
725+export async function writeTextFile(
726+  request: FileWriteOperationRequest
727+): Promise<FileWriteOperationResponse> {
728+  const normalized = normalizeWriteRequest(request);
729+
730+  if (!normalized.ok) {
731+    return normalized.response;
732+  }
733+
734+  const absolutePath = resolveOperationPath(normalized.input.path, normalized.input.cwd);
735+  let created = true;
736+
737+  try {
738+    await stat(absolutePath);
739+    created = false;
740+  } catch (error) {
741+    if (getErrorCode(error) !== "ENOENT") {
742+      created = false;
743+    }
744+  }
745+
746+  try {
747+    if (normalized.input.createParents) {
748+      await mkdir(dirname(absolutePath), { recursive: true });
749+    }
750+
751+    await writeFile(absolutePath, normalized.input.content, normalized.input.encoding);
752+    const fileStats = await stat(absolutePath);
753+
754+    return {
755+      ok: true,
756+      operation: "files/write",
757+      input: normalized.input,
758+      result: {
759+        absolutePath,
760+        bytesWritten: fileStats.isFile() ? fileStats.size : getUtf8ByteLength(normalized.input.content),
761+        created,
762+        encoding: normalized.input.encoding,
763+        modifiedAt: getModifiedAt(fileStats.mtimeMs)
764+      }
765+    };
766+  } catch (error) {
767+    const errorCode = getErrorCode(error);
768+    const errorPayload =
769+      errorCode === "EISDIR"
770+        ? createHostOpError("NOT_A_FILE", `Expected a file path at ${absolutePath}.`, false)
771+        : createHostOpError(
772+            "FILE_WRITE_FAILED",
773+            getErrorMessage(error),
774+            isRetryableFsErrorCode(errorCode),
775+            errorCode === undefined ? undefined : { errorCode }
776+          );
777+
778+    return {
779+      ok: false,
780+      operation: "files/write",
781+      input: normalized.input,
782+      error: errorPayload,
783+      result: buildFsFailureResult(absolutePath, normalized.input.encoding)
784+    };
785+  }
786+}
787+
788+export async function runHostOperation(request: HostOperationRequest): Promise<HostOperationResponse> {
789+  switch (request.operation) {
790+    case "exec":
791+      return executeCommand(request);
792+    case "files/read":
793+      return readTextFile(request);
794+    case "files/write":
795+      return writeTextFile(request);
796+  }
797+}
A packages/host-ops/src/node-shims.d.ts
+67, -0
 1@@ -0,0 +1,67 @@
 2+declare module "node:child_process" {
 3+  export interface ExecException extends Error {
 4+    code?: number | string;
 5+    killed?: boolean;
 6+    signal?: string | null;
 7+  }
 8+
 9+  export interface ExecOptions {
10+    cwd?: string;
11+    env?: Record<string, string | undefined>;
12+    maxBuffer?: number;
13+    timeout?: number;
14+  }
15+
16+  export function exec(
17+    command: string,
18+    options: ExecOptions,
19+    callback: (error: ExecException | null, stdout: string, stderr: string) => void
20+  ): void;
21+}
22+
23+declare module "node:fs/promises" {
24+  export interface FileOperationOptions {
25+    encoding?: string;
26+    mode?: number;
27+    flag?: string;
28+  }
29+
30+  export interface MakeDirectoryOptions {
31+    recursive?: boolean;
32+    mode?: number;
33+  }
34+
35+  export interface FileStats {
36+    mtimeMs: number;
37+    size: number;
38+    isFile(): boolean;
39+  }
40+
41+  export function mkdir(
42+    path: string,
43+    options?: MakeDirectoryOptions
44+  ): Promise<string | undefined>;
45+
46+  export function readFile(path: string, encoding: string): Promise<string>;
47+
48+  export function stat(path: string): Promise<FileStats>;
49+
50+  export function writeFile(
51+    path: string,
52+    data: string,
53+    options?: FileOperationOptions | string
54+  ): Promise<void>;
55+}
56+
57+declare module "node:path" {
58+  export function dirname(path: string): string;
59+  export function isAbsolute(path: string): boolean;
60+  export function resolve(...paths: string[]): string;
61+}
62+
63+declare const process:
64+  | {
65+      cwd(): string;
66+      env: Record<string, string | undefined>;
67+    }
68+  | undefined;
A packages/host-ops/tsconfig.json
+8, -0
1@@ -0,0 +1,8 @@
2+{
3+  "extends": "../../tsconfig.base.json",
4+  "compilerOptions": {
5+    "rootDir": "src",
6+    "outDir": "dist"
7+  },
8+  "include": ["src/**/*.ts", "src/**/*.d.ts"]
9+}