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