@ravefox/space 0.2.1 → 0.4.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 (167) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +104 -31
  3. package/bin/space.js +113 -0
  4. package/lib/agents.js +181 -0
  5. package/lib/boardTools.js +89 -0
  6. package/lib/credentials.js +95 -0
  7. package/lib/daemon.js +294 -0
  8. package/lib/harness.js +166 -0
  9. package/lib/http.js +101 -0
  10. package/lib/login.js +68 -0
  11. package/lib/mcp.js +39 -0
  12. package/lib/runtime.js +253 -0
  13. package/lib/store.js +96 -0
  14. package/lib/tools.js +93 -0
  15. package/lib/workspaceSandbox.js +77 -0
  16. package/lib/workspaceTools.js +160 -0
  17. package/lib/workspaceWorker.js +17 -0
  18. package/package.json +27 -46
  19. package/NOTICE +0 -55
  20. package/assets/webhook-sdk/python/bot.py +0 -63
  21. package/assets/webhook-sdk/python/commonly.py +0 -232
  22. package/node_modules/@commonlyai/seats/README.md +0 -25
  23. package/node_modules/@commonlyai/seats/package.json +0 -22
  24. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  25. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  26. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  27. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  28. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  29. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  30. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  31. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  32. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  33. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  34. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  35. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  36. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  37. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  38. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  39. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  40. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  41. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  42. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  43. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  44. package/skills/commonly/SKILL.md +0 -233
  45. package/src/commands/agent.js +0 -6334
  46. package/src/commands/daemon.js +0 -933
  47. package/src/commands/dev.js +0 -153
  48. package/src/commands/doctor.js +0 -141
  49. package/src/commands/inbox.js +0 -330
  50. package/src/commands/login.js +0 -194
  51. package/src/commands/mcp.js +0 -56
  52. package/src/commands/pod.js +0 -120
  53. package/src/commands/task.js +0 -279
  54. package/src/commands/verify.js +0 -48
  55. package/src/index.js +0 -99
  56. package/src/lib/adapter-login.js +0 -76
  57. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  58. package/src/lib/adapters/claude.js +0 -2207
  59. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  60. package/src/lib/adapters/codex.js +0 -1898
  61. package/src/lib/adapters/dsh.js +0 -513
  62. package/src/lib/adapters/goose.js +0 -510
  63. package/src/lib/adapters/hermes.js +0 -18
  64. package/src/lib/adapters/index.js +0 -68
  65. package/src/lib/adapters/opencode.js +0 -1681
  66. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  67. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  68. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  69. package/src/lib/adapters/pi.js +0 -1025
  70. package/src/lib/adapters/qwen.js +0 -523
  71. package/src/lib/adapters/seat-model.js +0 -8
  72. package/src/lib/adapters/stub.js +0 -1
  73. package/src/lib/adapters/tested-version.js +0 -106
  74. package/src/lib/api.js +0 -205
  75. package/src/lib/claim-outcome.js +0 -128
  76. package/src/lib/claimed-work-gate.js +0 -73
  77. package/src/lib/cli-hint.js +0 -33
  78. package/src/lib/commit-attribution.js +0 -391
  79. package/src/lib/config.js +0 -155
  80. package/src/lib/content-refusal.js +0 -94
  81. package/src/lib/credential-file.js +0 -208
  82. package/src/lib/daemon-agent-states.js +0 -95
  83. package/src/lib/daemon-hint.js +0 -18
  84. package/src/lib/daemon-logs.js +0 -43
  85. package/src/lib/daemon-service-windows.js +0 -263
  86. package/src/lib/daemon-service.js +0 -486
  87. package/src/lib/daemon-state.js +0 -84
  88. package/src/lib/daemon-store.js +0 -119
  89. package/src/lib/daemon-supervisor.js +0 -629
  90. package/src/lib/daemon-watch.js +0 -115
  91. package/src/lib/declared-mcp-guard.js +0 -284
  92. package/src/lib/default-environment.js +0 -207
  93. package/src/lib/device-login.js +0 -126
  94. package/src/lib/digest-frame.js +0 -151
  95. package/src/lib/doctor.js +0 -291
  96. package/src/lib/egress-proxy.js +0 -160
  97. package/src/lib/enforcement.js +0 -946
  98. package/src/lib/environment.js +0 -681
  99. package/src/lib/first-output-watchdog.js +0 -191
  100. package/src/lib/git-readonly-server.js +0 -138
  101. package/src/lib/git-readonly.js +0 -463
  102. package/src/lib/git-remote-commonly.mjs +0 -100
  103. package/src/lib/grant-broker-guard.js +0 -345
  104. package/src/lib/hooks-config.js +0 -238
  105. package/src/lib/host-gh-broker.js +0 -152
  106. package/src/lib/host-gh-shim.mjs +0 -98
  107. package/src/lib/inbox.js +0 -483
  108. package/src/lib/instance-proxy.js +0 -76
  109. package/src/lib/machine-host.js +0 -29
  110. package/src/lib/mcp-credential-delivery.js +0 -121
  111. package/src/lib/mcp-first-run.js +0 -50
  112. package/src/lib/mcp-home.js +0 -644
  113. package/src/lib/mcp-ready.js +0 -68
  114. package/src/lib/mcp-server-version.js +0 -123
  115. package/src/lib/mcp-token.js +0 -36
  116. package/src/lib/mcp-warm-child.mjs +0 -412
  117. package/src/lib/memory-bridge.js +0 -191
  118. package/src/lib/memory-import.js +0 -156
  119. package/src/lib/missing-harness.js +0 -31
  120. package/src/lib/model-unavailable.js +0 -154
  121. package/src/lib/native-runtime-inventory.js +0 -40
  122. package/src/lib/outsider-grant-mcp.js +0 -82
  123. package/src/lib/own-key-launch.js +0 -42
  124. package/src/lib/pod-catch-up.js +0 -55
  125. package/src/lib/pod-focus.js +0 -224
  126. package/src/lib/pod-tail.js +0 -68
  127. package/src/lib/poll-liveness.js +0 -103
  128. package/src/lib/poll-retry.js +0 -81
  129. package/src/lib/poller.js +0 -200
  130. package/src/lib/provider-proxy.js +0 -42
  131. package/src/lib/public-seat-mcp-flag.js +0 -36
  132. package/src/lib/public-tool-error.js +0 -60
  133. package/src/lib/public-withheld-tools.js +0 -48
  134. package/src/lib/reply-receipt.js +0 -55
  135. package/src/lib/resolve-on-path.js +0 -22
  136. package/src/lib/runtime-inventory.js +0 -56
  137. package/src/lib/runtime-observation.js +0 -37
  138. package/src/lib/sandbox/bwrap.js +0 -275
  139. package/src/lib/sandbox/codex-perimeter.js +0 -16
  140. package/src/lib/sandbox/host-check.js +0 -268
  141. package/src/lib/sandbox/mode.js +0 -49
  142. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  143. package/src/lib/sandbox/seatbelt.js +0 -684
  144. package/src/lib/sandbox/turn-dir.js +0 -56
  145. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  146. package/src/lib/sandbox/workspace-git-config.js +0 -243
  147. package/src/lib/sandbox/workspace-pin.js +0 -158
  148. package/src/lib/seat-runtime-controls.js +0 -61
  149. package/src/lib/seat-stop.js +0 -97
  150. package/src/lib/server-skills.js +0 -32
  151. package/src/lib/session-recycle.js +0 -219
  152. package/src/lib/session-store.js +0 -215
  153. package/src/lib/skill-prompt.js +0 -74
  154. package/src/lib/skills-import.js +0 -113
  155. package/src/lib/spawn-credential.js +0 -402
  156. package/src/lib/spawn-retry.js +0 -311
  157. package/src/lib/stable-install.js +0 -176
  158. package/src/lib/supervised-turn.js +0 -352
  159. package/src/lib/task.js +0 -235
  160. package/src/lib/turn-failure-notice.js +0 -177
  161. package/src/lib/unconfined-seat-guard.js +0 -94
  162. package/src/lib/update-check.js +0 -192
  163. package/src/lib/upstream-refusal.js +0 -1
  164. package/src/lib/webhook-server.js +0 -109
  165. package/src/lib/windows-command.js +0 -393
  166. package/src/lib/workspace-git.js +0 -87
  167. package/src/lib/workspace-trust.js +0 -162
@@ -1,1025 +0,0 @@
1
- /**
2
- * pi adapter — ADR-005 adapter contract for the pi coding agent
3
- * (https://pi.dev, `@earendil-works/pi-coding-agent`), the harness that lets
4
- * a wrapper seat run on any OpenAI-compatible model: DeepSeek through
5
- * LiteLLM today, anything LiteLLM routes tomorrow.
6
- *
7
- * Why a third adapter (2026-09-18): the Luna code-writer seats ran codex on
8
- * ChatGPT quota, and when that ran out the fleet stalled. codex 0.153 cannot
9
- * drive DeepSeek — it sends a `namespace`-type tool DeepSeek's API rejects
10
- * even with every feature flag off — while pi headless on LiteLLM's
11
- * `deepseek-v4-flash` wrote a file with its `write` tool, ran it with `bash`
12
- * and resumed its session on the next turn (proof on this laptop, 03:57Z).
13
- * pi was already the hosted turn engine (ADR-021); this is pi as a wrapper.
14
- *
15
- * How pi is driven:
16
- * pi -p --mode json --provider <p> --model <m> --thinking <t>
17
- * --session-dir <seat dir> (--session-id <uuid> | --session <uuid>)
18
- * -e pi-commonly-mcp.mjs "<prompt>"
19
- *
20
- * - `--session-id` creates the session on the first turn; `--session`
21
- * resumes it (`--session-id` cannot be combined with `--continue`).
22
- * - stdout is NDJSON; the reply is the last assistant `message_end`.
23
- * - Provider config is a per-seat models.json under
24
- * `~/.commonly/pi-homes/<hash>/agent`, pointed at by
25
- * PI_CODING_AGENT_DIR — never the operator's ~/.pi. The API key is an
26
- * env reference (`$COMMONLY_LITELLM_KEY`, or `$COMMONLY_PI_PROVIDER_KEY`
27
- * for a seat with its own provider.keyFile), so it never lands on disk.
28
- * - Commonly's tools reach pi through pi-commonly-mcp.mjs, an extension
29
- * that speaks MCP over stdio to every server in `environment.mcp` and
30
- * registers each tool. The token rides through the child env, never argv
31
- * (same rule as codex.js).
32
- *
33
- * A public seat (F113b) runs the same pi as
34
- * sandbox-exec -p <profile> <abs pi> … --no-builtin-tools
35
- * --exclude-tools <pi built-ins + this turn's withheld tools> --no-approve …
36
- * with a scrubbed environment and the provider behind a loopback proxy. pi's
37
- * MCP servers start inside the jailed pi (the bridge is in-process), so they
38
- * inherit the profile. Confinement is an explicit `sandbox.trust: 'public'`,
39
- * never a derived default: a derived one would strip the shell from every
40
- * private fleet pi seat (default-environment.js).
41
- *
42
- * Contract (see stub.js): detect() and spawn(prompt, ctx) → { text, newSessionId }.
43
- */
44
-
45
- import { requireSeatModel } from './seat-model.js';
46
- import { probeVersion } from './tested-version.js';
47
- import { resolveOnPath } from '../resolve-on-path.js';
48
- import { spawn as childSpawn, spawnSync } from 'child_process';
49
- import { createHash, randomUUID } from 'crypto';
50
- import { existsSync, readFileSync, realpathSync, rmSync } from 'fs';
51
- import {
52
- mkdir, readdir, readFile, rm, writeFile,
53
- } from 'fs/promises';
54
- import { homedir } from 'os';
55
- import {
56
- dirname, isAbsolute, join,
57
- } from 'path';
58
- import { fileURLToPath } from 'url';
59
- import {
60
- adapterFailure,
61
- describeUpstreamRefusal,
62
- readUpstreamRefusal,
63
- spawnCredentials,
64
- } from '../upstream-refusal.js';
65
- import { buildMemoryPreamble } from '../memory-bridge.js';
66
- import {
67
- effectiveSandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec,
68
- } from '../environment.js';
69
- import { GRANT_BROKER_PUBLIC_ONLY_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
70
- import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
71
- import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
72
- import { removeCredentialFile, writeCredentialFile } from '../credential-file.js';
73
- import { resolvePublicSandboxMode } from '../sandbox/mode.js';
74
- import { seatbeltSystemReadRoots, wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
75
- import { keyFileReadableRoot, resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
76
- import { publicSeatWithheldTools } from '../public-withheld-tools.js';
77
- import { promptTokens } from '../session-recycle.js';
78
-
79
- const DEFAULT_TIMEOUT_MS = (() => {
80
- const fallback = 15 * 60 * 1000;
81
- const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
82
- if (!raw) return fallback;
83
- const parsed = Number(raw);
84
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
85
- })();
86
-
87
- // The default provider is Commonly's own LiteLLM, reachable from a laptop
88
- // seat at the public ingress and from a cluster seat at the service.
89
- export const DEFAULT_PROVIDER = Object.freeze({
90
- name: 'litellm',
91
- baseUrl: 'https://litellm.commonly.me/v1',
92
- api: 'openai-completions',
93
- apiKeyEnv: 'COMMONLY_LITELLM_KEY',
94
- });
95
-
96
- // What a seat with its own `provider.keyFile` calls its key inside pi. A private
97
- // turn holds the key here, and pi-shell-env.mjs keeps it out of pi's shells. A
98
- // public turn holds the proxy's token here. models.json holds only `$` + this.
99
- const OWN_KEY_ENV = 'COMMONLY_PI_PROVIDER_KEY';
100
-
101
- // The shared provider shape's `api` (environment.js PROVIDER_APIS) in pi's own
102
- // words. Both measured on pi 0.84.1 against a local stub: `openai-completions`
103
- // calls `<baseURL>/chat/completions` with a Bearer, `anthropic-messages` calls
104
- // `<baseURL>/v1/messages` with x-api-key, so an Anthropic baseURL is the host,
105
- // as it is for OpenCode and for the provider proxy.
106
- const PI_PROVIDER_APIS = Object.freeze({
107
- 'openai-chat': 'openai-completions',
108
- 'anthropic-messages': 'anthropic-messages',
109
- });
110
-
111
- // ADR-008 `effort` → pi `--thinking`. pi's ladder is off/minimal/low/medium/high/xhigh/max.
112
- const THINKING = { none: 'off', off: 'off', minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' };
113
- export const thinkingFor = (effort) => (effort ? THINKING[String(effort).toLowerCase()] || null : null);
114
-
115
- const ADAPTERS_DIR = dirname(fileURLToPath(import.meta.url));
116
- const BRIDGE_PATH = join(ADAPTERS_DIR, 'pi-commonly-mcp.mjs');
117
- const SHELL_ENV_PATH = join(ADAPTERS_DIR, 'pi-shell-env.mjs');
118
- // The bridge imports siblings from the CLI's lib dir (credential-file.js,
119
- // mcp-server-version.js), so a jailed pi must be able to read that tree. It
120
- // holds code only.
121
- const EXTENSION_CODE_ROOT = dirname(ADAPTERS_DIR);
122
-
123
- // A public seat runs only a pi version whose tool flags were checked against a
124
- // real headless turn under Seatbelt: `--no-builtin-tools`, `--exclude-tools`
125
- // (which removes a tool from pi's registry, extension tools included, not only
126
- // from the active set) and `--no-approve`. Add a version only with that smoke.
127
- export const TESTED_PI_VERSIONS = Object.freeze(['0.84.1']);
128
- const PI_PACKAGE = '@earendil-works/pi-coding-agent';
129
-
130
- // pi's built-in tools (`allToolNames`, core/tools/index.js in 0.84). A public
131
- // seat gets none of them: no shell, and no file reads either, because the jail
132
- // has to admit the seat home that holds this turn's credential file (the bridge
133
- // reads it), so a read tool could hand that file to the room. Every public
134
- // adapter denies its shell; Qwen denies its read tools for this same reason,
135
- // and Goose loads no built-in at all.
136
- export const PI_BUILTIN_TOOLS = Object.freeze(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
137
-
138
- // What a public pi inherits from the launcher's environment. Everything else
139
- // (operator variables, the real provider key, the seat token) stays out.
140
- const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
141
- const safeEnv = (env) => Object.fromEntries(Object.entries(env).filter(([name]) => PUBLIC_SAFE_ENV.test(name)));
142
-
143
- // The one provider API the loopback proxy carries for pi. The proxy's model pin
144
- // checks chat completions only, and a public turn must not run unpinned.
145
- const PUBLIC_PROVIDER_API = 'openai-completions';
146
- const publicApiRefusal = (api) => `pi adapter: a public seat reaches its provider through a loopback proxy that carries the ${PUBLIC_PROVIDER_API} API only, not ${api}`;
147
-
148
- // Same substitution contract as claude.js / codex.js: ${COMMONLY_*}
149
- // placeholders in the declared MCP env are the wrapper's per-(agent, pod)
150
- // runtime values, filled at spawn time.
151
- const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_TOKEN_FILE', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
152
- const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
153
- const substitutePlaceholders = (value, ctx) => {
154
- if (typeof value !== 'string' || !value.includes('${COMMONLY_')) return value;
155
- const subs = {
156
- COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
157
- // The PATH of this spawn's credential file. The bridge reads the file and
158
- // hands the VALUE to the server on fd 3, so the token still never reaches a
159
- // child's environment — but the payload that travels to the bridge carries a
160
- // path rather than the secret (TASK-083).
161
- COMMONLY_TOKEN_FILE: ctx.credentialFile || '',
162
- COMMONLY_API_URL: ctx.instanceUrl || '',
163
- COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
164
- };
165
- return value.replace(PLACEHOLDER_RE, (whole, key) => (SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole));
166
- };
167
-
168
- /**
169
- * The daemon's own predicate, applied verbatim: `auditDeclaredMcp` reads
170
- * `server.transport || 'stdio'` and compares it as an exact string. It is
171
- * deliberately not friendlier than the guard's — a normalized `'HTTP'` or a
172
- * padded `' http '` is a shape the guard refuses as an unknown transport, and an
173
- * adapter that accepted one would be running something the guard never judged
174
- * The schema admits `http`/`stdio`/`sse`
175
- * (environment.js:236) and admits an ABSENT transport, which the guard reads as
176
- * stdio; that is what this reads too.
177
- */
178
- const transportOf = (server) => server.transport || 'stdio';
179
-
180
- const originOf = (value) => {
181
- try {
182
- return new URL(value).origin;
183
- } catch {
184
- return null;
185
- }
186
- };
187
-
188
- /**
189
- * Declared MCP servers from the environment spec, placeholders filled. Both
190
- * transports the spec admits are carried through: stdio (`command` + `env`) and
191
- * Streamable HTTP (`url` + `headers`). An entry with neither is skipped — there
192
- * is nothing to start.
193
- *
194
- * The HTTP half matters beyond a user-declared remote server: the grant broker
195
- * is a Streamable HTTP server (`agentBinding.ts` grantBrokerServer), so before
196
- * this pi dropped the one entry that carries a grant to a seat, silently.
197
- * Filling the headers here reuses the same substitution as the stdio env, and
198
- * the result is written into pi's fd 3 at spawn — a pipe the bridge reads to EOF
199
- * and closes (see takeServers), never the child's environment and never argv. The
200
- * environment is not usable for this: the kernel keeps the copy the process was
201
- * started with, so a same-user child could read the token back with
202
- * `ps eww $PPID` / `/proc/$PPID/environ` even after the bridge deleted its own
203
- * copy — which is what the previous channel did, and the row this closes.
204
- *
205
- * WHICH shape an entry becomes is decided by `transport` — the same field, read
206
- * with the same default and the same exact comparison the daemon's
207
- * `auditDeclaredMcp` judges it by — and the field that transport does not select
208
- * is dropped unsent. Classifying by which field is PRESENT instead is a bypass,
209
- * not a shorthand: an entry declaring `transport: 'http'` with the instance's own
210
- * url passes the guard (its http rule checks only the url origin, and
211
- * `${COMMONLY_AGENT_TOKEN}` in the command is one of the known placeholders), and
212
- * a presence-classifier then ran that command as stdio with the real token
213
- * substituted. The guard's judgement and the adapter's disagreed, and the adapter
214
- * is what executes.
215
- *
216
- * The http half also enforces the guard's own origin rule, so the HTTP half does
217
- * not depend on a guard that may not be on the machine: `auditDeclaredMcp`
218
- * admits a declared http server only when its url resolves to the INSTANCE's
219
- * origin, because the seat token rides its headers. Everything else — an
220
- * off-instance host, an unparseable url, an instance url we do not know — is
221
- * refused here as well. A transport pi cannot speak (`sse`, which the schema and
222
- * the guard both admit) is refused rather than reinterpreted as one it can.
223
- *
224
- * The grant broker is carried only on a public turn (F175c), keyed by PATH
225
- * rather than entry name (`isGrantBrokerUrl` in pi-mcp-client.mjs). It is the
226
- * one http entry that carries authority rather than data, and a public turn is
227
- * the only pi turn that is confined: Seatbelt on macOS (`assertSandboxSupported`
228
- * refuses a public turn anywhere else before this runs), with no built-in tool,
229
- * so the model cannot open a file at all. A carried broker is marked
230
- * `confined: true` for the bridge's own filter, and its name is reported on
231
- * `ctx.grantBrokers` so the spawn can end the credential file's life early. It
232
- * also names `ctx.credentialDir`, as a path, for the bridge to delete from
233
- * inside pi (`removeSpawnCredential`).
234
- * Every other pi turn refuses it, a broker the server flagged as named (F94)
235
- * included: this is the daemon-side half of the server's
236
- * `adapter_confines_public_only`, and the half that holds on deploy skew.
237
- *
238
- * The stdio half does NOT have that property and must not be read as if it did:
239
- * a declared stdio command is executed with no allowlist check, here and in both
240
- * sibling adapters, so it depends entirely on the guard — `auditDeclaredMcp`
241
- * admits only the shipped commonly MCP server or a command already present in the
242
- * local record, and that rule exists nowhere else (TASK-069).
243
- */
244
- export const resolveMcpServers = (mcpServers, ctx = {}) => {
245
- const carried = [];
246
- for (const server of mcpServers || []) {
247
- if (!server?.name || typeof server.name !== 'string') continue;
248
- const transport = transportOf(server);
249
- if (transport === 'http') {
250
- if (typeof server.url !== 'string' || !server.url) continue;
251
- const url = substitutePlaceholders(server.url, ctx);
252
- const origin = originOf(url);
253
- const instanceOrigin = originOf(ctx.instanceUrl);
254
- if (!origin || !instanceOrigin || origin !== instanceOrigin) {
255
- // eslint-disable-next-line no-console
256
- console.warn(`[pi] declared MCP server '${server.name}' points at ${origin || '(unparseable)'}, not this instance (${instanceOrigin || 'unknown'}) — not starting it`);
257
- continue;
258
- }
259
- // The grant broker carries AUTHORITY rather than data: a granted seat acts
260
- // on external systems as the granter. Only a confined (public) pi turn
261
- // may hold it; see the header above.
262
- const grantBroker = isGrantBrokerUrl(url);
263
- if (grantBroker && !ctx.publicSeat) {
264
- // eslint-disable-next-line no-console
265
- console.warn(`[pi] ${GRANT_BROKER_PUBLIC_ONLY_REFUSAL} — refusing the grant broker '${server.name}': pi carries a granter's authority only on a public seat confined by the macOS sandbox, and this seat is not one — declare sandbox.trust 'public' on a Mac, or remove the grant`);
266
- continue;
267
- }
268
- if (grantBroker) ctx.grantBrokers?.push(server.name);
269
- carried.push({
270
- name: server.name,
271
- url,
272
- headers: Object.fromEntries(Object.entries(server.headers || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
273
- ...(ctx.publicSeat ? { withheldTools: [...publicSeatWithheldTools(ctx)] } : {}),
274
- ...(grantBroker ? { confined: true } : {}),
275
- ...(grantBroker && ctx.credentialDir ? { credentialDir: ctx.credentialDir } : {}),
276
- });
277
- continue;
278
- }
279
- if (transport === 'stdio') {
280
- if (!Array.isArray(server.command) || !server.command.length) {
281
- // The url-only record that names no transport lands here: the guard reads
282
- // an absent transport as stdio too, and refuses it for having no command,
283
- // so the daemon never adopts it and this drop matches that judgement.
284
- if (typeof server.url === 'string' && server.url) {
285
- // eslint-disable-next-line no-console
286
- console.warn(`[pi] declared MCP server '${server.name}' names no transport, so it is judged stdio, and has no command — not starting it`);
287
- }
288
- continue;
289
- }
290
- // TASK-174: what will EXECUTE — the warmed build from the seat's MCP home
291
- // when there is one — and the credential channel is decided on that same
292
- // command, so the two can never disagree about which release runs.
293
- const spawnCommand = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl, publicSeat: Boolean(ctx.publicSeat) });
294
- // A public seat never runs a package runner (npx) from inside the jail; the
295
- // host-prepared build is the only stdio Commonly server it may start, and
296
- // that build's tree has to be readable inside the profile.
297
- if (!spawnCommand) {
298
- ctx.unavailableMcp?.push(server.name);
299
- continue;
300
- }
301
- if (ctx.publicSeat) {
302
- const versionDir = selectedMcpVersionDir(spawnCommand);
303
- if (versionDir) ctx.mcpReadDirectories?.push(versionDir);
304
- }
305
- carried.push({
306
- name: server.name,
307
- command: spawnCommand.map((a) => substitutePlaceholders(a, ctx)),
308
- // Rewritten before substitution, so our own server is handed the file
309
- // (whose value the bridge pipes) instead of the token itself.
310
- env: Object.fromEntries(Object.entries(
311
- deliverSeatCredential({ ...server, command: spawnCommand }, {
312
- credentialFile: ctx.credentialFile,
313
- label: 'pi',
314
- }).env,
315
- ).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
316
- ...(ctx.publicSeat ? { withheldTools: [...publicSeatWithheldTools(ctx)] } : {}),
317
- });
318
- continue;
319
- }
320
- // eslint-disable-next-line no-console
321
- console.warn(`[pi] declared MCP server '${server.name}' asks for transport '${transport}', which this adapter cannot speak — not starting it`);
322
- }
323
- return carried;
324
- };
325
-
326
- /**
327
- * Two provider shapes reach this adapter. The shared one, `{ id, baseURL,
328
- * keyFile, models, api }`, is what `--env` accepts and validateEnvironmentSpec
329
- * checks. pi's own, `{ name, baseUrl, api, apiKeyEnv }`, is a token-file
330
- * override older than that validation, and its `api` is pi's word for it. A key
331
- * only the shared shape has decides; a block with keys from both is then judged
332
- * as shared, and validation refuses it rather than reading half of each.
333
- */
334
- const SHARED_PROVIDER_KEYS = ['id', 'baseURL', 'keyFile', 'models'];
335
- const isSharedProvider = (spec) => Boolean(spec) && SHARED_PROVIDER_KEYS.some((key) => spec[key] !== undefined);
336
-
337
- /** The real key file of a shared provider, validated without reading it; null for any other seat. */
338
- const sharedProviderKeyFile = async (environment, workspacePath, platform) => {
339
- if (!isSharedProvider(environment?.provider)) return null;
340
- const validation = validateEnvironmentSpec(environment);
341
- if (!validation.ok) throw new Error(validation.errors.join('; '));
342
- return resolveProviderKeyFile(environment.provider, workspacePath, 'pi', { environment, platform });
343
- };
344
-
345
- /**
346
- * The proxy reads a public seat's key file from this process, so the file stays
347
- * out of the jail, and nothing here admits it. The jail does admit the seat's
348
- * own directories (the seat home is a stable path a key file could be put in),
349
- * and the Seatbelt baseline admits system trees such as /etc, /opt/homebrew/lib
350
- * and /usr/local/lib (seatbeltSystemReadRoots, read from the profile itself).
351
- * A key under any of them would be readable from inside, so it is refused. The
352
- * comparison is the shared keyFileReadableRoot (F453): as written, real-pathed
353
- * and by identity, so another spelling of the same path is caught too. The
354
- * system roots are also checked when the key file is first resolved; this is
355
- * where the per-turn directories join them.
356
- */
357
- export const refuseKeyFileInJail = (keyFile, readableDirectories, systemRoots = seatbeltSystemReadRoots()) => {
358
- const root = keyFileReadableRoot(keyFile, {
359
- subpaths: [...readableDirectories, ...systemRoots.subpaths],
360
- literals: systemRoots.literals,
361
- });
362
- if (root) {
363
- throw new Error(`pi adapter: provider.keyFile ${keyFile} is inside the sandbox of a public seat; move it outside ${root}`);
364
- }
365
- };
366
-
367
- /** The private turn's key, read the way goose reads it, refused if empty. */
368
- const readOwnKey = async (keyFile) => {
369
- const key = (await readFile(keyFile, 'utf8')).trim();
370
- if (!key) throw new Error('pi adapter: provider.keyFile must not be empty');
371
- return key;
372
- };
373
-
374
- /** The provider block for models.json: the env spec's `provider` over the LiteLLM default. */
375
- export const resolveProvider = (environment = {}) => {
376
- const spec = environment?.provider || {};
377
- if (isSharedProvider(spec)) {
378
- return {
379
- name: spec.id,
380
- baseUrl: spec.baseURL,
381
- api: PI_PROVIDER_APIS[spec.api || 'openai-chat'],
382
- apiKeyEnv: OWN_KEY_ENV,
383
- reasoning: spec.models?.[environment.model]?.reasoning !== false,
384
- };
385
- }
386
- return {
387
- name: spec.name || DEFAULT_PROVIDER.name,
388
- baseUrl: spec.baseUrl || DEFAULT_PROVIDER.baseUrl,
389
- api: spec.api || DEFAULT_PROVIDER.api,
390
- apiKeyEnv: spec.apiKeyEnv || DEFAULT_PROVIDER.apiKeyEnv,
391
- };
392
- };
393
-
394
- /** models.json content for one seat: one provider, one model, key by env reference. */
395
- export const buildModelsJson = (provider, model) => ({
396
- providers: {
397
- [provider.name]: {
398
- name: provider.name,
399
- baseUrl: provider.baseUrl,
400
- apiKey: `$${provider.apiKeyEnv}`,
401
- api: provider.api,
402
- models: [{
403
- id: model,
404
- name: model,
405
- reasoning: provider.reasoning ?? true,
406
- input: ['text'],
407
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
408
- contextWindow: 128000,
409
- maxTokens: 32000,
410
- }],
411
- },
412
- },
413
- });
414
-
415
- /**
416
- * Per-seat pi home: `~/.commonly/pi-homes/<hash(agent)>`. Never the operator's ~/.pi.
417
- *
418
- * A public turn gets its own sibling, `<hash>-public`. The home holds the
419
- * seat's sessions, so a shared one let a seat switched from private to public
420
- * resume a session full of private history: the wrapper hands back the id it
421
- * persisted, and `sessionExists` found it. In a separate home that id is not
422
- * found and the public turn starts fresh; and the Seatbelt profile admits only
423
- * the public home, so the private one is not readable from inside either.
424
- */
425
- export const seatHome = (ctx, { publicSeat = false } = {}) => {
426
- const identity = ctx.agentName || ctx.cwd || 'anonymous';
427
- const hash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
428
- const base = ctx._piHome || join(homedir(), '.commonly', 'pi-homes', hash);
429
- return publicSeat ? `${base}-public` : base;
430
- };
431
-
432
- /**
433
- * Only a session pi wrote can be resumed. The wrapper persists one id per
434
- * (agent, pod) across adapters, so a seat switched from codex hands pi the
435
- * codex thread id on its first turn — pi answers `No session found` and the
436
- * turn fails (sprint-impl's first spawn, 2026-09-18 05:06Z). pi's session
437
- * files are `<timestamp>_<id>.jsonl` under the seat's session dir; an id with
438
- * no file starts a fresh session under a new id, which the wrapper persists.
439
- */
440
- export const sessionExists = async (sessionDir, sessionId) => {
441
- if (!sessionId) return false;
442
- try {
443
- const files = await readdir(sessionDir);
444
- return files.some((name) => name.endsWith(`_${sessionId}.jsonl`) || name === `${sessionId}.jsonl`);
445
- } catch {
446
- return false;
447
- }
448
- };
449
-
450
- /**
451
- * What pi can enforce: a public seat, confined by Seatbelt on macOS (F113b),
452
- * and nothing else.
453
- *
454
- * The trust is read THROUGH the legacy table (`effectiveSandboxTrust`), not
455
- * raw. A stored `internal` means `public` and every other gate in this package
456
- * resolves it that way: the attach path (`agent.js`), the mode resolver
457
- * (`sandbox/mode.js`) and the grant-broker guard. Reading it raw here once made
458
- * this gate the one site that failed OPEN (TASK-114).
459
- *
460
- * Seatbelt only. pi has no bwrap path yet, so a public seat off macOS, or one
461
- * that names `bwrap`, is refused rather than run unconfined. A non-public seat
462
- * that declares a mode is refused too: nothing would enforce it.
463
- */
464
- export const assertSandboxSupported = (environment = {}, platform = process.platform) => {
465
- const sandbox = environment?.sandbox || {};
466
- refuseWorkspaceTrust(sandbox, 'pi');
467
- if (effectiveSandboxTrust(sandbox.trust) === 'public') {
468
- const mode = resolvePublicSandboxMode(sandbox, platform);
469
- if (!['workspace', 'read-only', 'bwrap'].includes(mode)) {
470
- throw new Error('pi adapter: public seats require an enforced sandbox');
471
- }
472
- if (platform !== 'darwin' || mode === 'bwrap') {
473
- throw new Error('pi adapter: public seats run only in the macOS Seatbelt sandbox; pi has no bwrap path yet, so this seat cannot run confined here');
474
- }
475
- if (sandbox.filesystem?.['read-outside']?.length) {
476
- throw new Error('pi adapter: public seats do not allow sandbox.filesystem.read-outside');
477
- }
478
- if (sandbox.network?.policy === 'restricted') {
479
- throw new Error('pi adapter: public seats do not support restricted networking');
480
- }
481
- return;
482
- }
483
- if (sandbox.mode && sandbox.mode !== 'none') {
484
- throw new Error(`pi adapter: sandbox.mode=${sandbox.mode} cannot be enforced by pi without public trust; declare sandbox.trust 'public' or remove the mode`);
485
- }
486
- };
487
-
488
- // Shared with the version probe (tested-version.js).
489
- export { resolveOnPath };
490
-
491
- /**
492
- * pi's install tree: the nearest directory above the real entry file whose
493
- * package.json names pi. Its `node_modules` and dist are what a jailed pi loads,
494
- * and nothing else under that prefix needs to be readable.
495
- */
496
- export const piPackageRoot = (binary) => {
497
- let dir;
498
- try { dir = dirname(realpathSync(binary)); } catch { return null; }
499
- for (;;) {
500
- const manifest = join(dir, 'package.json');
501
- if (existsSync(manifest)) {
502
- try {
503
- if (JSON.parse(readFileSync(manifest, 'utf8')).name === PI_PACKAGE) return dir;
504
- } catch { /* keep walking */ }
505
- }
506
- const parent = dirname(dir);
507
- if (parent === dir) return null;
508
- dir = parent;
509
- }
510
- };
511
-
512
- // The version is read whole or not at all, as goose.js does. A prerelease is kept (`0.84.1-rc.1` is not the tested
513
- // 0.84.1), and a version touching a letter, digit, `.`, `+`, `-` or `~` on either side (`0.84.1rc1`, `0.84.1.1`,
514
- // `1.0.84.1`, `v0.84.1`, `0.84.1+build`, the Debian-style `0.84.1~rc1`) reads as unknown, so the public gate never
515
- // admits part of a longer string.
516
- const versionOf = (value) => String(value || '').trim()
517
- .match(/(?<![\w.+~-])(\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)(?![\w.+~-])/)?.[1] || null;
518
-
519
- // The system prompt files pi reads from its agent folder unconditionally.
520
- const PI_AGENT_DIR_PROMPT_FILES = Object.freeze(['SYSTEM.md', 'APPEND_SYSTEM.md']);
521
-
522
- export const buildArgs = ({
523
- prompt, provider, model, thinking, sessionId, isResume, sessionDir, bridge, publicSeat = false, ownerWoken = false,
524
- }) => [
525
- '-p',
526
- '--mode', 'json',
527
- '--no-extensions',
528
- '--no-skills',
529
- '--no-prompt-templates',
530
- '--no-themes',
531
- // A public seat: no built-in tool (no shell, no file access), the withheld
532
- // Commonly tools removed from the registry, no project-local pi files from
533
- // the workspace trusted for this run, and no AGENTS.md / CLAUDE.md (F146).
534
- // pi discovers those from the working directory upward, independently of
535
- // --no-approve (0.84.1's resource loader gates them on noContextFiles alone),
536
- // so a public turn turns discovery off explicitly.
537
- ...(publicSeat ? [
538
- '--no-builtin-tools',
539
- '--exclude-tools', [...PI_BUILTIN_TOOLS, ...publicSeatWithheldTools({ ownerWoken })].join(','),
540
- '--no-approve',
541
- '--no-context-files',
542
- ] : []),
543
- '--provider', provider,
544
- '--model', model,
545
- ...(thinking ? ['--thinking', thinking] : []),
546
- '--session-dir', sessionDir,
547
- ...(isResume ? ['--session', sessionId] : ['--session-id', sessionId]),
548
- ...(bridge ? ['-e', bridge] : []),
549
- prompt,
550
- ];
551
-
552
- /**
553
- * The reply is the last assistant `message_end`'s text parts; tool calls are not
554
- * text. `finished` means pi ended the turn on an answer: `agent_end`, not retried,
555
- * after an assistant message that stopped with `stop`. An overflow's `agent_end`
556
- * says `willRetry: false` and then pi compacts and continues, so `agent_end` after
557
- * an `error` or `length` stop doesn't count; the continuation's message decides.
558
- * `lastError` is that last message's `errorMessage`, pi's cause for an `error`
559
- * stop (`Provider finish_reason: content_filter`, `fetch failed`). It is raw
560
- * provider text, so only adapterFailure's scrub may carry it anywhere.
561
- */
562
- export const extractReply = (stdout) => {
563
- let text = '';
564
- let usage = null;
565
- let sawAssistant = false;
566
- let finished = false;
567
- let lastStop = null;
568
- let lastError;
569
- const errors = [];
570
- for (const line of String(stdout).split('\n')) {
571
- if (!line.trim()) continue;
572
- let event;
573
- try { event = JSON.parse(line); } catch { continue; }
574
- if (event?.type === 'message_end' && event.message?.role === 'assistant') {
575
- const parts = (event.message.content || []).filter((c) => c?.type === 'text').map((c) => c.text || '');
576
- if (parts.length) { text = parts.join('\n').trim(); sawAssistant = true; }
577
- // F404: the last request's prompt, input counted apart from the cache.
578
- if (event.message.usage && typeof event.message.usage === 'object') usage = event.message.usage;
579
- lastStop = event.message.stopReason;
580
- lastError = typeof event.message.errorMessage === 'string' ? event.message.errorMessage : undefined;
581
- finished = false;
582
- }
583
- if (event?.type === 'agent_end') finished = event.willRetry !== true && lastStop === 'stop';
584
- if (event?.type === 'error') errors.push(String(event.message || event.error || 'error'));
585
- }
586
- return {
587
- text, sawAssistant, finished, lastStop, lastError, errors, contextTokens: usage ? promptTokens(usage) : null,
588
- };
589
- };
590
-
591
- // A turn that printed an interim message and then ended without finishing. A
592
- // crash keeps its cause, so a quota line in stderr or a refusal on the stream
593
- // still classifies. A signal (143, 129, or a null code) has neither: stderr then
594
- // holds pi's startup warnings, and a refusal on the stream may be one pi was
595
- // recovering from, like an overflow's 400. A clean exit that ended on an error
596
- // has only the stream: its stderr is those warnings too, and the refusal stands.
597
- // A stop with no status still has the cause pi gave it, and without it a content
598
- // filter's stop classes as a runtime fault and is retried in seconds (F320).
599
- // The status is pi's on a clean exit, not one the kept detail mentions: that
600
- // detail is the provider's prose, and "proxy said status 503" on a 429 is 429.
601
- const stoppedTurnFailure = (reply, {
602
- code, signal, stderr, credentials,
603
- }) => {
604
- const killed = Boolean(signal) || code === 143 || code === 129;
605
- const cleanExit = code === 0;
606
- const upstream = killed ? null : reply.upstream;
607
- const failure = adapterFailure(
608
- 'pi',
609
- reply.errors.join(' | ')
610
- || (cleanExit ? describeUpstreamRefusal(upstream) : '')
611
- || (killed || cleanExit ? '' : stderr.trim().slice(-600))
612
- || upstream?.detail
613
- || (killed || reply.lastStop !== 'error' ? '' : reply.lastError)
614
- || 'the turn stopped before it finished',
615
- { credentials, exitCode: code, limit: 600 },
616
- );
617
- if (upstream && cleanExit) failure.status = upstream.status;
618
- else if (upstream) failure.status ??= upstream.status;
619
- return failure;
620
- };
621
-
622
- /**
623
- * The first whole NDJSON line pi prints, as a callback. In json mode pi writes
624
- * its session header before it binds the session and sends the prompt, and only
625
- * after every extension factory has returned (main.js builds the runtime, which
626
- * loads and awaits each `-e` extension, then print-mode.js writes the header;
627
- * pi 0.84.1). So the bridge has read the server list and every credential it
628
- * needed by the time this fires, and the model has not been called yet. Any
629
- * line that parses with a string `type` counts: a resumed session's header and
630
- * `agent_start` both come after the extensions too.
631
- */
632
- const firstEventWatcher = (onFirstEvent) => {
633
- let buffer = '';
634
- let fired = !onFirstEvent;
635
- return (chunk) => {
636
- if (fired) return;
637
- buffer += chunk;
638
- let nl;
639
- while (!fired && (nl = buffer.indexOf('\n')) !== -1) {
640
- const raw = buffer.slice(0, nl);
641
- buffer = buffer.slice(nl + 1);
642
- let event;
643
- try { event = JSON.parse(raw); } catch { continue; }
644
- if (event && typeof event.type === 'string') {
645
- fired = true;
646
- buffer = '';
647
- onFirstEvent();
648
- }
649
- }
650
- };
651
- };
652
-
653
- const runPi = ({
654
- command = 'pi', args, cwd, env, payload, timeoutMs, credentials = [], spawnImpl = childSpawn, onFirstEvent,
655
- }) => new Promise((resolve, reject) => {
656
- let stdout = '';
657
- let stderr = '';
658
- let timedOut = false;
659
- let hookError = null;
660
- // fd 3 carries the MCP server list, so the 4th pipe exists exactly when there
661
- // is a list to hand over. The bridge reads it at extension load (takeServers).
662
- const withList = typeof payload === 'string';
663
- const proc = spawnImpl(command, args, { cwd, env, stdio: withList ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'] });
664
- if (withList) {
665
- const channel = proc.stdio && proc.stdio[3];
666
- // A missing pipe is an invariant break, not a degraded mode: the bridge would
667
- // read EBADF and the seat would silently have no commonly_* tools.
668
- if (!channel) { reject(new Error('pi adapter: no fd 3 pipe to carry the MCP server list')); return; }
669
- // The child may exit before draining; that surfaces on 'close', so a write
670
- // error here must not become an unhandled error event.
671
- channel.on('error', () => {});
672
- channel.end(payload);
673
- }
674
- const timer = setTimeout(() => { timedOut = true; proc.kill('SIGTERM'); }, timeoutMs);
675
- const watch = firstEventWatcher(onFirstEvent);
676
- proc.stdout.on('data', (chunk) => {
677
- const text = chunk.toString();
678
- stdout += text;
679
- // Synchronous, in the tick the line is parsed. A hook that throws ends the
680
- // turn: pi is killed and the error is the turn's result.
681
- try {
682
- watch(text);
683
- } catch (err) {
684
- if (!hookError) {
685
- hookError = err;
686
- proc.kill('SIGKILL');
687
- }
688
- }
689
- });
690
- proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
691
- proc.on('error', (err) => { clearTimeout(timer); reject(err); });
692
- proc.on('close', (code, signal) => {
693
- clearTimeout(timer);
694
- if (hookError) return reject(hookError);
695
- if (timedOut) return reject(new Error(`pi timed out after ${timeoutMs}ms`));
696
- const reply = extractReply(stdout);
697
- // A refused model route is NOT an error event: pi retries the ladder itself
698
- // and exits 0 with no assistant text, which is why this read has to happen
699
- // on the stream rather than on the exit code (TASK-096, measured 2026-09-22).
700
- const upstream = readUpstreamRefusal(stdout, { credentials });
701
- if (upstream) reply.upstream = upstream;
702
- // Without `finished`, the text pi printed was an interim message, so posting
703
- // it would answer with half a turn and acknowledge what it held. An error
704
- // event fails the turn even after `finished`, which could otherwise read as
705
- // silent. A tool-only turn that finished stays silent, whatever the exit.
706
- if (code !== 0 && (!reply.finished || reply.errors.length)) {
707
- if (reply.sawAssistant) {
708
- return reject(stoppedTurnFailure(reply, {
709
- code, signal, stderr, credentials,
710
- }));
711
- }
712
- const tail = (reply.errors.join(' | ') || stderr).trim().slice(-600);
713
- return reject(new Error(`pi exited ${code}: ${tail}`));
714
- }
715
- // A refused turn exits 0 too, so here the stream is what says the text was
716
- // interim. Posting it would answer with half a turn (F258). The last stop
717
- // decides, not `reply.upstream`, which outlives a refusal pi recovered from.
718
- // With no text it is a failure too when no status names it (F336): a tool-only
719
- // message, then "fetch failed", is a fault to retry, not a silent turn. But
720
- // that tool may have posted, which only the run loop can see, so the failure
721
- // rides on the reply and the run loop throws it only when nothing was posted.
722
- // A refusal pi kept still resolves plainly, so the run loop names it (TASK-096).
723
- if (code === 0 && !reply.finished && reply.lastStop === 'error' && (reply.sawAssistant || !reply.upstream)) {
724
- const failure = stoppedTurnFailure(reply, {
725
- code, signal, stderr, credentials,
726
- });
727
- if (reply.sawAssistant) return reject(failure);
728
- reply.failure = failure;
729
- }
730
- return resolve(reply);
731
- });
732
- });
733
-
734
- export default {
735
- requiresModel: true,
736
- name: 'pi',
737
-
738
- // TASK-049: the variable this adapter needs from the environment of whatever
739
- // process spawns it. Declared HERE because the adapter owns the provider; the
740
- // registry collects it (adapters/index.js) so the daemon can carry the key
741
- // into its login service and fail loudly at bind when it is absent, instead
742
- // of the seat dying at line ~406 with nothing naming the missing variable at
743
- // daemon level.
744
- // A seat with its own provider.keyFile doesn't need it; spawn doesn't ask.
745
- providerKeyEnv: DEFAULT_PROVIDER.apiKeyEnv,
746
-
747
- supportsProvider: true,
748
- providerApis: Object.keys(PI_PROVIDER_APIS),
749
-
750
- // The rule pi adds to the shared provider check. Its own shape names pi's
751
- // api, which the shared list doesn't hold. A public seat can't take a wire
752
- // the proxy doesn't pin. Anything else goes to the shared check (undefined).
753
- providerVerdict(environment) {
754
- const spec = environment?.provider;
755
- if (!isSharedProvider(spec)) return null;
756
- if (effectiveSandboxTrust(environment?.sandbox?.trust) !== 'public') return undefined;
757
- const api = PI_PROVIDER_APIS[spec.api || 'openai-chat'];
758
- return api && api !== PUBLIC_PROVIDER_API ? publicApiRefusal(api) : undefined;
759
- },
760
-
761
- // Attach runs this before anything is published, so a public pi seat this
762
- // host cannot confine is refused there rather than at its first turn, and so
763
- // is a provider block or key file that couldn't be used.
764
- async validateEnvironment(environment, workspacePath) {
765
- assertSandboxSupported(environment);
766
- return sharedProviderKeyFile(environment, workspacePath);
767
- },
768
-
769
- async detect() {
770
- const res = spawnSync('pi', ['--version'], { encoding: 'utf8' });
771
- if (res.error || res.status !== 0) return null;
772
- const version = String(res.stdout || '').trim().split('\n').pop() || 'unknown';
773
- const which = spawnSync('which', ['pi'], { encoding: 'utf8' });
774
- return { path: String(which.stdout || 'pi').trim() || 'pi', version };
775
- },
776
-
777
- async spawn(prompt, ctx = {}) {
778
- const seatModel = requireSeatModel(ctx.environment, 'pi', true);
779
- const platform = ctx._platform || process.platform;
780
- // A public seat is confined by Seatbelt below; every sandbox declaration pi
781
- // cannot honour on this host is refused here, before anything starts.
782
- assertSandboxSupported(ctx.environment, platform);
783
- const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
784
- const isPublic = effectiveSandboxTrust(sandbox?.trust) === 'public';
785
- const keyFile = await sharedProviderKeyFile(ctx.environment, ctx.cwd, platform);
786
- const provider = resolveProvider(ctx.environment);
787
- const model = seatModel;
788
- const thinking = thinkingFor(ctx.environment?.effort);
789
- const baseEnv = ctx.env || process.env;
790
- // A public turn leaves the key file to the proxy, so this process holds the
791
- // value only for a private pi, which is the one that is handed it.
792
- const ownKey = keyFile && !isPublic ? await readOwnKey(keyFile) : null;
793
- if (!keyFile && !baseEnv[provider.apiKeyEnv]) {
794
- throw new Error(`pi adapter: ${provider.apiKeyEnv} is not set — the seat's environment must carry the provider key (LiteLLM virtual key)`);
795
- }
796
-
797
- // What a public turn runs inside the jail: the absolute pi entry, the
798
- // package tree it loads, and the node its shebang resolves to.
799
- let binary = 'pi';
800
- let packageRoot = null;
801
- let nodePath = null;
802
- if (isPublic) {
803
- if (provider.api !== PUBLIC_PROVIDER_API) {
804
- throw new Error(publicApiRefusal(provider.api));
805
- }
806
- if (!ctx.cwd || !isAbsolute(ctx.cwd)) {
807
- throw new Error('pi adapter: a public seat needs an absolute workspace path');
808
- }
809
- binary = ctx._binaryPath || resolveOnPath('pi', baseEnv.PATH);
810
- if (!binary || !isAbsolute(binary)) {
811
- throw new Error('pi adapter: a public seat needs an absolute pi executable on PATH');
812
- }
813
- packageRoot = piPackageRoot(binary);
814
- if (!packageRoot) {
815
- throw new Error(`pi adapter: no ${PI_PACKAGE} package found behind ${binary}, so the sandbox cannot admit pi's code`);
816
- }
817
- // Async and cached per binary (tested-version.js): a synchronous probe
818
- // stalled every other seat's proxy on the daemon's loop.
819
- const version = ctx._version || await probeVersion(binary, {
820
- env: safeEnv(baseEnv), timeoutMs: 30000, parse: versionOf,
821
- });
822
- if (!TESTED_PI_VERSIONS.includes(version)) {
823
- throw new Error(`pi adapter: public seats refuse untested pi version ${version || 'unknown'}; smoke-tested: ${TESTED_PI_VERSIONS.join(', ')}`);
824
- }
825
- nodePath = resolveOnPath('node', baseEnv.PATH);
826
- }
827
-
828
- // The values this spawn is handed, for the refusal log's exact-match check:
829
- // the provider key the route authenticates with, and the seat credential the
830
- // bridge carries. Snapshot them HERE — `withholdRuntimeCredential` removes
831
- // the token from the child copy below, and after that there is nothing left
832
- // to compare a refusal against.
833
- const credentials = spawnCredentials(ctx, [provider.apiKeyEnv]);
834
- if (ownKey) credentials.push(ownKey);
835
-
836
- // Per-seat pi home: models.json holds the provider by env reference.
837
- const home = seatHome(ctx, { publicSeat: isPublic });
838
- const agentDir = join(home, 'agent');
839
- const sessionDir = join(home, 'sessions');
840
- await mkdir(agentDir, { recursive: true, mode: 0o700 });
841
- await mkdir(sessionDir, { recursive: true, mode: 0o700 });
842
- if (isPublic) {
843
- await mkdir(join(home, 'tmp'), { recursive: true, mode: 0o700 });
844
- // F146: pi 0.84.1 loads these two from PI_CODING_AGENT_DIR with no flag
845
- // to turn that off. The CLI writes only models.json there, so a public
846
- // turn starts without either.
847
- await Promise.all(PI_AGENT_DIR_PROMPT_FILES.map((file) => rm(join(agentDir, file), { force: true })));
848
- }
849
-
850
- // Resume only what pi wrote; anything else (a codex thread id from before
851
- // the switch, a wiped home) starts fresh under a new id.
852
- const isResume = await sessionExists(sessionDir, ctx.sessionId);
853
- const sessionId = isResume ? ctx.sessionId : randomUUID();
854
- // F118, F145: a public seat reads its memory and is offered the save tool
855
- // only on a turn its owner woke, so otherwise its preamble names none.
856
- const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, {
857
- freshSession: !isResume, memoryReadOnly: isPublic && ctx.ownerWoken !== true,
858
- });
859
-
860
- // One credential file per spawn, inside this seat's own 0700 home: the
861
- // narrowest place the bridge can still read. On a public seat that home is
862
- // the jail's state directory, which is why pi gets no read tool there. The
863
- // value the server receives still travels on fd 3 (pi-mcp-client's fd
864
- // channel); the file is where the launcher puts it.
865
- const credential = writeCredentialFile(ctx.runtimeToken, {
866
- agentName: ctx.agentName || 'agent',
867
- root: join(home, 'credentials'),
868
- });
869
- let proxy = null;
870
- try {
871
- const unavailableMcp = [];
872
- const mcpReadDirectories = [];
873
- const grantBrokers = [];
874
- const servers = resolveMcpServers(ctx.environment?.mcp, {
875
- ...ctx,
876
- credentialFile: credential?.path || null,
877
- credentialDir: credential?.dir || null,
878
- publicSeat: isPublic,
879
- unavailableMcp,
880
- mcpReadDirectories,
881
- grantBrokers,
882
- });
883
- if (unavailableMcp.length) throw publicMcpUnavailableError(unavailableMcp);
884
- // Every refusal comes before the proxy opens the key file (F453): nothing
885
- // touches a refused key file, this process included.
886
- if (keyFile && isPublic) {
887
- refuseKeyFileInJail(keyFile, [home, packageRoot, EXTENSION_CODE_ROOT, ...mcpReadDirectories]);
888
- }
889
- // A public pi never holds the provider key: it talks to a loopback proxy
890
- // with a per-turn token, and the proxy, in this process, adds the key and
891
- // pins the model (the same shape as Goose and Qwen).
892
- if (isPublic) {
893
- proxy = await (ctx._startProviderProxy || startProviderProxy)({
894
- provider: { baseURL: provider.baseUrl },
895
- ...(keyFile ? { keyFile } : { keyMaterial: baseEnv[provider.apiKeyEnv] }),
896
- adapterLabel: 'pi',
897
- pinnedModel: model,
898
- });
899
- }
900
- await writeFile(join(agentDir, 'models.json'), `${JSON.stringify(buildModelsJson(
901
- proxy ? { ...provider, baseUrl: proxy.baseURL } : provider, model,
902
- ), null, 2)}\n`, { mode: 0o600 });
903
- const childEnv = isPublic ? {
904
- // A scrubbed environment: nothing of the operator's crosses into the
905
- // jail, and the provider variable holds the proxy's token, not the key.
906
- ...safeEnv(baseEnv),
907
- HOME: home,
908
- TMPDIR: join(home, 'tmp'),
909
- PI_CODING_AGENT_DIR: agentDir,
910
- PI_SKIP_VERSION_CHECK: '1',
911
- PI_OFFLINE: '1',
912
- [provider.apiKeyEnv]: proxy.token,
913
- } : {
914
- ...baseEnv,
915
- PI_CODING_AGENT_DIR: agentDir,
916
- PI_SKIP_VERSION_CHECK: '1',
917
- // A variable NAME, never its value. The always-loaded extension uses
918
- // this to scrub custom provider keys as well as the default LiteLLM key.
919
- COMMONLY_PI_PROVIDER_KEY_ENV: provider.apiKeyEnv,
920
- ...(ownKey ? { [provider.apiKeyEnv]: ownKey } : {}),
921
- };
922
- // This seat brought its own key, so it has no use for ours.
923
- if (ownKey) delete childEnv[DEFAULT_PROVIDER.apiKeyEnv];
924
- // `baseEnv` is normally the process environment, which carries the
925
- // bootstrap export. Nothing below this process needs the value: the bridge
926
- // is handed the servers, their resolved values included, over fd 3. It has
927
- // to come out rather than merely stop being added, because pi's `bash`
928
- // tool spawns children with `{ ...process.env }`, so a copy here is a copy
929
- // in the seat's shell — and the file path goes in, so a hook resolves its
930
- // credential without the value.
931
- withholdRuntimeCredential(childEnv, {
932
- credentialFile: credential?.path || null,
933
- });
934
- const args = buildArgs({
935
- prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
936
- bridge: servers.length ? (ctx._bridgePath || BRIDGE_PATH) : null,
937
- publicSeat: isPublic,
938
- ownerWoken: ctx.ownerWoken,
939
- });
940
- // Shell isolation must not depend on whether this turn declares MCP. A
941
- // public turn has no shell at all, so it loads no shell extension (that
942
- // extension registers a `bash` tool of its own).
943
- if (!isPublic) args.splice(args.length - 1, 0, '-e', SHELL_ENV_PATH);
944
-
945
- // The public jail wraps pi itself. The bridge is an in-process pi
946
- // extension, so every MCP server it starts is a child of the jailed
947
- // process, and a Seatbelt profile is inherited across fork and exec: there
948
- // is no second spawn here, and nothing pi launches runs outside it.
949
- let command = 'pi';
950
- let spawnArgs = args;
951
- if (isPublic) {
952
- [command, ...spawnArgs] = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([binary, ...args], {
953
- workspacePath: ctx.cwd,
954
- workspaceAccess: resolvePublicSandboxMode(sandbox, platform) === 'read-only' ? 'read' : 'write',
955
- executablePath: binary,
956
- executablePaths: nodePath ? [nodePath] : [],
957
- statePath: home,
958
- loopbackNetworkPorts: [proxy.port],
959
- // Public seat: the CLI made this workspace, so it is pinned (F197).
960
- pinWorkspace: true,
961
- readOnlyDirectories: [packageRoot, EXTENSION_CODE_ROOT, ...mcpReadDirectories],
962
- // Defense in depth, not the boundary: the jailed pi holds the seat
963
- // token in memory (the bridge reads it), and pi has no shell or other
964
- // built-in tool to start an inspector with. Its only children are the
965
- // MCP servers the bridge starts. Even so, nothing it starts may
966
- // inspect another process.
967
- processInfo: 'self',
968
- });
969
- }
970
-
971
- const reply = await runPi({
972
- command,
973
- args: spawnArgs,
974
- cwd: ctx.cwd,
975
- env: childEnv,
976
- payload: servers.length ? JSON.stringify(servers) : undefined,
977
- timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
978
- credentials: proxy ? [...credentials, proxy.token] : credentials,
979
- spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
980
- // A public turn keeps its credential file only until pi has loaded its
981
- // extensions: the bridge reads it at load, and nothing on a public turn
982
- // reads it after that (our own server gets the value on its fd 3 pipe,
983
- // and pi has no shell). F175c: on a turn holding a grant broker the
984
- // first layer is inside pi: the bridge deletes the directory before its
985
- // factory returns, which pi awaits before it creates the session
986
- // (removeSpawnCredential), and stops pi if it cannot. This is the second
987
- // layer, at pi's first JSON line (firstEventWatcher), and since F146 it
988
- // runs on every public turn. It is async to pi, so it cannot be the one
989
- // that closes the file before a prompt; on a broker turn it normally
990
- // finds the file gone, and `force` tolerates that. A delete that does
991
- // fail here still ends the turn.
992
- onFirstEvent: isPublic && credential
993
- ? () => {
994
- try {
995
- (ctx._rmSync || rmSync)(credential.dir, { recursive: true, force: true });
996
- } catch (err) {
997
- throw new Error(
998
- `pi adapter: a public pi seat could not delete its runtime credential (${credential.path}) once pi `
999
- + `had loaded its extensions: ${err?.message || err}; the turn was stopped so the credential does not `
1000
- + 'stay readable during it',
1001
- );
1002
- }
1003
- }
1004
- : undefined,
1005
- });
1006
- // Empty text with a clean exit is a silent turn; the run loop treats it
1007
- // as NO_REPLY-shaped and re-delivers on its own rules. Empty text that
1008
- // came with a refusal is named instead — see upstream-refusal.js.
1009
- return {
1010
- text: reply.text,
1011
- newSessionId: sessionId,
1012
- // F404: the F392 ceiling and cold rule read this; null keeps resuming.
1013
- contextTokens: reply.contextTokens ?? null,
1014
- upstream: reply.upstream ?? null,
1015
- ...(reply.failure ? { failure: reply.failure } : {}),
1016
- };
1017
- } finally {
1018
- // Best effort: the bridge has already read what it needs by the time the
1019
- // turn ends, and a token file that outlives its turn is a token file that
1020
- // sits in a home for the next one.
1021
- removeCredentialFile(credential);
1022
- try { await proxy?.close(); } catch { /* best effort */ }
1023
- }
1024
- },
1025
- };