im_wower
·
2026-03-24
local-host-ops.md
1# Local Host Ops Contract
2
3`@baa-conductor/host-ops` 现在已经作为本机能力层挂到 `conductor-daemon` 的本地 HTTP 面:
4
5- `POST /v1/exec`
6- `POST /v1/files/read`
7- `POST /v1/files/write`
8
9当前状态:
10
11- 已有最小 Node 实现
12- 已有结构化输入输出合同
13- 已有 package smoke / HTTP 集成测试
14- 已挂到 `conductor-daemon` 本地 API
15- 不再挂到单独 Worker / D1 兼容层
16
17## Authentication
18
19这三条 HTTP 路由现在统一要求:
20
21- `Authorization: Bearer <BAA_SHARED_TOKEN>`
22- token 来自 daemon 进程启动时配置的 `BAA_SHARED_TOKEN`
23- 缺少 token 或 token 错误时,HTTP 层直接返回 `401` JSON 错误,不会进入实际 host operation
24- 即使经由 `conductor.makefile.so`、Nginx 或 tunnel 转发,这三条路由也不是匿名公网接口
25
26## Operations
27
28| operation | request | success.result | failure.error |
29| --- | --- | --- | --- |
30| `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`, `TCC_PERMISSION_DENIED` |
31| `files/read` | `path`, `cwd?`, `encoding?` | `absolutePath`, `content`, `sizeBytes`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `FILE_NOT_FOUND`, `NOT_A_FILE`, `FILE_READ_FAILED` |
32| `files/write` | `path`, `content`, `cwd?`, `encoding?`, `createParents?`, `overwrite?` | `absolutePath`, `bytesWritten`, `created`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `FILE_ALREADY_EXISTS`, `NOT_A_FILE`, `FILE_WRITE_FAILED` |
33
34当前文本编码只支持 `utf8`。
35
36## Input Semantics
37
38- `cwd`:可选字符串。省略时使用 `conductor-daemon` 进程当前工作目录。
39- `exec` 会在启动 shell 之前先校验 `cwd`:不存在时返回 `INVALID_INPUT` 和 `message: "exec.cwd does not exist: <cwd>"`;存在但不是目录时返回 `INVALID_INPUT` 和 `message: "exec.cwd is not a directory: <cwd>"`。两种情况都会在 `error.details.cwd` 里回传原始 `cwd`。
40- `path`:可为绝对路径;相对路径会相对 `cwd` 解析。
41- `timeoutMs`:仅 `exec` 使用。可选整数 `>= 0`,默认 `30000`;`0` 表示不启用超时。
42- `maxBufferBytes`:仅 `exec` 使用。可选整数 `> 0`,默认 `10485760`。
43- `exec` 子进程默认不会继承 `conductor-daemon` 的完整环境变量。当前在 macOS / Linux 只透传最小集合:`PATH`、`HOME`、`USER`、`LOGNAME`、`LANG`、`TERM`、`TMPDIR`;在 Windows 只透传启动 shell 和常见可执行文件解析所需的最小集合(如 `ComSpec`、`PATH`、`PATHEXT`、`SystemRoot`、`TEMP`、`TMP`、`USERNAME`、`USERPROFILE`、`WINDIR`)。因此 `BAA_SHARED_TOKEN`、`*_TOKEN`、`*_SECRET`、`*_KEY`、`*_PASSWORD` 等 daemon 自身环境变量不会默认暴露给 `exec`。
44- 在 macOS 上,`exec` 只会对显式传入的 `cwd` 做 TCC 受保护目录前置检查,避免后台进程把工作目录直接设到 `Desktop` / `Documents` / `Downloads` 时无提示挂住 30 秒。`command` 仍按 shell 字符串原样执行,这个预检不是安全边界,也不会尝试解析 `command` 里的变量、`cd`、命令替换或其他间接路径。
45- `createParents`:仅 `files/write` 使用。可选布尔值,默认 `true`;会递归创建缺失父目录。
46- `overwrite`:仅 `files/write` 使用。可选布尔值,默认 `true`;为 `false` 且目标文件已存在时返回 `FILE_ALREADY_EXISTS`。
47
48## Response Shape
49
50包级返回 union 仍保持不变。
51
52`exec` 的失败返回现在也保证带完整的 `result` 结构,不再出现 `result: null` 或空对象:
53
54```json
55{
56 "result": {
57 "stdout": "",
58 "stderr": "",
59 "exitCode": null,
60 "signal": null,
61 "durationMs": 0,
62 "startedAt": null,
63 "finishedAt": null,
64 "timedOut": false
65 }
66}
67```
68
69如果子进程已经启动,则 `result` 会继续返回实际采集到的 `stdout` / `stderr` / `durationMs` / 时间戳。
70
71成功返回:
72
73```json
74{
75 "ok": true,
76 "operation": "files/read",
77 "input": {
78 "path": "README.md",
79 "cwd": "/Users/george/code/baa-conductor",
80 "encoding": "utf8"
81 },
82 "result": {
83 "absolutePath": "/Users/george/code/baa-conductor/README.md",
84 "content": "# baa-conductor\n...",
85 "sizeBytes": 2783,
86 "modifiedAt": "2026-03-22T09:01:00.000Z",
87 "encoding": "utf8"
88 }
89}
90```
91
92失败返回:
93
94```json
95{
96 "ok": false,
97 "operation": "files/write",
98 "input": {
99 "path": "tmp/demo.txt",
100 "cwd": "/Users/george/code/baa-conductor",
101 "content": "hello",
102 "encoding": "utf8",
103 "createParents": true,
104 "overwrite": false
105 },
106 "error": {
107 "code": "FILE_ALREADY_EXISTS",
108 "message": "File already exists at /Users/george/code/baa-conductor/tmp/demo.txt and overwrite=false.",
109 "retryable": false,
110 "details": {
111 "overwrite": false
112 }
113 },
114 "result": {
115 "absolutePath": "/Users/george/code/baa-conductor/tmp/demo.txt",
116 "encoding": "utf8"
117 }
118}
119```
120
121对于 `conductor-daemon` 的 HTTP `POST /v1/exec`,所有失败路径现在都返回稳定的 `result` 骨架。
122也就是说,即使请求在 macOS TCC 快速预检或输入校验阶段就被拦截,`data.result` 里也至少会有这些字段:
123
124- `stdout`
125- `stderr`
126- `exitCode`
127- `signal`
128- `durationMs`
129- `startedAt`
130- `finishedAt`
131- `timedOut`
132
133对于未真正启动子进程的提前失败,当前默认值为:
134
135- `stdout: ""`
136- `stderr: ""`
137- `exitCode: null`
138- `signal: null`
139- `durationMs: 0`
140- `timedOut: false`
141
142## HTTP Envelope
143
144`conductor-daemon` 的 HTTP 返回外层仍然使用统一 envelope:
145
146```json
147{
148 "ok": true,
149 "request_id": "req_xxx",
150 "data": {
151 "ok": true,
152 "operation": "exec",
153 "input": {
154 "command": "printf 'hello'",
155 "cwd": "/tmp",
156 "timeoutMs": 2000,
157 "maxBufferBytes": 10485760
158 },
159 "result": {
160 "stdout": "hello",
161 "stderr": "",
162 "exitCode": 0,
163 "signal": null,
164 "durationMs": 12,
165 "startedAt": "2026-03-22T09:10:00.000Z",
166 "finishedAt": "2026-03-22T09:10:00.012Z",
167 "timedOut": false
168 }
169 }
170}
171```
172
173也就是说:
174
175- 外层 `ok` / `request_id` 属于 `conductor-daemon` HTTP 协议
176- 内层 `data.ok` / `data.operation` / `data.error` 属于 `host-ops` 结构化结果
177
178未授权示例:
179
180```json
181{
182 "ok": false,
183 "request_id": "req_xxx",
184 "error": "unauthorized",
185 "message": "POST /v1/exec requires Authorization: Bearer <BAA_SHARED_TOKEN>.",
186 "details": {
187 "auth_scheme": "Bearer",
188 "env_var": "BAA_SHARED_TOKEN",
189 "route_id": "host.exec",
190 "reason": "missing_authorization_header"
191 }
192}
193```
194
195## Minimal Curl
196
197```bash
198LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
199SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
200curl -X POST "${LOCAL_API_BASE}/v1/exec" \
201 -H "Authorization: Bearer ${SHARED_TOKEN}" \
202 -H 'Content-Type: application/json' \
203 -d '{"command":"printf '\''hello from conductor'\''","cwd":"/tmp","timeoutMs":2000}'
204```
205
206```bash
207LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
208SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
209curl -X POST "${LOCAL_API_BASE}/v1/files/read" \
210 -H "Authorization: Bearer ${SHARED_TOKEN}" \
211 -H 'Content-Type: application/json' \
212 -d '{"path":"README.md","cwd":"/Users/george/code/baa-conductor","encoding":"utf8"}'
213```
214
215```bash
216LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
217SHARED_TOKEN="${BAA_SHARED_TOKEN:?set BAA_SHARED_TOKEN}"
218curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
219 -H "Authorization: Bearer ${SHARED_TOKEN}" \
220 -H 'Content-Type: application/json' \
221 -d '{"path":"tmp/demo.txt","cwd":"/Users/george/code/baa-conductor","content":"hello from conductor","overwrite":false,"createParents":true}'
222```
223
224## Package API
225
226导出函数:
227
228- `executeCommand(request)`
229- `readTextFile(request)`
230- `writeTextFile(request)`
231- `runHostOperation(request)`
232
233它们全部返回结构化 result union,不依赖 HTTP 层;HTTP 层只是把这份 union 包进 `data`。
234
235## Exec cwd Validation
236
237当 `POST /v1/exec` 传入不存在的 `cwd` 时,会在调用 `child_process.exec()` 之前直接返回:
238
239```json
240{
241 "ok": false,
242 "operation": "exec",
243 "input": {
244 "command": "echo ok",
245 "cwd": "/tmp/does-not-exist",
246 "timeoutMs": 30000,
247 "maxBufferBytes": 10485760
248 },
249 "error": {
250 "code": "INVALID_INPUT",
251 "message": "exec.cwd does not exist: /tmp/does-not-exist",
252 "retryable": false,
253 "details": {
254 "cwd": "/tmp/does-not-exist"
255 }
256 },
257 "result": {
258 "stdout": "",
259 "stderr": "",
260 "exitCode": null,
261 "signal": null,
262 "durationMs": 0,
263 "startedAt": null,
264 "finishedAt": null,
265 "timedOut": false
266 }
267}
268```
269
270如果 `cwd` 指向的是文件而不是目录,返回同样的 `INVALID_INPUT` 结构,但 message 会改成 `exec.cwd is not a directory: <cwd>`。
271
272## macOS TCC Fast-Fail
273
274如果 `exec` 在 macOS 上检测到 `cwd` 落在受 TCC 保护的用户目录内,会在启动子进程前直接返回:
275
276```json
277{
278 "ok": false,
279 "operation": "exec",
280 "input": {
281 "command": "pwd",
282 "cwd": "/Users/george/Desktop/project",
283 "timeoutMs": 2000,
284 "maxBufferBytes": 10485760
285 },
286 "error": {
287 "code": "TCC_PERMISSION_DENIED",
288 "message": "Command cwd resolves inside macOS TCC-protected path /Users/george/Desktop. Grant Full Disk Access to the Node.js binary running conductor-daemon (/opt/homebrew/bin/node) and retry.",
289 "retryable": false,
290 "details": {
291 "accessPoint": "cwd",
292 "protectedPath": "/Users/george/Desktop",
293 "nodeBinary": "/opt/homebrew/bin/node",
294 "requiresFullDiskAccess": true
295 }
296 },
297 "result": {
298 "stdout": "",
299 "stderr": "",
300 "exitCode": null,
301 "signal": null,
302 "durationMs": 0,
303 "startedAt": null,
304 "finishedAt": null,
305 "timedOut": false
306 }
307}
308```
309
310当前这只是针对 `cwd` 的快速预检,用来减少已知的 TCC 挂起场景;真正是否允许访问仍由 macOS 自身权限决定。
311当前这只是针对 `cwd` 的快速预检,用来减少已知的 TCC 挂起场景;真正是否允许访问仍由 macOS 自身权限决定。
312它不会解析 shell 变量、命令替换或更复杂的间接路径展开。
313
314补充说明:底层 `@baa-conductor/host-ops` 包返回原始 union;如果经由 `conductor-daemon` HTTP `/v1/exec` 调用,适配层还会把失败 `result` 补齐为稳定骨架,并把 `startedAt` / `finishedAt` 归一化为字符串时间戳。