@ravefox/space 0.2.1 → 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 -205
- package/README.md +80 -31
- 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 +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/daemon-watch.js
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// The live view behind `commonly daemon status --watch`: the machine, then one
|
|
2
|
-
// block per supervised agent with its state, last turn, last error and the tail
|
|
3
|
-
// of its log. Rendering is a pure function of its inputs so the layout can be
|
|
4
|
-
// tested at fixed widths; the redraw loop lives in the command.
|
|
5
|
-
|
|
6
|
-
import { seatKey } from './daemon-agent-states.js';
|
|
7
|
-
|
|
8
|
-
export const WATCH_INTERVAL_MS = 2000;
|
|
9
|
-
|
|
10
|
-
const ANSI = {
|
|
11
|
-
dim: '\x1b[2m',
|
|
12
|
-
bold: '\x1b[1m',
|
|
13
|
-
reset: '\x1b[0m',
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// A redraw clears the screen and returns the cursor home. Plain ANSI, no
|
|
17
|
-
// dependency; the caller decides whether to use it at all.
|
|
18
|
-
export const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
19
|
-
|
|
20
|
-
// The view is for a person watching a terminal. Anything else gets one print.
|
|
21
|
-
export const shouldRedraw = ({ isTTY, env = process.env } = {}) => (
|
|
22
|
-
Boolean(isTTY) && !(env && env.NO_COLOR !== undefined && env.NO_COLOR !== '')
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
const relative = (iso, now) => {
|
|
26
|
-
if (!iso) return 'never';
|
|
27
|
-
const ms = now - new Date(iso).getTime();
|
|
28
|
-
if (!Number.isFinite(ms)) return 'unknown';
|
|
29
|
-
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
|
|
30
|
-
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
|
|
31
|
-
return `${Math.round(ms / 3_600_000)}h ago`;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// Terminal control sequences in a log or state field would reach the operator's
|
|
35
|
-
// terminal: a line could clear the watch, set the title, or write the clipboard.
|
|
36
|
-
// Strip OSC and CSI sequences, bare escapes, and C0/C1 controls before anything
|
|
37
|
-
// else. Tabs become spaces so the column count stays honest.
|
|
38
|
-
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
39
|
-
const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
|
|
40
|
-
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
41
|
-
const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
42
|
-
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
43
|
-
const ESC = /\x1b[@-Z\\-_]/g;
|
|
44
|
-
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
45
|
-
const CONTROL = /[\x00-\x08\x0a-\x1f\x7f-\x9f]/g;
|
|
46
|
-
export const stripControls = (text) => String(text ?? '')
|
|
47
|
-
.replace(OSC, '').replace(CSI, '').replace(ESC, '').replace(CONTROL, '').replace(/\t/g, ' ');
|
|
48
|
-
|
|
49
|
-
// Credential shapes the CLI knows, plus the forms that carry a credential in a
|
|
50
|
-
// log without a known prefix: `Authorization: Basic …`, and a quoted or bare
|
|
51
|
-
// `token`/`password`/`secret`/`api_key` field. Generic key/value redaction can
|
|
52
|
-
// over-match, which is the cheaper error for a watch that prints logs.
|
|
53
|
-
const CREDENTIAL = [
|
|
54
|
-
[/cm_(?:daemon|agent|user)_[A-Za-z0-9_-]+/g, '[redacted]'],
|
|
55
|
-
[/\bBearer\s+\S+/gi, '[redacted]'],
|
|
56
|
-
[/\bBasic\s+[A-Za-z0-9+/=._-]+/g, '[redacted]'],
|
|
57
|
-
[/(\bauthorization\s*[:=]\s*)(?:(?:basic|bearer|digest|token)\s+)?\S+/gi, '$1[redacted]'],
|
|
58
|
-
[/(["']?\b(?:token|password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\b["']?\s*[:=]\s*)(["']?)[^\s"',}&]+\2/gi, '$1$2[redacted]$2'],
|
|
59
|
-
];
|
|
60
|
-
const redact = (text) => CREDENTIAL.reduce((value, [shape, replacement]) => value.replace(shape, replacement), text);
|
|
61
|
-
|
|
62
|
-
// Every field the watch prints goes through here: control sequences out,
|
|
63
|
-
// credentials redacted, then clipped to the width.
|
|
64
|
-
const clip = (text, width) => {
|
|
65
|
-
const value = redact(stripControls(text));
|
|
66
|
-
return value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}…`;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
// A log line can carry a credential or a whole prompt; the same boundary covers it.
|
|
70
|
-
export const safeLogLine = (line, width) => clip(line, width);
|
|
71
|
-
|
|
72
|
-
// The state is the server's verdict, read by daemon-agent-states. `states` is
|
|
73
|
-
// that Map. Without it (no login, or the read failed) the seat shows its local
|
|
74
|
-
// process state, labelled `local`, and never a server word. An agent the verdict
|
|
75
|
-
// does not list has no installation on its pod.
|
|
76
|
-
export const agentStateLabel = (seat = {}, states = null) => {
|
|
77
|
-
if (!states) return `local ${seat.state || 'unknown'}`;
|
|
78
|
-
return states.get(seatKey(seat.agentName, seat.instanceId)) || 'not registered';
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const agentBlock = (seat, { now, width, color, logTail, states }) => {
|
|
82
|
-
const dim = (text) => (color ? `${ANSI.dim}${text}${ANSI.reset}` : text);
|
|
83
|
-
const bold = (text) => (color ? `${ANSI.bold}${text}${ANSI.reset}` : text);
|
|
84
|
-
const lines = [
|
|
85
|
-
`${bold(clip(seat.recordKey || seat.agentName || 'unknown', width))} ${agentStateLabel(seat, states)} last turn ${relative(seat.lastTurnAt, now)}`,
|
|
86
|
-
];
|
|
87
|
-
if (seat.lastError) lines.push(clip(` error: ${seat.lastError}`, width));
|
|
88
|
-
for (const line of logTail || []) lines.push(dim(` ${safeLogLine(line, Math.max(10, width - 2))}`));
|
|
89
|
-
return lines;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
// Inputs: `machine` { name, status, lastSeenAt }, `seats` (from the daemon state
|
|
93
|
-
// file), `logTails` { [recordKey || agentName]: string[] }, `states` (Map from
|
|
94
|
-
// loadReachByAgent, or null), `stateNote` (why states is null), `now` (ms),
|
|
95
|
-
// `width` (columns).
|
|
96
|
-
export const renderDaemonWatch = ({
|
|
97
|
-
machine = {}, seats = [], logTails = {}, states = null, stateNote = '', now = Date.now(), width = 80, color = false,
|
|
98
|
-
} = {}) => {
|
|
99
|
-
const bold = (text) => (color ? `${ANSI.bold}${text}${ANSI.reset}` : text);
|
|
100
|
-
const out = [
|
|
101
|
-
bold(clip(`${machine.name || 'machine'}: ${machine.status || 'unknown'}`, width)),
|
|
102
|
-
clip(`last heartbeat ${relative(machine.lastSeenAt, now)}`, width),
|
|
103
|
-
];
|
|
104
|
-
if (stateNote) out.push(clip(stateNote, width));
|
|
105
|
-
out.push('');
|
|
106
|
-
if (!seats.length) {
|
|
107
|
-
out.push('No supervised agents on this machine.');
|
|
108
|
-
return out.join('\n');
|
|
109
|
-
}
|
|
110
|
-
for (const seat of seats) {
|
|
111
|
-
out.push(...agentBlock(seat, { now, width, color, logTail: logTails[seat.recordKey || seat.agentName] || [], states }));
|
|
112
|
-
out.push('');
|
|
113
|
-
}
|
|
114
|
-
return out.join('\n').replace(/\n+$/, '');
|
|
115
|
-
};
|
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Guard server-declared `environment.mcp` before the daemon adopts it.
|
|
3
|
-
*
|
|
4
|
-
* The daemon projects `AgentInstallation.config.environment` onto the OWNER's
|
|
5
|
-
* machine: every declared stdio server is spawned as the operator, and every
|
|
6
|
-
* declared http server is handed the seat token wherever the declaration puts
|
|
7
|
-
* the `${COMMONLY_AGENT_TOKEN}` placeholder. The registry PATCH that writes
|
|
8
|
-
* that declaration was pod-member gated (Vera, Connectors 69500, 2026-09-18),
|
|
9
|
-
* so a plain member could run a command on the owner's laptop or ship the
|
|
10
|
-
* token to a host they control. The server fix is owner/admin gating; this is
|
|
11
|
-
* the daemon's own layer, which must hold even if the server is wrong again.
|
|
12
|
-
*
|
|
13
|
-
* Two rules, both fail-closed:
|
|
14
|
-
* stdio — the ENTRY must be the shipped commonly MCP server — command
|
|
15
|
-
* `npx -y @commonlyai/mcp@<tag>`, env limited to the two canonical
|
|
16
|
-
* placeholders, no args/cwd — or equal, as a whole entry, to one
|
|
17
|
-
* the operator already installed by hand in the local token record.
|
|
18
|
-
* Command alone is not enough: the shipped command with
|
|
19
|
-
* `NODE_OPTIONS=--import=data:…` executes code, with
|
|
20
|
-
* `npm_config_registry` fetches the package from an attacker, and
|
|
21
|
-
* with a literal `COMMONLY_API_URL=https://attacker…` posts the
|
|
22
|
-
* token there (sprint-review, Sharpen 69526/69534).
|
|
23
|
-
* http — the ENTRY, `name` aside, must BE the one http server the instance
|
|
24
|
-
* ships: the grant broker (`grantBrokerServer`,
|
|
25
|
-
* backend/services/grantBrokerProjectionService.ts). `transport`
|
|
26
|
-
* exactly `http`; the url, with ONLY the two instance placeholders
|
|
27
|
-
* resolved and nothing else expanded, of our own origin at
|
|
28
|
-
* `/api/mcp/grants/<id>`, with no query, hash or userinfo; headers
|
|
29
|
-
* exactly the single Bearer token placeholder; and no other field.
|
|
30
|
-
* `sse` is not admitted at all: nothing ships it, and its GET is the
|
|
31
|
-
* method every 3xx route in this repo answers. Pinning the ORIGIN
|
|
32
|
-
* alone was not enough — a same-origin url whose path is a GET route
|
|
33
|
-
* that redirects (`/api/auth/oauth/*`, both Slack and Discord
|
|
34
|
-
* callbacks) passed the origin check and let the client leave the
|
|
35
|
-
* instance on the hop, and an added header would have travelled with
|
|
36
|
-
* it (TASK-150). Matched exactly rather than folded: the set this
|
|
37
|
-
* guard ADMITS has to equal the set that is TESTED for the absence of
|
|
38
|
-
* a redirect, and folding case or collapsing slashes widens what is
|
|
39
|
-
* admitted while narrowing what is withheld.
|
|
40
|
-
* The rule is origin-based, not placeholder-based, because the claude CLI
|
|
41
|
-
* expands `${VAR}` and `${VAR:-default}` in url and headers from its own
|
|
42
|
-
* environment: `?t=${COMMONLY_AGENT_TOKEN:-}` is not the literal placeholder
|
|
43
|
-
* and still becomes the token, and outside the public sandbox that
|
|
44
|
-
* environment is the operator's, so `?k=${GITHUB_TOKEN}` leaks too (Vera,
|
|
45
|
-
* Connectors 69519). So any `${` other than the three known placeholders,
|
|
46
|
-
* anywhere in an entry — url, headers, command, env — is refused outright.
|
|
47
|
-
*
|
|
48
|
-
* The guarantee is instance-specific: a self-hosted proxy that redirects
|
|
49
|
-
* `/api/mcp/grants/…` breaks it in a way no test in this repo can see.
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
const URL_PLACEHOLDERS = ['${COMMONLY_API_URL}', '${COMMONLY_INSTANCE_URL}'];
|
|
53
|
-
const KNOWN_PLACEHOLDERS = [...URL_PLACEHOLDERS, '${COMMONLY_AGENT_TOKEN}'];
|
|
54
|
-
const SHIPPED_PACKAGE = /^@commonlyai\/mcp(@[A-Za-z0-9._-]+)?$/;
|
|
55
|
-
|
|
56
|
-
export const isShippedCommonlyMcpCommand = (command) => (
|
|
57
|
-
Array.isArray(command)
|
|
58
|
-
&& command.length === 3
|
|
59
|
-
&& command[0] === 'npx'
|
|
60
|
-
&& command[1] === '-y'
|
|
61
|
-
&& typeof command[2] === 'string'
|
|
62
|
-
&& SHIPPED_PACKAGE.test(command[2])
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const CANONICAL_STDIO_ENV = {
|
|
66
|
-
COMMONLY_API_URL: '${COMMONLY_API_URL}',
|
|
67
|
-
COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}',
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// The execution-relevant shape of a stdio entry: everything that decides what
|
|
71
|
-
// runs and with what. `name` and `transport` are identity, not execution.
|
|
72
|
-
const executionShape = (server) => JSON.stringify({
|
|
73
|
-
command: Array.isArray(server.command) ? server.command : null,
|
|
74
|
-
args: Array.isArray(server.args) && server.args.length ? server.args : null,
|
|
75
|
-
cwd: typeof server.cwd === 'string' ? server.cwd : null,
|
|
76
|
-
env: server.env && typeof server.env === 'object'
|
|
77
|
-
? Object.fromEntries(Object.entries(server.env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
78
|
-
: null,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// The shipped server exactly: its command, only its own env keys at their
|
|
82
|
-
// canonical placeholder values, nothing else that changes what executes.
|
|
83
|
-
export const isShippedCommonlyMcpEntry = (server) => {
|
|
84
|
-
if (!server || typeof server !== 'object') return false;
|
|
85
|
-
if (!isShippedCommonlyMcpCommand(server.command)) return false;
|
|
86
|
-
if (Array.isArray(server.args) && server.args.length) return false;
|
|
87
|
-
if (server.cwd !== undefined) return false;
|
|
88
|
-
const env = server.env && typeof server.env === 'object' ? server.env : {};
|
|
89
|
-
return Object.entries(env).every(([key, value]) => CANONICAL_STDIO_ENV[key] === value);
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
// True when a string still contains `${` after the known placeholders are
|
|
93
|
-
// removed — a `${VAR}`, `${VAR:-default}` or any other expansion the CLI
|
|
94
|
-
// would resolve from an environment this declaration does not own.
|
|
95
|
-
// A field the guard cannot READ is not a field it can judge. `env` and
|
|
96
|
-
// `headers` are spread by every reader (`{ ...server.env }`), so a STRING there
|
|
97
|
-
// becomes single-character keys and the declaration is silently dropped rather
|
|
98
|
-
// than honoured — the next consumer that parses the string instead would honour
|
|
99
|
-
// it, and this guard would have admitted the entry on the strength of its other
|
|
100
|
-
// fields (TASK-069: judge the whole entry, not selected fields). `args` is read
|
|
101
|
-
// only through `Array.isArray`, and `cwd` through `typeof === 'string'` in
|
|
102
|
-
// `executionShape`, so a value of the wrong type there is dropped the same way —
|
|
103
|
-
// and for `cwd` that drop is load-bearing: the normalised shape then equals an
|
|
104
|
-
// installed entry that declares NO cwd, so the entry is admitted as one the
|
|
105
|
-
// operator already installed (Vera, hold on #1915).
|
|
106
|
-
// Fail closed: an unreadable shape is a refusal, never an absence.
|
|
107
|
-
// The one http entry the server ships: url and headers as `grantBrokerServer`
|
|
108
|
-
// writes them (`GRANT_BROKER_URL` / `GRANT_BROKER_AUTHORIZATION`).
|
|
109
|
-
const GRANT_BROKER_PATH = /^\/api\/mcp\/grants\/[A-Za-z0-9_-]+$/;
|
|
110
|
-
const GRANT_BROKER_AUTHORIZATION = 'Bearer ${COMMONLY_AGENT_TOKEN}';
|
|
111
|
-
|
|
112
|
-
const sameOriginAsInstance = (url, instanceUrl) => {
|
|
113
|
-
const origin = originOf(url, instanceUrl);
|
|
114
|
-
if (!origin) return false;
|
|
115
|
-
try {
|
|
116
|
-
return origin === new URL(instanceUrl).origin;
|
|
117
|
-
} catch {
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
// The url is judged by what a client would carry, not by the substring the
|
|
123
|
-
// origin check happens to read: the path has to be the grants path with ONE id
|
|
124
|
-
// segment, and a query, a hash or userinfo is a field the origin comparison
|
|
125
|
-
// does not look at but the request does carry.
|
|
126
|
-
const grantBrokerUrl = (url, instanceUrl) => {
|
|
127
|
-
if (!sameOriginAsInstance(url, instanceUrl)) return false;
|
|
128
|
-
let expanded = String(url);
|
|
129
|
-
for (const placeholder of URL_PLACEHOLDERS) expanded = expanded.split(placeholder).join(instanceUrl);
|
|
130
|
-
let parsed;
|
|
131
|
-
try {
|
|
132
|
-
parsed = new URL(expanded);
|
|
133
|
-
} catch {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
if (parsed.username || parsed.password || parsed.search || parsed.hash) return false;
|
|
137
|
-
return GRANT_BROKER_PATH.test(parsed.pathname);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* The shipped grant broker, matched as a WHOLE entry (the http twin of
|
|
142
|
-
* `isShippedCommonlyMcpEntry`): `name` is identity — the projection suffixes it
|
|
143
|
-
* when one seat holds several grants — and every other field has to equal what
|
|
144
|
-
* `grantBrokerServer` writes, with no field added, because a field the client
|
|
145
|
-
* reads and this guard does not is exactly the shape the whole-entry rule
|
|
146
|
-
* exists to refuse (TASK-069).
|
|
147
|
-
*/
|
|
148
|
-
export const isShippedGrantBrokerEntry = (server, instanceUrl) => {
|
|
149
|
-
if (!server || typeof server !== 'object') return false;
|
|
150
|
-
if (server.transport !== 'http') return false;
|
|
151
|
-
const keys = Object.keys(server).filter((key) => key !== 'name').sort();
|
|
152
|
-
if (keys.join(',') !== 'headers,transport,url') return false;
|
|
153
|
-
const headers = server.headers;
|
|
154
|
-
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return false;
|
|
155
|
-
const headerKeys = Object.keys(headers);
|
|
156
|
-
if (headerKeys.length !== 1 || headerKeys[0] !== 'Authorization') return false;
|
|
157
|
-
if (headers.Authorization !== GRANT_BROKER_AUTHORIZATION) return false;
|
|
158
|
-
return grantBrokerUrl(server.url, instanceUrl);
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
const unreadableField = (server) => {
|
|
162
|
-
for (const key of ['env', 'headers']) {
|
|
163
|
-
const value = server[key];
|
|
164
|
-
if (value === undefined || value === null) continue;
|
|
165
|
-
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
166
|
-
return `${key} is of type ${typeof value}, not an object`;
|
|
167
|
-
}
|
|
168
|
-
const badValue = Object.entries(value).find(([, v]) => typeof v !== 'string');
|
|
169
|
-
if (badValue) return `${key}.${badValue[0]} is not a string`;
|
|
170
|
-
}
|
|
171
|
-
if (server.cwd !== undefined && server.cwd !== null && typeof server.cwd !== 'string') {
|
|
172
|
-
return `cwd is of type ${typeof server.cwd}, not a string`;
|
|
173
|
-
}
|
|
174
|
-
if (server.args === undefined || server.args === null) return null;
|
|
175
|
-
if (!Array.isArray(server.args)) return `args is of type ${typeof server.args}, not an array`;
|
|
176
|
-
if (server.args.some((a) => typeof a !== 'string')) return 'args carries a non-string';
|
|
177
|
-
return null;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const hasForeignExpansion = (value) => {
|
|
181
|
-
if (typeof value !== 'string') return false;
|
|
182
|
-
let rest = value;
|
|
183
|
-
for (const placeholder of KNOWN_PLACEHOLDERS) rest = rest.split(placeholder).join('');
|
|
184
|
-
return rest.includes('${');
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
const stringsOf = (server) => {
|
|
188
|
-
const out = [];
|
|
189
|
-
if (typeof server.url === 'string') out.push(server.url);
|
|
190
|
-
for (const bag of [server.headers, server.env]) {
|
|
191
|
-
if (bag && typeof bag === 'object') out.push(...Object.values(bag));
|
|
192
|
-
}
|
|
193
|
-
if (Array.isArray(server.command)) out.push(...server.command);
|
|
194
|
-
if (Array.isArray(server.args)) out.push(...server.args);
|
|
195
|
-
return out.filter((v) => typeof v === 'string');
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const originOf = (url, instanceUrl) => {
|
|
199
|
-
let expanded = String(url);
|
|
200
|
-
for (const placeholder of URL_PLACEHOLDERS) expanded = expanded.split(placeholder).join(instanceUrl);
|
|
201
|
-
try {
|
|
202
|
-
return new URL(expanded).origin;
|
|
203
|
-
} catch {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* @returns {{ ok: boolean, refusals: string[] }} — one refusal line per
|
|
210
|
-
* offending server, naming it, so the daemon log says exactly what was kept
|
|
211
|
-
* off the machine.
|
|
212
|
-
*/
|
|
213
|
-
export const auditDeclaredMcp = (environment, { instanceUrl, allowedStdioEntries = [] } = {}) => {
|
|
214
|
-
const refusals = [];
|
|
215
|
-
const servers = environment && typeof environment === 'object' && Array.isArray(environment.mcp)
|
|
216
|
-
? environment.mcp : [];
|
|
217
|
-
let instanceOrigin = null;
|
|
218
|
-
try {
|
|
219
|
-
instanceOrigin = new URL(instanceUrl).origin;
|
|
220
|
-
} catch {
|
|
221
|
-
instanceOrigin = null;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
servers.forEach((server, index) => {
|
|
225
|
-
if (!server || typeof server !== 'object') {
|
|
226
|
-
refusals.push(`mcp[${index}] is not an object`);
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
const name = typeof server.name === 'string' && server.name ? server.name : `mcp[${index}]`;
|
|
230
|
-
const transport = server.transport || 'stdio';
|
|
231
|
-
const unreadable = unreadableField(server);
|
|
232
|
-
if (unreadable) {
|
|
233
|
-
refusals.push(`'${name}': declares ${unreadable}, so this guard cannot judge what the entry would run; refusing it rather than reading the field as absent`);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
// One shape per entry. The adapters classified by which field was present,
|
|
237
|
-
// so `{transport:'http', url:<instance>, command:['sh','-c',…]}` passed an
|
|
238
|
-
// origin check here and ran as stdio there with the token substituted
|
|
239
|
-
// (#1764 fixes the adapters; the guard refuses the shape outright).
|
|
240
|
-
if (server.command !== undefined && server.url !== undefined) {
|
|
241
|
-
refusals.push(`'${name}': declares both a command and a url; one entry is one transport`);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
if (transport === 'stdio' && server.url !== undefined) {
|
|
245
|
-
refusals.push(`'${name}': stdio entry carries a url`);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
if ((transport === 'http' || transport === 'sse') && server.command !== undefined) {
|
|
249
|
-
refusals.push(`'${name}': ${transport} entry carries a command`);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const foreign = stringsOf(server).find(hasForeignExpansion);
|
|
253
|
-
if (foreign !== undefined) {
|
|
254
|
-
refusals.push(`'${name}': ${JSON.stringify(foreign)} contains an expansion other than the instance placeholders; the CLI would resolve it from this machine's environment`);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (transport === 'stdio') {
|
|
258
|
-
if (isShippedCommonlyMcpEntry(server)) return;
|
|
259
|
-
const shape = executionShape(server);
|
|
260
|
-
if (allowedStdioEntries.some((allowed) => executionShape(allowed) === shape)) return;
|
|
261
|
-
const why = isShippedCommonlyMcpCommand(server.command)
|
|
262
|
-
? `carries env/args/cwd beyond the shipped server's own (${Object.keys(server.env || {}).filter((k) => CANONICAL_STDIO_ENV[k] !== server.env[k]).join(', ') || 'args/cwd'})`
|
|
263
|
-
: `command ${JSON.stringify(server.command)} is not the shipped commonly MCP server`;
|
|
264
|
-
refusals.push(`'${name}': declared stdio entry ${why}, and no entry installed on this machine matches it as a whole`);
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
if (transport === 'http' || transport === 'sse') {
|
|
268
|
-
if (transport === 'http' && isShippedGrantBrokerEntry(server, instanceUrl)) return;
|
|
269
|
-
refusals.push(`'${name}': declared ${transport} entry ${JSON.stringify(server.url)} is not the one http server this instance ships; a declaration is admitted only as the grant broker (transport 'http', url ${instanceOrigin || instanceUrl}/api/mcp/grants/<id> with no query or fragment, headers exactly {'Authorization': 'Bearer \${COMMONLY_AGENT_TOKEN}'}, and no other field), and 'sse' is not admitted at all`);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
refusals.push(`'${name}': unknown transport ${JSON.stringify(transport)}`);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
return { ok: refusals.length === 0, refusals };
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
/** The stdio entries an operator has already placed in a local token record. */
|
|
279
|
-
export const installedStdioEntries = (record) => (
|
|
280
|
-
Array.isArray(record?.environment?.mcp)
|
|
281
|
-
? record.environment.mcp
|
|
282
|
-
.filter((s) => s && typeof s === 'object' && (s.transport || 'stdio') === 'stdio' && Array.isArray(s.command))
|
|
283
|
-
: []
|
|
284
|
-
);
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The commonly MCP server every wrapper seat gets by default (ADR-008 `mcp[]`).
|
|
3
|
-
*
|
|
4
|
-
* One declaration, three consumers: `agent attach`, the COMMONLY_AGENT_TOKEN
|
|
5
|
-
* bootstrap in `agent run`, and the daemon's per-seat token provisioning. The
|
|
6
|
-
* third consumer was missing until 2026-09-18 — a seat installed server-side
|
|
7
|
-
* (nobody ever ran `agent attach` on that host) got a token record carrying
|
|
8
|
-
* runtime model/effort and no `mcp[]` at all, so the CLI it spawned had no
|
|
9
|
-
* `commonly_*` tools and could not post; the operator hand-added the entry to
|
|
10
|
-
* get the seat working (C4 run, TASK-048).
|
|
11
|
-
*
|
|
12
|
-
* Only adapters with a real `mcp[]` consumption path get a default:
|
|
13
|
-
* claude — `--mcp-config` (adapters/claude.js)
|
|
14
|
-
* codex — `-c mcp_servers.*` overrides (adapters/codex.js; added after the
|
|
15
|
-
* 2026-07-22 as-operator attribution incident, where an MCP-less
|
|
16
|
-
* codex agent posted through the operator's own CLI profile because
|
|
17
|
-
* it had no commonly_* tool of its own)
|
|
18
|
-
* pi — stdio servers from the environment spec (adapters/pi.js)
|
|
19
|
-
* opencode — per-spawn config file with local/remote MCP entries (adapters/opencode.js)
|
|
20
|
-
* qwen — per-spawn --mcp-config with file-backed stdio credentials (adapters/qwen.js)
|
|
21
|
-
* dsh — per-spawn --patch with file-backed stdio credentials (adapters/dsh.js)
|
|
22
|
-
* goose — per-spawn --with-extension with file-backed stdio credentials (adapters/goose.js)
|
|
23
|
-
* `stub` has no consumption path and must keep being handed `environment: null`.
|
|
24
|
-
*
|
|
25
|
-
* The placeholders are substituted at spawn time by the adapter, so the
|
|
26
|
-
* declaration itself carries no secret and is safe to persist to a token file.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { normalizeSandboxTrust } from './environment.js';
|
|
30
|
-
|
|
31
|
-
export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'opencode', 'qwen', 'dsh', 'goose']);
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* The adapters that take the default sandbox — a strict subset of the ones that
|
|
35
|
-
* consume `mcp[]`, and of the ones that enforce a sandbox (below).
|
|
36
|
-
*
|
|
37
|
-
* Goose and pi enforce an explicitly declared public sandbox but are not handed
|
|
38
|
-
* this default. Goose: a default cannot supply its required provider and model.
|
|
39
|
-
* pi (F113b): a public pi turn has no shell and no file tools, the same as every
|
|
40
|
-
* public adapter, and the daemon derives pi's environment for the private fleet
|
|
41
|
-
* seats that write code with exactly those tools. A derived public default would
|
|
42
|
-
* strip them from every one of those seats. A pi seat that takes instructions
|
|
43
|
-
* from strangers declares `sandbox.trust: 'public'` and is confined then.
|
|
44
|
-
*/
|
|
45
|
-
export const ADAPTERS_WITH_DEFAULT_SANDBOX = new Set(['claude', 'codex', 'opencode', 'qwen']);
|
|
46
|
-
|
|
47
|
-
// Adapters that confine a public turn themselves. This is wider than the set
|
|
48
|
-
// above: Goose enforces Seatbelt/bwrap and pi enforces Seatbelt (macOS only;
|
|
49
|
-
// adapters/pi.js), and neither is handed the default block (see above). The
|
|
50
|
-
// attach gate (agent.js resolveAttachSandbox) reads this set, not the default
|
|
51
|
-
// one, or an explicitly configured public Goose or pi seat is refused before its
|
|
52
|
-
// adapter runs (D247 walk, 11-04; F113b).
|
|
53
|
-
export const ADAPTERS_WITH_SANDBOX_ENFORCEMENT = new Set([...ADAPTERS_WITH_DEFAULT_SANDBOX, 'goose', 'pi']);
|
|
54
|
-
|
|
55
|
-
// Adapters that may receive a room grant: each confines a public seat AND keeps
|
|
56
|
-
// the per-spawn credential out of its own tools' reach (OpenCode: F175a, its
|
|
57
|
-
// external_directory deny with the credential outside --dir; pi: F175c, a
|
|
58
|
-
// public turn with no built-in tool, and the credential file deleted once pi
|
|
59
|
-
// has loaded its extensions; Qwen and Goose: F175d, a public turn that offers no
|
|
60
|
-
// file or shell tool, with the broker behind the stdio bridge so the token
|
|
61
|
-
// stays in one file). Narrower than the sandbox sets above on purpose, and
|
|
62
|
-
// mirrored by the server's CONFINING_ADAPTERS
|
|
63
|
-
// (backend/services/grantBrokerConfinement.ts).
|
|
64
|
-
export const ADAPTERS_WITH_GRANT_BROKER = new Set(['claude', 'codex', 'opencode', 'pi', 'qwen', 'goose']);
|
|
65
|
-
|
|
66
|
-
// The modes a jail enforces, by host: what an adapter's spawn turns into
|
|
67
|
-
// Seatbelt on macOS and bubblewrap on Linux.
|
|
68
|
-
const SEATBELT_MODES = new Set(['workspace', 'read-only']);
|
|
69
|
-
const BWRAP_MODES = new Set(['bwrap']);
|
|
70
|
-
|
|
71
|
-
// F175c, F175d: adapters in the set above that hold a grant ONLY on a confined
|
|
72
|
-
// public seat, in the modes each host's jail enforces; a host with no entry has
|
|
73
|
-
// no jail for it. Each runs a private seat with no sandbox at all, so a private
|
|
74
|
-
// seat is never confined. pi confines in macOS Seatbelt alone
|
|
75
|
-
// (adapters/pi.js assertSandboxSupported). Naming the agent on the grant (F94)
|
|
76
|
-
// does not lift this, unlike the sandbox refusals of Claude, Codex and OpenCode.
|
|
77
|
-
// Mirrored by the server's PUBLIC_ONLY_GRANT_ADAPTERS.
|
|
78
|
-
export const ADAPTERS_WITH_PUBLIC_ONLY_GRANT = new Map([
|
|
79
|
-
['pi', Object.freeze({ darwin: SEATBELT_MODES })],
|
|
80
|
-
['qwen', Object.freeze({ darwin: SEATBELT_MODES, linux: BWRAP_MODES })],
|
|
81
|
-
['goose', Object.freeze({ darwin: SEATBELT_MODES, linux: BWRAP_MODES })],
|
|
82
|
-
]);
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The sandbox an unconfigured seat gets.
|
|
86
|
-
*
|
|
87
|
-
* `sandbox.mode` defaults to `'none'` in the adapters, so an ABSENT sandbox
|
|
88
|
-
* block means NO sandbox — the seat runs unconfined on the operator's machine
|
|
89
|
-
* while taking instructions from whoever is in the room. That is what a
|
|
90
|
-
* self-serve install shipped (C4-6 / TASK-052): the install declares no
|
|
91
|
-
* environment, the daemon projected nothing, and the socket was born with no
|
|
92
|
-
* confinement and no way to notice.
|
|
93
|
-
*
|
|
94
|
-
* `trust: 'public'` is the conservative declaration — "this seat takes
|
|
95
|
-
* instructions from people I do not control" — and it is the one that engages
|
|
96
|
-
* the real sandbox in the adapters.
|
|
97
|
-
*
|
|
98
|
-
* NO MODE is stored, deliberately. The record is platform-independent and the
|
|
99
|
-
* host is not: the adapters read a public trust with no mode as Seatbelt
|
|
100
|
-
* workspace on macOS and bwrap elsewhere. `mode: 'workspace'` in the row is
|
|
101
|
-
* refused on Linux, and `mode: 'bwrap'` is meaningless on macOS, so either one
|
|
102
|
-
* moves a host fact into the database and breaks the day the seat is re-homed.
|
|
103
|
-
* An explicit mode in a record still wins over the derived one.
|
|
104
|
-
*
|
|
105
|
-
* Claude, codex, opencode and qwen receive this default block
|
|
106
|
-
* (ADAPTERS_WITH_DEFAULT_SANDBOX). Goose and pi support explicitly configured
|
|
107
|
-
* public confinement, but are not defaulted into it (see the set's comment).
|
|
108
|
-
* Grant-broker eligibility is its own list (ADAPTERS_WITH_GRANT_BROKER).
|
|
109
|
-
*/
|
|
110
|
-
export const COMMONLY_DEFAULT_SANDBOX = Object.freeze({ trust: 'public' });
|
|
111
|
-
|
|
112
|
-
export const defaultSeatSandbox = () => ({ ...COMMONLY_DEFAULT_SANDBOX });
|
|
113
|
-
|
|
114
|
-
export const COMMONLY_MCP_SERVER_NAME = 'commonly';
|
|
115
|
-
|
|
116
|
-
export const commonlyMcpServer = () => ({
|
|
117
|
-
name: COMMONLY_MCP_SERVER_NAME,
|
|
118
|
-
transport: 'stdio',
|
|
119
|
-
command: ['npx', '-y', '@commonlyai/mcp@latest'],
|
|
120
|
-
env: {
|
|
121
|
-
COMMONLY_API_URL: '${COMMONLY_API_URL}',
|
|
122
|
-
COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}',
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
/** The default `mcp[]` for an adapter, or [] when it has no consumption path. */
|
|
127
|
-
export const defaultMcpServers = (adapterName) => (
|
|
128
|
-
ADAPTERS_WITH_DEFAULT_MCP.has(adapterName) ? [commonlyMcpServer()] : []
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Ensure `environment` declares the commonly MCP server, touching nothing that
|
|
133
|
-
* is already declared.
|
|
134
|
-
*
|
|
135
|
-
* The predicate is "is there an mcp entry named commonly?", not "is mcp[]
|
|
136
|
-
* absent?" — the daemon projects live room grants into this same array, so a
|
|
137
|
-
* seat can arrive with `mcp: [<grant broker>]` and no kernel server, and it is
|
|
138
|
-
* exactly as tool-less as one with no `mcp` key at all.
|
|
139
|
-
*
|
|
140
|
-
* Returns the SAME reference when nothing needs adding, so callers can use the
|
|
141
|
-
* identity as a dirty check. A declared commonly entry is never replaced or
|
|
142
|
-
* duplicated — an operator's hand-set command (a pinned version, a staging
|
|
143
|
-
* checkout) wins over the shipped default — and a non-consuming adapter is
|
|
144
|
-
* returned untouched. A malformed (non-object) spec is not ours to repair.
|
|
145
|
-
*/
|
|
146
|
-
export const withDefaultMcpServer = (environment, adapterName) => {
|
|
147
|
-
if (!ADAPTERS_WITH_DEFAULT_MCP.has(adapterName)) return environment;
|
|
148
|
-
if (environment !== null && environment !== undefined
|
|
149
|
-
&& (typeof environment !== 'object' || Array.isArray(environment))) {
|
|
150
|
-
return environment;
|
|
151
|
-
}
|
|
152
|
-
const declared = Array.isArray(environment?.mcp) ? environment.mcp : null;
|
|
153
|
-
if (declared && declared.some((server) => server?.name === COMMONLY_MCP_SERVER_NAME)) {
|
|
154
|
-
return environment;
|
|
155
|
-
}
|
|
156
|
-
return { ...(environment || {}), mcp: [...(declared || []), commonlyMcpServer()] };
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Ensure the environment declares an ENFORCED sandbox, touching nothing that
|
|
161
|
-
* is already enforced. Returns the SAME reference when nothing needs adding.
|
|
162
|
-
*
|
|
163
|
-
* Only for environments this daemon derived from the server's declaration or
|
|
164
|
-
* from nothing at all — never for a local record the operator authored. A
|
|
165
|
-
* declared `mode: 'none'` is replaced rather than honoured: absence and 'none'
|
|
166
|
-
* are the same thing to the adapter, and this is the path that decides what an
|
|
167
|
-
* undeclared sandbox means.
|
|
168
|
-
*/
|
|
169
|
-
/**
|
|
170
|
-
* Whether an environment declares a sandbox the adapters ENFORCE: a public or
|
|
171
|
-
* workspace trust (whose mode they resolve, and refuse to spawn without, every
|
|
172
|
-
* adapter but claude refusing `workspace` outright, TASK-294) or an explicit
|
|
173
|
-
* non-'none' mode. `mode: 'none'`, an empty block and a missing one are all the
|
|
174
|
-
* same thing to a spawn: no confinement.
|
|
175
|
-
*
|
|
176
|
-
* Normalized first (Wren 71719): a legacy `internal` means `public` here as it
|
|
177
|
-
* does to the attach gate and the adapters.
|
|
178
|
-
*/
|
|
179
|
-
export const declaresEnforcedSandbox = (environment) => {
|
|
180
|
-
const sandbox = normalizeSandboxTrust(environment?.sandbox);
|
|
181
|
-
return sandbox !== null && typeof sandbox === 'object'
|
|
182
|
-
&& sandbox.mode !== 'none'
|
|
183
|
-
&& (sandbox.trust === 'public' || sandbox.trust === 'workspace'
|
|
184
|
-
|| typeof sandbox.mode === 'string');
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
export const withDefaultSandbox = (environment) => {
|
|
188
|
-
if (environment !== null && environment !== undefined
|
|
189
|
-
&& (typeof environment !== 'object' || Array.isArray(environment))) {
|
|
190
|
-
return environment;
|
|
191
|
-
}
|
|
192
|
-
// `mode: 'none'`, an empty block and a missing one are replaced; the ORIGINAL
|
|
193
|
-
// block is what is returned when it is enforced.
|
|
194
|
-
if (declaresEnforcedSandbox(environment)) return environment;
|
|
195
|
-
return { ...(environment || {}), sandbox: defaultSeatSandbox() };
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The full baseline a seat gets from the daemon: the kernel MCP server, plus an
|
|
200
|
-
* enforced sandbox when the environment is one the daemon derived rather than
|
|
201
|
-
* one the operator wrote (`sandbox: true` at the call sites).
|
|
202
|
-
*/
|
|
203
|
-
export const seatBaseline = (environment, adapterName, { sandbox = false } = {}) => {
|
|
204
|
-
const withMcp = withDefaultMcpServer(environment, adapterName);
|
|
205
|
-
if (!sandbox || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName)) return withMcp;
|
|
206
|
-
return withDefaultSandbox(withMcp);
|
|
207
|
-
};
|