- 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
+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
+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` 的“小而直接”的使用体验
+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 层。
+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+}
+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+});
+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+}
+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;
+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+}