@ravefox/space 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +203 -0
  2. package/NOTICE +44 -0
  3. package/README.md +49 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +56 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,115 @@
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
+ };
@@ -0,0 +1,284 @@
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
+ );
@@ -0,0 +1,207 @@
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
+ };