@ravefox/space 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +203 -0
  2. package/NOTICE +44 -0
  3. package/README.md +49 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +56 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * A minimal MCP client for the pi bridge (pi-commonly-mcp.mjs), over both
3
+ * transports the environment spec admits: stdio (newline-delimited JSON-RPC)
4
+ * and Streamable HTTP.
5
+ *
6
+ * A client for four methods (initialize, tools/list, tools/call, the
7
+ * initialized notification) is smaller than a dependency, and keeping it in a
8
+ * file with no pi imports means the CLI's own jest can test it — `typebox`
9
+ * only resolves inside pi's extension loader, so the extension file stays thin
10
+ * and untested by jest.
11
+ *
12
+ * pi itself has no MCP support by design (its README: "No MCP. … build an
13
+ * extension that adds MCP support"); this bridge IS that extension, so an
14
+ * HTTP MCP server reaches a pi seat only through the client below. A
15
+ * `url`-only entry used to be dropped by the two filters here and in pi.js,
16
+ * which is how a granted pi seat ended up holding the grant broker — a
17
+ * Streamable HTTP server — and getting nothing, silently.
18
+ */
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import { closeSync, readFileSync, rmSync } from 'node:fs';
22
+ import { isAbsolute } from 'node:path';
23
+ import {
24
+ MCP_PACKAGE, PIPE_READER_VERSION, describeMcpCommand, versionOlderThan,
25
+ } from '../mcp-server-version.js';
26
+
27
+ import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY } from '../credential-file.js';
28
+
29
+ /**
30
+ * The grant broker's path. Keyed on the PATH and not on the entry's `name`,
31
+ * because the name is whatever the declaration says while the path is the
32
+ * broker's. A url that does not parse is not this predicate's business — the
33
+ * origin rule in `pi.js resolveMcpServers` refuses those before they are
34
+ * carried.
35
+ *
36
+ * F175c: a pi turn carries the broker only when it is confined, which for pi
37
+ * means a public seat in the macOS Seatbelt jail (F113b). Every other pi turn
38
+ * refuses it here as well as at the server's projection and in the daemon's
39
+ * guard — this layer is what holds on deploy skew, when the row names no
40
+ * adapter for the server to key on, and in a backend older than the rule.
41
+ */
42
+ export const GRANT_BROKER_PATH = '/api/mcp/grants/';
43
+
44
+ /**
45
+ * The reason strings BOTH halves of this refusal use, verbatim from the
46
+ * server's typed refusal (`backend/services/grantBrokerConfinement.ts`: code
47
+ * `grant_broker_unconfined`). wren's ruling (69829): "Same reason string on
48
+ * both halves" — so a daemon log line and the server's `grantBrokerRefusal`
49
+ * field can be read side by side, and neither layer grows a vocabulary the
50
+ * other one does not have. Mirrored rather than imported: this package does not
51
+ * depend on the backend, so the literals are pinned by a test instead.
52
+ *
53
+ * `adapter_cannot_confine` is the adapter-wide refusal the other unproven
54
+ * adapters get. pi's own is `adapter_confines_public_only` (F175c): pi carries a
55
+ * grant on a public macOS seat and refuses it on every other one, and naming the
56
+ * agent on the grant (F94) does not lift it.
57
+ */
58
+ export const GRANT_BROKER_REFUSAL = 'grant_broker_unconfined: adapter_cannot_confine';
59
+ export const GRANT_BROKER_PUBLIC_ONLY_REFUSAL = 'grant_broker_unconfined: adapter_confines_public_only';
60
+
61
+ /**
62
+ * Is this url the instance's grant broker? Path-keyed, and the comparison is
63
+ * case-INSENSITIVE because the route it mirrors is: express matches paths
64
+ * case-insensitively unless `caseSensitive` is set, so the live API answers
65
+ * `/API/MCP/GRANTS/x` from the same handler as `/api/mcp/grants/x` (measured
66
+ * 2026-09-19: POST both → 401, POST `/api/nothing/x` → 404, i.e. the control is
67
+ * what distinguishes a matched route from a missing one). A case-sensitive
68
+ * predicate therefore refuses the spelled-canonically broker and hands pi the
69
+ * uppercased spelling of it — the real runtime token included. Vera measured
70
+ * exactly that against the running instance (69839).
71
+ *
72
+ * Case is the only spelling the router forgives: `%67rants`, `//`, `./` and
73
+ * `GRANTS%2F` all 404 on the live API, and a trailing slash is already inside
74
+ * the prefix.
75
+ */
76
+ export const isGrantBrokerUrl = (url) => {
77
+ try {
78
+ return new URL(url).pathname.toLowerCase().startsWith(GRANT_BROKER_PATH);
79
+ } catch {
80
+ return false;
81
+ }
82
+ };
83
+
84
+ const PROTOCOL_VERSION = '2024-11-05';
85
+ const MAX_TEXT = 50 * 1024;
86
+
87
+ const truncate = (text) => (text.length <= MAX_TEXT
88
+ ? text
89
+ : `${text.slice(0, MAX_TEXT)}\n… [truncated ${text.length - MAX_TEXT} bytes]`);
90
+
91
+ /**
92
+ * Connect to one declared server, stdio or Streamable HTTP. `resolveMcpServers`
93
+ * emits exactly one of `command`/`url` per entry, and `readServers` keeps that
94
+ * invariant, so the shape decides; `url` wins if a hand-built entry still
95
+ * carries both, because a server that advertises an endpoint is not a command
96
+ * to spawn.
97
+ */
98
+ export const connectMcp = (server, opts = {}) => (typeof server?.url === 'string' && server.url
99
+ ? connectHttpMcp(server, opts)
100
+ : connectStdioMcp(server, opts));
101
+
102
+ /**
103
+ * The credential channel (TASK-078, ruled 2026-09-19: "take the token out of
104
+ * the environment", inherited pipe).
105
+ *
106
+ * `connectStdioMcp` spawns each declared stdio server with
107
+ * `{...process.env, ...env}`, and the default declaration puts the seat's
108
+ * runtime token in that `env` map. So the token sat in the MCP child's
109
+ * environment, where any same-user process could read it back with
110
+ * `ps eww <pid>` or `/proc/<pid>/environ` — including, on a shared host, a
111
+ * process the seat is not allowed to talk to.
112
+ *
113
+ * Now the token rides an inherited pipe on fd 3 and the child's environment
114
+ * carries only a pointer to it (`COMMONLY_TOKEN_FD=3`) — not a secret. The
115
+ * child end of that pipe is read to EOF.
116
+ *
117
+ * WHERE THE TOKEN COMES FROM. The launcher writes a per-spawn 0600 file and the
118
+ * declaration names it with `${COMMONLY_TOKEN_FILE}` — a PATH, not a secret — so
119
+ * the runtime's own environment never holds the token (TASK-082/083). A
120
+ * declaration that instead carries the literal token in `COMMONLY_AGENT_TOKEN`
121
+ * still works: that is the older channel, and it is the operator's to declare.
122
+ * Whichever named the credential, the child gets it on the pipe and its own
123
+ * environment is left clean.
124
+ *
125
+ * THE PIPE IS ONLY AVAILABLE WHERE WE SPAWN. This is the pi path; claude and
126
+ * codex let their own CLI start the server (claude expands `${VAR}` in its own
127
+ * process env, codex writes a plain `mcp_servers.*.env` entry), so a pipe opened
128
+ * here never reaches that grandchild. Those two deliver the PATH instead, which
129
+ * is what let the token leave their runtime environments too.
130
+ *
131
+ * THE OLD SERVER STILL WORKS. `@commonlyai/mcp` only learned to read the pipe in
132
+ * 0.3.11, and a seat may pin an older one — the five staging seats ran a checkout
133
+ * of 0.3.7 when this was measured (2026-09-19; the doc here said 0.3.4, which was
134
+ * true two weeks earlier) — so a declaration whose command names an older
135
+ * `@commonlyai/mcp` keeps the environment variable AS WELL, with a warning that
136
+ * names the pin. An operator can also opt out explicitly with
137
+ * `COMMONLY_TOKEN_CHANNEL=env` in the entry's own env. Otherwise the token is
138
+ * piped and the environment is left clean.
139
+ */
140
+ export { CREDENTIAL_KEY, CREDENTIAL_FILE_VAR };
141
+
142
+ export const CREDENTIAL_FD_VAR = 'COMMONLY_TOKEN_FD';
143
+ export const CREDENTIAL_CHANNEL_VAR = 'COMMONLY_TOKEN_CHANNEL';
144
+ export const CREDENTIAL_FD = 3;
145
+
146
+ /**
147
+ * The only variables a spawned MCP server inherits from us.
148
+ *
149
+ * This list is DERIVED, not guessed: each entry exists because a real child
150
+ * failed without it, and a child that only fails at spawn is the worst failure
151
+ * shape there is (TASK-083). `PATH` runs the command (and, for a seat, carries
152
+ * the seat-launch shim directory prepended, so it must be passed through rather
153
+ * than canonicalised); `HOME` is where `npx` keeps the cache it fetches
154
+ * `@commonlyai/mcp` into, which a cold start needs; `TMPDIR` was measured in a
155
+ * live child on macOS, where /var/folders is not /tmp; the proxy and CA names
156
+ * are absent on this machine, so an operator behind a proxy or a private CA is
157
+ * the case that cannot be exercised here — including them unset costs nothing,
158
+ * and without them that operator's cold `npx` would fail in a way that reads as
159
+ * this change regressing.
160
+ *
161
+ * Deliberately NOT a prefix match: a variable named `COMMONLY_*` is ours to hand
162
+ * over explicitly, and a variable named like a secret is exactly what an
163
+ * allowlist exists to drop.
164
+ */
165
+ export const CHILD_ENV_ALLOWLIST = Object.freeze([
166
+ 'PATH',
167
+ 'HOME',
168
+ 'TMPDIR',
169
+ 'HTTPS_PROXY',
170
+ 'HTTP_PROXY',
171
+ 'NO_PROXY',
172
+ 'NODE_EXTRA_CA_CERTS',
173
+ ]);
174
+
175
+ /**
176
+ * The environment a spawned MCP server gets: the allowlist above, then whatever
177
+ * the entry itself declared, and nothing else. The daemon's environment is not a
178
+ * channel into a child — `...process.env` used to make it one, which is how a
179
+ * third-party server came to hold our seat token and the model key.
180
+ */
181
+ export const buildChildEnv = (parentEnv, declaredEnv) => {
182
+ const out = {};
183
+ for (const key of CHILD_ENV_ALLOWLIST) {
184
+ const value = parentEnv ? parentEnv[key] : undefined;
185
+ if (value !== undefined) out[key] = value;
186
+ }
187
+ return Object.assign(out, declaredEnv || {});
188
+ };
189
+
190
+ /**
191
+ * The channel predicates live in `../mcp-server-version.js`, because the claude
192
+ * and codex adapters have to answer the same question and a second copy of it
193
+ * would drift silently in one of them.
194
+ */
195
+ export {
196
+ MCP_PACKAGE, PIPE_READER_VERSION, FILE_READER_VERSION, parseVersion, describeMcpCommand,
197
+ } from '../mcp-server-version.js';
198
+
199
+ const olderThanPipeReader = (version) => versionOlderThan(version, PIPE_READER_VERSION);
200
+
201
+ /**
202
+ * Split a declared env map into the child's environment and the credential to
203
+ * hand over the pipe.
204
+ *
205
+ * Returns `{ env, credential, keepInEnv }`. `keepInEnv` is true only when the
206
+ * server cannot read the pipe — it predates the reader, it is somebody else's
207
+ * server, or the declaration opted out explicitly. Everything else gets the
208
+ * pointer variable and no secret.
209
+ */
210
+ export const splitCredential = (env, command, {
211
+ onWarn = (m) => process.stderr.write(`${m}\n`),
212
+ readCredentialFile = (path) => readFileSync(path, 'utf8'),
213
+ } = {}) => {
214
+ const declared = { ...(env || {}) };
215
+ const declaredFile = declared[CREDENTIAL_FILE_VAR];
216
+ const requested = String(declared[CREDENTIAL_CHANNEL_VAR] || '').trim().toLowerCase();
217
+ delete declared[CREDENTIAL_CHANNEL_VAR];
218
+ let credential = null;
219
+ let fromFile = false;
220
+ // A declaration that names a file is using the launcher channel, and that is
221
+ // the one to honour: if it ALSO carries a literal token, the literal is the
222
+ // leftover of an older declaration, and preferring it would keep the secret in
223
+ // the very place this exists to empty.
224
+ if (declaredFile !== undefined && String(declaredFile).trim() !== '') {
225
+ // The launcher channel: the declaration names a path, and the credential is
226
+ // read here so the child never needs the file (or the token) at all.
227
+ const path = String(declaredFile).trim();
228
+ let raw;
229
+ try {
230
+ raw = readCredentialFile(path);
231
+ } catch (err) {
232
+ throw new Error(`the declared credential file could not be read: ${path}: ${err.message}`);
233
+ }
234
+ credential = String(raw ?? '').trim();
235
+ if (!credential) throw new Error(`the declared credential file carried nothing: ${path}`);
236
+ fromFile = true;
237
+ } else if (declared[CREDENTIAL_KEY]) {
238
+ credential = declared[CREDENTIAL_KEY];
239
+ }
240
+ if (!credential) {
241
+ delete declared[CREDENTIAL_KEY];
242
+ delete declared[CREDENTIAL_FILE_VAR];
243
+ return { env: declared, credential: null, keepInEnv: false };
244
+ }
245
+ if (requested === 'env') {
246
+ declared[CREDENTIAL_KEY] = credential;
247
+ delete declared[CREDENTIAL_FILE_VAR];
248
+ return { env: declared, credential: null, keepInEnv: true };
249
+ }
250
+ const server = describeMcpCommand(command);
251
+ if (!server) {
252
+ // Not identifiable as @commonlyai/mcp. A declaration that put this key in a
253
+ // stranger's environment asked for it to be there, and that server has no
254
+ // reason to know about a pipe; changing its contract is not this change's
255
+ // business.
256
+ return { env: declared, credential: null, keepInEnv: true };
257
+ }
258
+ if (server.version && olderThanPipeReader(server.version) === true) {
259
+ onWarn(`[pi-mcp-client] ${command[0]} runs ${MCP_PACKAGE} ${server.version.join('.')}, which predates the pipe channel (0.3.11): keeping the token in the child environment. Unpin it, or set ${CREDENTIAL_CHANNEL_VAR}=env to say so on purpose.`);
260
+ declared[CREDENTIAL_KEY] = credential;
261
+ delete declared[CREDENTIAL_FILE_VAR];
262
+ return { env: declared, credential: null, keepInEnv: true };
263
+ }
264
+ delete declared[CREDENTIAL_KEY];
265
+ // The pipe carries the credential, so the child needs neither the token nor the
266
+ // path: one declared channel, and it is the fd.
267
+ delete declared[CREDENTIAL_FILE_VAR];
268
+ declared[CREDENTIAL_FD_VAR] = String(CREDENTIAL_FD);
269
+ return { env: declared, credential, keepInEnv: false, fromFile };
270
+ };
271
+
272
+ /** A minimal MCP stdio client: initialize, tools/list, tools/call. */
273
+ export const connectStdioMcp = ({
274
+ name, command, env,
275
+ }, {
276
+ spawnImpl = spawn, timeoutMs = 60_000, onWarn, parentEnv = process.env, readCredentialFile,
277
+ } = {}) => {
278
+ const [cmd, ...args] = command;
279
+ const opts = onWarn ? { onWarn } : {};
280
+ if (readCredentialFile) opts.readCredentialFile = readCredentialFile;
281
+ const { env: declaredEnv, credential, keepInEnv } = splitCredential(env, command, opts);
282
+ // The child gets an ALLOWLIST plus its own declaration — never the daemon's
283
+ // environment. `...process.env` is what let a third-party MCP server inherit
284
+ // both the seat token and the model key (TASK-083).
285
+ const childEnv = buildChildEnv(parentEnv, declaredEnv);
286
+ if (!keepInEnv) delete childEnv[CREDENTIAL_KEY];
287
+ const stdio = credential ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'];
288
+ const proc = spawnImpl(cmd, args, { env: childEnv, stdio });
289
+ if (credential) {
290
+ const channel = proc.stdio && proc.stdio[CREDENTIAL_FD];
291
+ if (!channel) {
292
+ // Fail loudly rather than fall back: the environment it would fall back to
293
+ // is the thing this change exists to empty.
294
+ throw new Error(`${name}: no fd ${CREDENTIAL_FD} pipe to carry the runtime token`);
295
+ }
296
+ channel.on('error', () => {});
297
+ channel.end(credential);
298
+ }
299
+ const pending = new Map();
300
+ let nextId = 1;
301
+ let buffer = '';
302
+ proc.stdout.on('data', (chunk) => {
303
+ buffer += chunk.toString();
304
+ let nl;
305
+ while ((nl = buffer.indexOf('\n')) !== -1) {
306
+ const line = buffer.slice(0, nl).trim();
307
+ buffer = buffer.slice(nl + 1);
308
+ if (!line) continue;
309
+ let msg;
310
+ try { msg = JSON.parse(line); } catch { continue; }
311
+ const waiter = msg && msg.id !== undefined ? pending.get(msg.id) : null;
312
+ if (!waiter) continue;
313
+ pending.delete(msg.id);
314
+ clearTimeout(waiter.timer);
315
+ if (msg.error) waiter.reject(new Error(`${name}: ${msg.error.message || JSON.stringify(msg.error)}`));
316
+ else waiter.resolve(msg.result);
317
+ }
318
+ });
319
+ proc.on('exit', (code) => {
320
+ for (const [id, waiter] of pending) {
321
+ pending.delete(id);
322
+ clearTimeout(waiter.timer);
323
+ waiter.reject(new Error(`${name}: MCP server exited (${code}) before answering`));
324
+ }
325
+ });
326
+ const send = (obj) => proc.stdin.write(`${JSON.stringify(obj)}\n`);
327
+ const request = (method, params) => new Promise((resolve, reject) => {
328
+ const id = nextId++;
329
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${name}: ${method} timed out after ${timeoutMs}ms`)); }, timeoutMs);
330
+ pending.set(id, { resolve, reject, timer });
331
+ send({ jsonrpc: '2.0', id, method, params: params || {} });
332
+ });
333
+ const notify = (method, params) => send({ jsonrpc: '2.0', method, params: params || {} });
334
+ const initialize = async () => {
335
+ const result = await request('initialize', {
336
+ protocolVersion: PROTOCOL_VERSION,
337
+ capabilities: {},
338
+ clientInfo: { name: 'commonly-pi-bridge', version: '1.0.0' },
339
+ });
340
+ notify('notifications/initialized');
341
+ return result;
342
+ };
343
+ const listTools = async () => (await request('tools/list')).tools || [];
344
+ const callTool = (toolName, args) => request('tools/call', { name: toolName, arguments: args || {} });
345
+ const close = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
346
+ return { initialize, listTools, callTool, close, proc };
347
+ };
348
+
349
+ /**
350
+ * Split an SSE body into its `data:` payloads. Streamable HTTP lets a server
351
+ * answer a POST either with one JSON object or with an event stream holding
352
+ * the JSON-RPC messages, so both shapes are parsed.
353
+ */
354
+ const ssePayloads = (text) => text.split(/\r?\n\r?\n/)
355
+ .map((block) => block.split(/\r?\n/)
356
+ .filter((line) => line.startsWith('data:'))
357
+ .map((line) => line.slice(5).trimStart())
358
+ .join('\n'))
359
+ .filter(Boolean);
360
+
361
+ const parseMessages = (text, contentType) => {
362
+ const body = text.trim();
363
+ if (!body) return [];
364
+ if (contentType.includes('text/event-stream')) return ssePayloads(body);
365
+ // A server MAY answer a request with plain JSON regardless of the request's
366
+ // `accept`, so the body decides before the header does.
367
+ if (body.startsWith('{') || body.startsWith('[')) return [body];
368
+ return ssePayloads(body);
369
+ };
370
+
371
+ /**
372
+ * A minimal MCP Streamable HTTP client: initialize, tools/list, tools/call.
373
+ *
374
+ * `headers` is where a declared `Authorization` arrives, already substituted
375
+ * with the seat's runtime token by the adapter. The token therefore rides in an
376
+ * HTTP header built from the JSON list the bridge reads off its own fd 3 (see
377
+ * takeServers) — never on argv, and never in this process's environment, which a
378
+ * same-user child of this process can read back whole (`ps eww $PPID`,
379
+ * `/proc/$PPID/environ`) no matter what this process deletes from its own copy.
380
+ */
381
+ export const connectHttpMcp = ({ name, url, headers }, { fetchImpl = globalThis.fetch, timeoutMs = 60_000 } = {}) => {
382
+ if (typeof fetchImpl !== 'function') throw new Error(`${name}: no fetch implementation for the HTTP MCP transport`);
383
+ const declared = headers || {};
384
+ let nextId = 1;
385
+ let sessionId = null;
386
+ let protocolVersion = PROTOCOL_VERSION;
387
+ let negotiated = false;
388
+ const requestHeaders = () => ({
389
+ 'content-type': 'application/json',
390
+ // Both, per the spec: a server may answer with JSON or with an event stream.
391
+ accept: 'application/json, text/event-stream',
392
+ ...declared,
393
+ ...(sessionId ? { 'mcp-session-id': sessionId } : {}),
394
+ // The version header follows NEGOTIATION, not the session. Our own broker is
395
+ // stateless (`mcpGrants.ts` sets `sessionIdGenerator: undefined`), so it
396
+ // never mints a session id — gating this on one meant the only broker we
397
+ // have never received the version it negotiated (Vera, Connectors).
398
+ ...(negotiated ? { 'mcp-protocol-version': protocolVersion } : {}),
399
+ });
400
+ const post = async (payload) => {
401
+ const controller = new AbortController();
402
+ let timer;
403
+ // Race an explicit timer as well as aborting: a client whose fetch ignores
404
+ // the signal must not leave a seat waiting forever on a dead endpoint.
405
+ const expired = new Promise((_resolve, reject) => {
406
+ timer = setTimeout(() => {
407
+ controller.abort();
408
+ reject(new Error(`${name}: timed out after ${timeoutMs}ms`));
409
+ }, timeoutMs);
410
+ });
411
+ try {
412
+ const res = await Promise.race([fetchImpl(url, {
413
+ method: 'POST',
414
+ headers: requestHeaders(),
415
+ body: JSON.stringify(payload),
416
+ signal: controller.signal,
417
+ // These requests carry the seat token in a header. A redirect is
418
+ // allowed to point at another origin, and whether undici strips an
419
+ // Authorization header when it follows one is not something a seat may
420
+ // rely on — so no redirect is followed at all.
421
+ redirect: 'error',
422
+ }), expired]);
423
+ const text = await res.text();
424
+ const header = res.headers?.get?.('mcp-session-id');
425
+ if (header && !sessionId) sessionId = header;
426
+ return { res, text, contentType: res.headers?.get?.('content-type') || '' };
427
+ } catch (error) {
428
+ if (error?.message?.startsWith(`${name}:`)) throw error;
429
+ throw new Error(`${name}: ${error?.name === 'AbortError' ? `timed out after ${timeoutMs}ms` : `${payload?.method || 'request'} failed: ${error?.message || error}`}`);
430
+ } finally {
431
+ clearTimeout(timer);
432
+ }
433
+ };
434
+ const request = async (method, params) => {
435
+ const id = nextId++;
436
+ const { res, text, contentType } = await post({ jsonrpc: '2.0', id, method, params: params || {} });
437
+ if (!res.ok) throw new Error(`${name}: ${method} failed with HTTP ${res.status}${text ? `: ${truncate(text)}` : ''}`);
438
+ const messages = parseMessages(text, contentType)
439
+ .map((raw) => { try { return JSON.parse(raw); } catch { return null; } })
440
+ .filter(Boolean);
441
+ const answer = messages.find((m) => m.id === id);
442
+ if (!answer) throw new Error(`${name}: ${method} returned no JSON-RPC answer (HTTP ${res.status})`);
443
+ if (answer.error) throw new Error(`${name}: ${answer.error.message || JSON.stringify(answer.error)}`);
444
+ return answer.result;
445
+ };
446
+ const notify = async (method, params) => {
447
+ const { res, text } = await post({ jsonrpc: '2.0', method, params: params || {} });
448
+ // The spec answers a notification with 202 and no body; anything else is
449
+ // an error the seat should see rather than a silent drop.
450
+ if (!res.ok) throw new Error(`${name}: ${method} failed with HTTP ${res.status}${text ? `: ${truncate(text)}` : ''}`);
451
+ };
452
+ const initialize = async () => {
453
+ const result = await request('initialize', {
454
+ protocolVersion: PROTOCOL_VERSION,
455
+ capabilities: {},
456
+ clientInfo: { name: 'commonly-pi-bridge', version: '1.0.0' },
457
+ });
458
+ // The server's answer is authoritative; echo what it negotiated from here on.
459
+ if (result && typeof result.protocolVersion === 'string') protocolVersion = result.protocolVersion;
460
+ negotiated = true;
461
+ await notify('notifications/initialized');
462
+ return result;
463
+ };
464
+ const listTools = async () => (await request('tools/list')).tools || [];
465
+ const callTool = (toolName, args) => request('tools/call', { name: toolName, arguments: args || {} });
466
+ const close = () => {
467
+ if (!sessionId) return;
468
+ try {
469
+ const pending = fetchImpl(url, { method: 'DELETE', headers: requestHeaders(), redirect: 'error' });
470
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
471
+ } catch { /* best effort: the session expires on its own */ }
472
+ };
473
+ return { initialize, listTools, callTool, close, sessionId: () => sessionId };
474
+ };
475
+
476
+ /** MCP `{ content, isError }` → pi tool result. Non-text parts are named, not dropped silently. */
477
+ export const toPiResult = (result) => {
478
+ const parts = (result?.content || []).map((c) => (c?.type === 'text' ? String(c.text ?? '') : `[${c?.type || 'content'} omitted]`));
479
+ const text = truncate(parts.join('\n'));
480
+ return { content: [{ type: 'text', text: result?.isError ? `error: ${text}` : text }], details: { isError: !!result?.isError } };
481
+ };
482
+
483
+ /**
484
+ * Read the server list off the inherited pipe on `fd` and CONSUME it.
485
+ *
486
+ * The list carries every server's substituted secrets — a stdio server's env and
487
+ * an HTTP server's `Authorization` header, the seat's bearer token among them —
488
+ * and pi's `bash` tool spawns with `{ ...process.env }` (pi's getShellEnv), so it
489
+ * has to arrive by a channel pi's children do not inherit and must not outlive
490
+ * the read. It arrives here as fd 3: a pipe the adapter writes the JSON into and
491
+ * ends at spawn (pi.js runPi). This reads it to EOF and closes the descriptor.
492
+ *
493
+ * WHY NOT THE ENVIRONMENT, which is what this replaced: deleting the variable
494
+ * scrubbed Node's copy only, while the kernel keeps the environment this process
495
+ * was STARTED with, so a same-user child still read the token back with
496
+ * `ps eww $PPID` on macOS and `/proc/$PPID/environ` on Linux. Unsetting it at
497
+ * spawn cannot help either, because this runs inside the pi process that holds
498
+ * it. WHY NOT a 0600 file the bridge unlinks on load: the mode protects nothing
499
+ * against a same-user reader, and that shape needs both the unlink and a close
500
+ * to leave no window, since `/proc/<pid>/fd` on Linux still reaches an unlinked
501
+ * inode. A pipe has neither a path nor a stored copy.
502
+ *
503
+ * THE READ IS WHAT REMOVES THE SECRET, not the close: a pipe is consumed, so a
504
+ * second reader gets nothing. Measured against pi 0.84.1 — after a read to EOF
505
+ * a second read of the same descriptor returned zero bytes, while a 5-byte
506
+ * partial read left the remainder readable. Closing afterwards is hygiene.
507
+ *
508
+ * Two further measurements this rests on, both against pi 0.84.1: pi does not
509
+ * close inherited descriptors before loading extensions, so fd 3 is still open
510
+ * here (this runs at extension load, before the first model turn — and before
511
+ * any bash tool can run); and pi's own spawns (`dist/core/tools/bash.js`,
512
+ * `dist/core/exec.js`) pass a THREE-element stdio list, so a shell tool child
513
+ * does not inherit fd 3 at all.
514
+ *
515
+ * An unreadable descriptor yields no servers rather than throwing: a seat whose
516
+ * bridge cannot read its list should run without Commonly tools, not fail to
517
+ * start. The bridge logs that empty result (pi-commonly-mcp.mjs).
518
+ *
519
+ * The descriptor is closed only after a successful read, and that is not
520
+ * tidiness: on macOS, `closeSync` on the descriptor Node opens for an `'ignore'`
521
+ * stdio entry aborts the process — measured 2026-09-19, Node 20 — with
522
+ * `Assertion failed: (errno == EINTR), function uv__io_poll, file kqueue.c`,
523
+ * where the read itself had already failed harmlessly with `ENXIO`. A close in
524
+ * `finally` therefore turns "no channel" into a SIGABRT in the seat.
525
+ */
526
+ export const takeServers = (fd = 3) => {
527
+ let raw = null;
528
+ try {
529
+ raw = readFileSync(fd, 'utf8');
530
+ } catch {
531
+ // Nothing to close: the read failed, so this was not a descriptor we consumed.
532
+ return [];
533
+ }
534
+ try { closeSync(fd); } catch { /* already closed */ }
535
+ return readServers(raw);
536
+ };
537
+
538
+ /**
539
+ * Entries with a non-empty `command` and no `url` are stdio; entries with a
540
+ * non-empty `url` and no `command` are Streamable HTTP. The two are mutually
541
+ * exclusive on purpose: `resolveMcpServers` emits exactly one field per entry
542
+ * (the transport decides which), so an entry carrying BOTH did not come from it
543
+ * and is dropped rather than spawned — the adapter is the layer that executes,
544
+ * and a shape it cannot classify is not one it should run. Both the adapter
545
+ * (pi.js resolveMcpServers) and this filter have to agree, or a server reaches
546
+ * the bridge as an unstartable entry.
547
+ */
548
+ export const isStdioServer = (s) => Array.isArray(s?.command) && s.command.length > 0
549
+ && !(typeof s?.url === 'string' && s.url.length > 0);
550
+ export const isHttpServer = (s) => typeof s?.url === 'string' && s.url.length > 0
551
+ && !(Array.isArray(s?.command) && s.command.length > 0);
552
+
553
+ /**
554
+ * The tools the bridge may register for one server: all of them, minus the
555
+ * entry's `withheldTools`. pi.js sets that list per turn on a public seat
556
+ * (publicSeatWithheldTools), so a withheld Commonly tool is never registered with
557
+ * pi at all; pi's own `--exclude-tools` removes the same names as a second
558
+ * layer.
559
+ */
560
+ /**
561
+ * Delete this spawn's credential directory from inside pi, before the session
562
+ * exists (F175c). The factory that calls this is awaited before pi creates the
563
+ * session, so the delete precedes every prompt by construction, which the
564
+ * parent's delete at pi's first JSON line cannot promise: that one runs on the
565
+ * parent's event loop, and a locally declared stdio server on a public seat
566
+ * could read the file for a prompt in the gap.
567
+ *
568
+ * The directory comes on the fd 3 list, on the broker entry, because the list is
569
+ * written by the adapter alone and consumed by the read. It is honoured only on
570
+ * a confined broker entry, and it is not an environment variable: a private pi
571
+ * turn passes the launcher's environment through, and an ambient value must
572
+ * never be able to name a directory to delete.
573
+ *
574
+ * pi logs a factory that throws and carries on without it, so a throw here would
575
+ * reach the prompt with the file on disk. A failed delete stops the process
576
+ * instead, and the adapter reports the line below as the turn's error.
577
+ */
578
+ export const removeSpawnCredential = (servers, {
579
+ rm = rmSync,
580
+ exit = process.exit,
581
+ write = (line) => process.stderr.write(line),
582
+ } = {}) => {
583
+ const dirs = new Set((Array.isArray(servers) ? servers : [])
584
+ .filter((s) => s?.confined === true && isHttpServer(s) && isGrantBrokerUrl(s.url)
585
+ && s.credentialDir !== undefined)
586
+ .map((s) => s.credentialDir));
587
+ for (const dir of dirs) {
588
+ try {
589
+ if (typeof dir !== 'string' || !isAbsolute(dir)) throw new Error('the path is not absolute');
590
+ rm(dir, { recursive: true, force: true });
591
+ } catch (error) {
592
+ write(`[commonly-pi-bridge] could not delete this turn's connector credential (${dir}): `
593
+ + `${error?.message || error}; stopping before any prompt runs\n`);
594
+ exit(1);
595
+ return false;
596
+ }
597
+ }
598
+ return true;
599
+ };
600
+
601
+ export const registrableTools = (tools, server) => {
602
+ const withheld = new Set(Array.isArray(server?.withheldTools) ? server.withheldTools : []);
603
+ return withheld.size ? tools.filter((tool) => !withheld.has(tool?.name)) : tools;
604
+ };
605
+
606
+ export const readServers = (raw) => {
607
+ if (!raw) return [];
608
+ try {
609
+ // A broker entry is kept only with the adapter's `confined: true` mark,
610
+ // which pi.js sets on a public turn alone (the same reason string, see
611
+ // GRANT_BROKER_PUBLIC_ONLY_REFUSAL): this is the last layer before a client
612
+ // is started, and the two filters have to agree or a server reaches the
613
+ // bridge as an entry the adapter would not have carried.
614
+ return JSON.parse(raw).filter((s) => s?.name
615
+ && (isStdioServer(s) || (isHttpServer(s) && (!isGrantBrokerUrl(s.url) || s.confined === true))));
616
+ } catch { return []; }
617
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Keep the provider key available to pi's per-call auth resolver, but out of
3
+ * shells started by its model bash tool and by manual ! / !! commands.
4
+ * This limits environment inheritance; pi itself is not an OS sandbox.
5
+ */
6
+ import { createBashTool, createLocalBashOperations } from '@earendil-works/pi-coding-agent';
7
+
8
+ export default function commonlyShellEnvironment(pi) {
9
+ const providerKey = process.env.COMMONLY_PI_PROVIDER_KEY_ENV;
10
+ if (!providerKey) throw new Error('pi adapter: missing provider key environment name');
11
+ const excluded = new Set([
12
+ providerKey, 'COMMONLY_LITELLM_KEY', 'COMMONLY_PI_PROVIDER_KEY', 'COMMONLY_PI_PROVIDER_KEY_ENV',
13
+ ].map((key) => key.toUpperCase()));
14
+ const cleanEnv = (env) => {
15
+ const clean = { ...env };
16
+ // Windows environment lookup ignores case; spreading it loses that behavior.
17
+ for (const key of Object.keys(clean)) {
18
+ if (excluded.has(key.toUpperCase())) delete clean[key];
19
+ }
20
+ return clean;
21
+ };
22
+ const spawnHook = (context) => ({ ...context, env: cleanEnv(context.env) });
23
+ const bash = createBashTool(process.cwd(), { spawnHook });
24
+ pi.registerTool({
25
+ ...bash,
26
+ execute(id, params, signal, onUpdate, ctx) {
27
+ return createBashTool(ctx.cwd, { spawnHook }).execute(id, params, signal, onUpdate, ctx);
28
+ },
29
+ });
30
+ const local = createLocalBashOperations();
31
+ pi.on('user_bash', () => ({
32
+ operations: {
33
+ exec(command, cwd, options) {
34
+ return local.exec(command, cwd, {
35
+ ...options,
36
+ env: cleanEnv(options.env ?? process.env),
37
+ });
38
+ },
39
+ },
40
+ }));
41
+ }