@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.
- package/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
package/src/lib/adapters/pi.js
DELETED
|
@@ -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
|
-
};
|