@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.
Files changed (163) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +80 -31
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +27 -46
  15. package/NOTICE +0 -55
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. package/src/lib/workspace-trust.js +0 -162
@@ -1,1681 +0,0 @@
1
- import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
2
- /**
3
- * OpenCode adapter — wraps `opencode run --format json` as a Commonly seat.
4
- *
5
- * Smoke-tested versions are recorded in TESTED_OPENCODE_VERSIONS below.
6
- * OpenCode stores sessions under XDG_DATA_HOME. Trusted seats use the
7
- * operator's data home directly; public seats get per-identity state without
8
- * operator auth.json and a child environment limited to safe launch/locale
9
- * variables. Public provider keys
10
- * stay in the host adapter and reach the configured provider through a
11
- * per-spawn loopback proxy; OpenCode receives only that proxy's bearer.
12
- * This protects the key, not provider spend: the model can use its seat's key
13
- * budget. On Linux the process has its own network namespace, with loopback
14
- * bridges to the fixed provider and Commonly instance origins.
15
- * Config is generated per spawn. OpenCode merges config sources, therefore the
16
- * global config directory is isolated and project config is explicitly off;
17
- * the environment spec is the sole source of MCP servers for this run.
18
- *
19
- * OpenCode's JSON stream carries `sessionID` on step_start, final text in
20
- * `text.part.text`, and token counts in `step_finish.part.tokens`.
21
- */
22
-
23
- import { requireSeatModel, seatModelRequired } from './seat-model.js';
24
- import { isPatchAfterNewestTested } from './tested-version.js';
25
- import { spawn as childSpawn, spawnSync } from 'child_process';
26
- import { createHash } from 'crypto';
27
- import {
28
- existsSync, lstatSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync,
29
- } from 'fs';
30
- import {
31
- chmod,
32
- lstat,
33
- mkdir,
34
- mkdtemp,
35
- readlink,
36
- rm,
37
- unlink,
38
- symlink,
39
- writeFile,
40
- } from 'fs/promises';
41
- import { homedir, tmpdir } from 'os';
42
- import { fileURLToPath } from 'url';
43
- import {
44
- basename, dirname, isAbsolute, join, relative, resolve as pathResolve,
45
- } from 'path';
46
- import { armCredentialRelease, writeCredentialFile } from '../credential-file.js';
47
- import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
48
- import {
49
- prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir,
50
- } from '../mcp-home.js';
51
- import { buildMemoryPreamble } from '../memory-bridge.js';
52
- import {
53
- isLegacySandboxTrust,
54
- normalizeSandboxTrust,
55
- refuseWorkspaceTrust,
56
- validateEnvironmentSpec,
57
- } from '../environment.js';
58
- import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
59
- import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
60
- import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
61
- import { resolvePublicSandboxMode } from '../sandbox/mode.js';
62
- import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
63
- import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
64
- import { MCP_READY_FILE_ENV, commonlyMcpReadyFile } from '../mcp-ready.js';
65
- import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
66
- import { startInstanceProxy } from '../instance-proxy.js';
67
- import { publicSeatWithheldTools } from '../public-withheld-tools.js';
68
-
69
- const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
70
- const ISOLATED_PROXY_PORT = 8765;
71
- const ISOLATED_INSTANCE_PORT = 8766;
72
- const LOOPBACK_BRIDGE = fileURLToPath(new URL('../sandbox/opencode-loopback.mjs', import.meta.url));
73
- const TERMINATION_GRACE_MS = 5000;
74
- // Bump only with a passing real sandbox-exec smoke against the new version.
75
- const TESTED_OPENCODE_VERSIONS = Object.freeze(['1.18.35', '1.19.0', '1.19.4']);
76
- const ALLOW_UNTESTED_OPENCODE_VERSION_ENV = 'COMMONLY_OPENCODE_ALLOW_UNTESTED_VERSION';
77
- const VERSION_CHECK_TIMEOUT_MS = 5000;
78
- const OPENCODE_CONFIG_GITIGNORE = [
79
- 'node_modules',
80
- 'package.json',
81
- 'package-lock.json',
82
- 'bun.lock',
83
- '.gitignore',
84
- ].join('\n');
85
- const DISABLED_ENV = [
86
- 'OPENCODE_DISABLE_CLAUDE_CODE',
87
- 'OPENCODE_DISABLE_CLAUDE_CODE_PROMPT',
88
- 'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS',
89
- 'OPENCODE_DISABLE_EXTERNAL_SKILLS',
90
- 'OPENCODE_DISABLE_DEFAULT_PLUGINS',
91
- 'OPENCODE_DISABLE_AUTOUPDATE',
92
- ];
93
- const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
94
- const SPAWN_ROOT_DENY = '**/commonly-opencode-*/**';
95
- const PUBLIC_READ_DENIES = [
96
- '**/.commonly/**',
97
- '**/.codex/**',
98
- '**/.env',
99
- '**/*.env',
100
- '**/.env.*',
101
- '**/*.env.*',
102
- // Every per-spawn root (mkdtemp `commonly-opencode-`), by name: OpenCode's
103
- // older tools compare paths as text without following symlinks, so a
104
- // workspace link to the temp root would otherwise reach the credential.
105
- SPAWN_ROOT_DENY,
106
- ];
107
- // `.git` (a directory, or a linked worktree's file): its config can widen the
108
- // project (`core.worktree`, Opus review #3487) or run code (`core.fsmonitor`).
109
- const PUBLIC_EDIT_DENIES = [
110
- ...PUBLIC_READ_DENIES, '**/.npmrc', '**/.yarnrc*', '.git', '.git/**', '**/.git', '**/.git/**',
111
- ];
112
- const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
113
-
114
- // `realpathSync.native` (realpath(3)) returns the on-disk spelling, case
115
- // included, on a case-insensitive APFS volume; the JS realpath keeps the case
116
- // it was given, so `/PRIVATE/...` would not compare equal to `/private/...`.
117
- const canonicalPath = (path) => realpathSync.native(path);
118
-
119
- const realOrSelf = (path) => {
120
- try {
121
- return canonicalPath(path);
122
- } catch {
123
- return path;
124
- }
125
- };
126
-
127
- const isWithin = (parent, child) => {
128
- const rel = relative(parent, child);
129
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
130
- };
131
-
132
- /**
133
- * F175a: what hides a public seat's per-spawn files (the credential file the
134
- * grant broker's bearer names, the provider-proxy token) from OpenCode's own
135
- * read, glob, grep and lsp tools is OpenCode's permission layer, not the jail:
136
- * OpenCode itself must read the credential to expand `{file:}`, so Seatbelt and
137
- * bwrap both admit the spawn root. Those tools read anything under `--dir`
138
- * without asking and ask `external_directory` (denied here) for anything else
139
- * (Tool.assertExternalDirectory, OpenCode 1.19.4). So the spawn root must lie
140
- * OUTSIDE the workspace, or the deny never fires. Compared both as written and
141
- * resolved, since macOS spells the temp root two ways (/var and /private/var).
142
- */
143
- /**
144
- * The nearest ancestor of `dir` (itself included) holding a `.git` entry, by a
145
- * plain filesystem walk. OpenCode counts the whole git worktree as inside the
146
- * project (Instance.containsPath), so its tools reach it without asking
147
- * external_directory, exactly as they reach --dir.
148
- */
149
- const gitToplevel = (dir) => {
150
- let current = dir;
151
- for (;;) {
152
- if (existsSync(join(current, '.git'))) return current;
153
- const parent = dirname(current);
154
- if (parent === current) return null;
155
- current = parent;
156
- }
157
- };
158
-
159
- const GIT_TOPLEVEL_TIMEOUT_MS = 5000;
160
-
161
- // Round 8 (#3487): OpenCode matches permission patterns case-sensitively and
162
- // APFS does not, so `.GIT/config` slips past the `.git` edit deny. Config from
163
- // the environment outranks the repo's own, however the path was spelled, so a
164
- // public seat's git (OpenCode's and this adapter's check alike) runs no
165
- // fsmonitor and no hook. Added after the public env filter, which drops every
166
- // GIT_* the launcher had.
167
- const PUBLIC_GIT_ENV = Object.freeze({
168
- GIT_CONFIG_COUNT: '2',
169
- GIT_CONFIG_KEY_0: 'core.fsmonitor',
170
- GIT_CONFIG_VALUE_0: 'false',
171
- GIT_CONFIG_KEY_1: 'core.hooksPath',
172
- GIT_CONFIG_VALUE_1: '/dev/null',
173
- });
174
-
175
- /**
176
- * The worktree git reports for the repo whose `.git` sits in `holder`, the way
177
- * OpenCode 1.19.4 computes `Instance.worktree` (GitV2 `Git.repo.discover`: find
178
- * `.git` upward, then `git rev-parse --show-toplevel` with that folder as cwd,
179
- * falling back to the folder itself when git fails). `env` is the seat's own
180
- * child environment, so both read the same config. Returns `{ top }`,
181
- * `{ notRepo: true }` when git finds no repository there (OpenCode then has no
182
- * worktree beyond --dir), or `{ error }`.
183
- */
184
- const gitReportedWorktree = (holder, env) => {
185
- const run = spawnSync('git', ['rev-parse', '--show-toplevel'], {
186
- cwd: holder, env, encoding: 'utf8', timeout: GIT_TOPLEVEL_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'],
187
- });
188
- if (run.error) return { error: run.error.code || run.error.message };
189
- const stderr = String(run.stderr || '').trim();
190
- if (run.status !== 0) {
191
- if (/not a git repository/i.test(stderr)) return { notRepo: true };
192
- return { error: `exit ${run.status}${stderr ? `: ${stderr.slice(0, 200)}` : ''}` };
193
- }
194
- const text = String(run.stdout || '').trim();
195
- return text ? { top: pathResolve(holder, text) } : { error: 'empty output' };
196
- };
197
-
198
- // Bounds for the pre-spawn symlink walk. Reaching either one leaves the
199
- // workspace unproven, and a public seat then refuses
200
- // (`assertSpawnRootOutsideWorkspace`), so the depth sits well above real source
201
- // trees: it guards against a pathological tree, not an ordinary one.
202
- const SYMLINK_WALK_BOUNDS = Object.freeze({ maxDepth: 32, maxEntries: 20000 });
203
- // CodeRabbit round 4 (#3487): the walk also covers every dependency tree,
204
- // links only, under its own bound (every public spawn since F185). Measured 2026-11-05 on
205
- // this repo's backend/node_modules: 44,664 entries, 63 symlinks (none leaving
206
- // the workspace), 0.5-1.4 s on a host at load average ~90.
207
- const DEPENDENCY_WALK_BOUNDS = Object.freeze({ maxDepth: 64, maxEntries: 200000 });
208
- const SYMLINK_WALK_SKIP = new Set(['.git']);
209
- // Package-manager trees: their contents are walked links-only under the
210
- // dependency budget, never as source. A Python environment is one too, by
211
- // name or by its marker (`pyvenv.cfg`, conda's `conda-meta`), whatever its
212
- // folder is called: its files would exhaust the source budget (F185).
213
- const DEPENDENCY_DIRS = new Set(['node_modules', '.venv']);
214
- const isPythonEnvironment = (entries) => entries.some((entry) => (
215
- (entry.name === 'pyvenv.cfg' && entry.isFile()) || (entry.name === 'conda-meta' && entry.isDirectory())
216
- ));
217
- // Pseudo-file systems: a path in them names something per process (an open
218
- // descriptor, a device), so where it leads in OpenCode is not where it leads
219
- // here. No link may go there.
220
- const SPECIAL_ROOTS = ['/proc', '/dev'];
221
- const underSpecial = (path) => SPECIAL_ROOTS.some((root) => isWithin(root, path));
222
- const MAX_LINK_HOPS = 40;
223
-
224
- const isRegularFile = (path) => {
225
- try { return statSync(path).isFile(); } catch { return false; }
226
- };
227
-
228
- /**
229
- * F185. Matches a resolved path against the folders holding this spawn's
230
- * secrets: it lies inside one, or holds one. The same three tests as the
231
- * provider key file check (keyFileReadableRoot, F453): the paths as written,
232
- * real-pathed, and by file identity, so another spelling of the same place
233
- * (case, a firmlink) is the same place. Each path's identity is looked up once
234
- * per walk; a dependency tree shares most of its ancestors.
235
- */
236
- const secretMatcher = (secretRoots) => {
237
- const ids = new Map();
238
- const idOf = (path) => {
239
- if (!ids.has(path)) {
240
- let id = null;
241
- try {
242
- const details = statSync(path, { bigint: true });
243
- id = `${details.dev}:${details.ino}`;
244
- } catch { /* absent: no identity */ }
245
- ids.set(path, id);
246
- }
247
- return ids.get(path);
248
- };
249
- const chainIds = (path) => {
250
- const out = new Set();
251
- for (let current = path; ; current = dirname(current)) {
252
- const id = idOf(current);
253
- if (id) out.add(id);
254
- if (dirname(current) === current) break;
255
- }
256
- return out;
257
- };
258
- const secrets = secretRoots.map((root) => ({
259
- root,
260
- spellings: [...new Set([pathResolve(root), realOrSelf(root)])],
261
- id: idOf(root),
262
- ancestorIds: chainIds(root),
263
- }));
264
- return (resolved) => {
265
- const ancestors = chainIds(resolved);
266
- const own = idOf(resolved);
267
- const found = secrets.find((secret) => (
268
- secret.spellings.some((spelling) => isWithin(spelling, resolved) || isWithin(resolved, spelling))
269
- || (secret.id && ancestors.has(secret.id))
270
- || (own && secret.ancestorIds.has(own))
271
- ));
272
- return found ? found.root : null;
273
- };
274
- };
275
-
276
- /**
277
- * Where a link lands, resolved one component at a time the way the kernel
278
- * does it, so every step is seen: `special` is set when any step (a hop's
279
- * target, or a folder passed through) is under /proc or /dev. There a path
280
- * names something per process (an open descriptor, a process's root), so
281
- * where it leads for OpenCode is not where it leads for this check, whether or
282
- * not it resolves here. `..` applies to the real path reached so far, never
283
- * to the text.
284
- *
285
- * `{ resolved, exists: true }` when every component exists (`resolved` is the
286
- * canonical spelling). When one is missing, `{ resolved, exists: false }`
287
- * gives where it would land: the real prefix with the rest appended. A missing
288
- * target is not proof of no route: the seat's data home gets files during the
289
- * turn. Throws ELOOP past MAX_LINK_HOPS, and any lstat or readlink error other
290
- * than a missing component.
291
- *
292
- * A `..` after the missing component is folded as text (F462). The kernel
293
- * can't follow such a link at all today. The fold is where it lands once the
294
- * missing piece exists as a directory, which is all a public seat's tools can
295
- * create (they have no shell, so no symlink). Only someone with access to the
296
- * host could make it a link instead, and that person can read the credential
297
- * directly.
298
- */
299
- const linkLanding = (path) => {
300
- let special = false;
301
- let hops = 0;
302
- // The walk only reaches real directories, so the link's own folder is real.
303
- let done = realOrSelf(dirname(pathResolve(path)));
304
- special = special || underSpecial(done);
305
- const pending = [basename(path)];
306
- while (pending.length) {
307
- const part = pending.shift();
308
- if (part === '' || part === '.') continue;
309
- if (part === '..') {
310
- done = dirname(done);
311
- continue;
312
- }
313
- const next = join(done, part);
314
- special = special || underSpecial(next);
315
- let details;
316
- try {
317
- details = lstatSync(next);
318
- } catch (err) {
319
- if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err;
320
- const rest = pending.filter((piece) => piece && piece !== '.');
321
- const resolved = pathResolve(next, ...(rest.length ? [rest.join('/')] : []));
322
- return { resolved, exists: false, special: special || underSpecial(resolved) };
323
- }
324
- if (details.isSymbolicLink()) {
325
- hops += 1;
326
- if (hops > MAX_LINK_HOPS) throw Object.assign(new Error(`too many symlink hops resolving ${path}`), { code: 'ELOOP' });
327
- const text = readlinkSync(next);
328
- if (isAbsolute(text)) done = '/';
329
- special = special || underSpecial(pathResolve(done, text));
330
- pending.unshift(...text.split('/'));
331
- continue;
332
- }
333
- done = next;
334
- }
335
- // Case and firmlinks: the canonical spelling, for the boundary comparison.
336
- return { resolved: realOrSelf(done), exists: true, special: special || underSpecial(done) };
337
- };
338
-
339
- /**
340
- * Scan `workspace` for a symlink OpenCode's tools could follow to the per-spawn
341
- * credential directory. Measured in OpenCode 1.19.4: `grep` and `glob` judge
342
- * only their argument (the regex, the glob) against permission rules and check
343
- * the search PATH as text through external_directory, so `grep` with
344
- * `path: <workspace link to the temp root>` searches the credential's contents
345
- * with nothing asked. This walk is what stops that, not a permission pattern.
346
- *
347
- * Returns `{ link, reason, secret? }` for the first offending link, `{ incomplete }`
348
- * when a bound stopped the walk before it saw the whole tree, or null.
349
- * - `reaches_spawn_root`: the link's landing holds or lies inside `spawnRoot`
350
- * or one of `secretRoots` (named in `secret`). A dangling link is judged by
351
- * where it would land (`linkLanding`).
352
- * - `special_filesystem`: the link leads, at any hop, under /proc or /dev.
353
- * - `outside_workspace`: a link AT a node_modules top level (or under an
354
- * @scope there) leading out of `boundary`. node_modules is not walked into,
355
- * since a typical one exhausts the entry bound on every spawn; its packages'
356
- * links are installed by the package manager inside it, while a top-level
357
- * link out (`npm link`, a `file:` dependency) reaches a tree this walk never
358
- * read.
359
- *
360
- * `deepDependencies` (every public spawn since F185; the default stays off for
361
- * direct callers): every workspace link is held to `boundary` as well, and every node_modules tree, nested ones and @scopes included, is walked too,
362
- * links only, under `dependencyBounds`, its own budget. A link anywhere in it
363
- * that reaches the spawn root, or that leaves `boundary`, is refused, since a
364
- * package tarball or postinstall script can plant `dist/x -> /tmp`, or a chain
365
- * through a directory outside (CodeRabbit #3487). Nothing is followed, and
366
- * only a symlink costs a realpath; a regular entry costs its dirent. A stopped
367
- * walk reports `{ incomplete: 'dependency_entries' | 'dependency_depth' }`.
368
- * The same mode walks every `.git` directory links-only, and treats what it
369
- * cannot see as unproven (Opus review, #3487): a directory it cannot list
370
- * (`incomplete: 'unreadable'`; a 0111 directory is still traversable by the
371
- * OS) or a link realpath cannot resolve for a reason other than a missing
372
- * target (`incomplete: 'unresolvable'`, a loop say), each with `problem:
373
- * { path, code }`. `root` is where the walk starts: the git worktree root for a
374
- * public seat, since OpenCode's tools reach the whole worktree. A link to a
375
- * regular file outside `boundary` and every secret folder passes: a file holds
376
- * no further link.
377
- *
378
- * Hard links are not looked for (F462). A readdir gives no inode, so finding
379
- * them means a stat per file in the worktree, and only the host's owner can
380
- * make one: a public seat's tools have no shell. The runtime token and the
381
- * proxy token are new files in a new temp folder each spawn, so no link made
382
- * before the spawn names them, and a public seat's data home never gets the
383
- * operator's login. The one lasting secret is the operator's provider.keyFile:
384
- * a hard link to it in the worktree would pass the jail-path check, but making
385
- * one is the owner putting their own key in the workspace, the same as a copy.
386
- */
387
- const scanWorkspaceLinks = (workspace, spawnRoot, {
388
- boundary = workspace,
389
- root = workspace,
390
- secretRoots = [],
391
- bounds = SYMLINK_WALK_BOUNDS,
392
- deepDependencies = false,
393
- dependencyBounds = DEPENDENCY_WALK_BOUNDS,
394
- } = {}) => {
395
- const matchSecret = secretMatcher([...new Set([spawnRoot, ...secretRoots].filter(Boolean))]);
396
- const inside = realOrSelf(boundary);
397
- let budget = bounds.maxEntries;
398
- let dependencyBudget = dependencyBounds.maxEntries;
399
- let incomplete = null;
400
- let problem = null;
401
- // A missing path (a dangling link, a directory removed mid-walk) is no route.
402
- const ABSENT = new Set(['ENOENT', 'ENOTDIR']);
403
- const unproven = (kind, path, err) => {
404
- if (ABSENT.has(err?.code) || !deepDependencies) return;
405
- incomplete = incomplete || kind;
406
- problem = problem || { path, code: err?.code || String(err) };
407
- };
408
- const linkFinding = (path, { dependencyTop }) => {
409
- let landing;
410
- try {
411
- landing = linkLanding(path);
412
- } catch (err) {
413
- unproven('unresolvable', path, err);
414
- return null;
415
- }
416
- const { resolved, exists, special } = landing;
417
- if (special) return { link: path, reason: 'special_filesystem' };
418
- const secret = matchSecret(resolved);
419
- if (secret) return { link: path, reason: 'reaches_spawn_root', secret };
420
- if (!exists) return null;
421
- // A regular file outside every secret folder holds no further link, so a
422
- // link to one leads nowhere else (a virtualenv's bin/python -> /usr/bin).
423
- if (dependencyTop && !isWithin(inside, resolved) && !isRegularFile(resolved)) {
424
- return { link: path, reason: 'outside_workspace' };
425
- }
426
- return null;
427
- };
428
- const readEntries = (dir) => {
429
- try {
430
- return readdirSync(dir, { withFileTypes: true });
431
- } catch (err) {
432
- unproven('unreadable', dir, err);
433
- return [];
434
- }
435
- };
436
- // The top level of a node_modules, and of each @scope in it: links checked,
437
- // nothing descended into.
438
- const scanDependencyTop = (dir, { scoped = false } = {}) => {
439
- const entries = readEntries(dir);
440
- if (incomplete) return null;
441
- for (const entry of entries) {
442
- budget -= 1;
443
- if (budget < 0) {
444
- incomplete = incomplete || 'entries';
445
- return null;
446
- }
447
- const path = join(dir, entry.name);
448
- if (entry.isSymbolicLink()) {
449
- const hit = linkFinding(path, { dependencyTop: true });
450
- if (hit || incomplete) return hit;
451
- } else if (!scoped && entry.isDirectory() && entry.name.startsWith('@')) {
452
- const hit = scanDependencyTop(path, { scoped: true });
453
- if (hit || incomplete) return hit;
454
- }
455
- }
456
- return null;
457
- };
458
- // Every entry under one node_modules, iteratively: a link is resolved and
459
- // judged, never descended into; a real directory is queued. The node_modules
460
- // directory itself is level 1. A deep link is held to the top-level rule:
461
- // one leaving `boundary` is refused, since a path argument resolves a chain
462
- // (node_modules/x/y -> /outside/dir/y -> /tmp) in a tree never read.
463
- const scanDependencyTree = (modulesDir) => {
464
- const stack = [[modulesDir, 1]];
465
- while (stack.length) {
466
- const [dir, depth] = stack.pop();
467
- if (depth > dependencyBounds.maxDepth) {
468
- incomplete = incomplete || 'dependency_depth';
469
- return null;
470
- }
471
- const entries = readEntries(dir);
472
- if (incomplete) return null;
473
- for (const entry of entries) {
474
- dependencyBudget -= 1;
475
- if (dependencyBudget < 0) {
476
- incomplete = incomplete || 'dependency_entries';
477
- return null;
478
- }
479
- const path = join(dir, entry.name);
480
- if (entry.isSymbolicLink()) {
481
- const hit = linkFinding(path, { dependencyTop: true });
482
- if (hit || incomplete) return hit;
483
- } else if (entry.isDirectory()) {
484
- stack.push([path, depth + 1]);
485
- }
486
- }
487
- }
488
- return null;
489
- };
490
- const walk = (dir, depth) => {
491
- if (depth > bounds.maxDepth) {
492
- incomplete = incomplete || 'depth';
493
- return null;
494
- }
495
- const entries = readEntries(dir);
496
- if (incomplete) return null;
497
- // A Python environment under any name: links only, on the dependency budget.
498
- if (deepDependencies && isPythonEnvironment(entries)) return scanDependencyTree(dir);
499
- for (const entry of entries) {
500
- budget -= 1;
501
- if (budget < 0) {
502
- incomplete = incomplete || 'entries';
503
- return null;
504
- }
505
- const path = join(dir, entry.name);
506
- if (entry.isSymbolicLink()) {
507
- // Every workspace link is held to the boundary: a chain through a tree
508
- // outside it (src/vendor -> /outside, a node_modules that is itself a
509
- // link) is resolved by the OS, unread by this walk.
510
- const hit = linkFinding(path, { dependencyTop: deepDependencies });
511
- if (hit || incomplete) return hit;
512
- } else if (entry.isDirectory() && DEPENDENCY_DIRS.has(entry.name)) {
513
- const hit = scanDependencyTop(path) || (!incomplete && deepDependencies ? scanDependencyTree(path) : null);
514
- if (hit || incomplete) return hit;
515
- } else if (deepDependencies && entry.isDirectory() && SYMLINK_WALK_SKIP.has(entry.name)) {
516
- // `.git`: links only, under the dependency budget (8,243 entries in
517
- // this repo's own checkout, no links).
518
- const hit = scanDependencyTree(path);
519
- if (hit || incomplete) return hit;
520
- } else if (entry.isDirectory() && !SYMLINK_WALK_SKIP.has(entry.name)) {
521
- const hit = walk(path, depth + 1);
522
- if (hit || incomplete) return hit;
523
- }
524
- }
525
- return null;
526
- };
527
- const hit = walk(pathResolve(root), 0);
528
- if (hit) return hit;
529
- if (!incomplete) return null;
530
- return problem ? { incomplete, problem } : { incomplete };
531
- };
532
-
533
- /**
534
- * Refuse a public seat whose workspace lets OpenCode's tools reach the
535
- * per-spawn credential directory, or another folder holding this spawn's
536
- * secrets (`secretRoots`, the seat's data home). Every public spawn holds a
537
- * live Commonly token and a provider-proxy token there, so every one gets the
538
- * same walk (F185): from the worktree root, node_modules, .venv and .git
539
- * links-only, every link held to the worktree, and a walk its bound stopped
540
- * refuses.
541
- */
542
- const assertSpawnRootOutsideWorkspace = (workspace, spawnRoot, {
543
- secretRoots = [],
544
- bounds = SYMLINK_WALK_BOUNDS,
545
- dependencyBounds = DEPENDENCY_WALK_BOUNDS,
546
- gitEnv = null,
547
- } = {}) => {
548
- if (typeof workspace !== 'string' || !workspace) return;
549
- const pairs = [[pathResolve(workspace), spawnRoot], [realOrSelf(workspace), realOrSelf(spawnRoot)]];
550
- if (pairs.some(([parent, child]) => isWithin(parent, child))) {
551
- throw new Error(
552
- `public OpenCode agents refuse a workspace (${workspace}) that contains the per-spawn credential directory `
553
- + `(${spawnRoot}): OpenCode's tools could read the credential there; choose a workspace outside the temp directory`,
554
- );
555
- }
556
- // Containment first: a real repo holding the spawn root also trips the
557
- // core.worktree check below, whose message would blame a setting that isn't
558
- // there (CI #3487).
559
- const toplevels = pairs
560
- .map(([parent, child]) => [gitToplevel(parent), child])
561
- .filter(([top]) => top);
562
- const hit = toplevels.find(([top, child]) => isWithin(top, child));
563
- if (hit) {
564
- throw new Error(
565
- `public OpenCode agents refuse a workspace (${workspace}) whose git worktree (${hit[0]}) contains the per-spawn `
566
- + `credential directory (${spawnRoot}): OpenCode's tools could read the credential there; `
567
- + 'choose a workspace outside that repository or set TMPDIR outside it',
568
- );
569
- }
570
- // Opus review round 7 (#3487): the folder holding .git is the boundary only
571
- // while git agrees. `core.worktree` makes `--show-toplevel` (OpenCode's
572
- // worktree) name any folder, $TMPDIR and every spawn root included.
573
- if (gitEnv) {
574
- const holders = [...new Set(pairs.map(([parent]) => gitToplevel(parent)).filter(Boolean))];
575
- for (const holder of holders) {
576
- const reported = gitReportedWorktree(holder, gitEnv);
577
- if (reported.error) {
578
- // A git that cannot answer (missing, timed out, failing) leaves the
579
- // boundary a guess; no public seat runs on one (round 8, #3487).
580
- throw new Error(
581
- `public OpenCode agents could not confirm the git worktree of the workspace (${workspace}): `
582
- + `git rev-parse --show-toplevel in ${holder} failed (${reported.error}); `
583
- + 'make git available to the seat, or choose a workspace outside a git repository',
584
- );
585
- }
586
- if (!reported.top) continue;
587
- const top = realOrSelf(reported.top);
588
- const widened = top !== realOrSelf(holder);
589
- if (widened || isWithin(top, realOrSelf(spawnRoot)) || isWithin(top, realOrSelf(tmpdir()))) {
590
- throw new Error(
591
- `public OpenCode agents refuse a workspace (${workspace}) whose git reports the worktree as ${reported.top}, `
592
- + `not ${holder}, the folder holding .git: OpenCode's tools reach the whole of that worktree, which a `
593
- + 'core.worktree setting can widen to the temp directory and the per-spawn credential; remove the setting',
594
- );
595
- }
596
- }
597
- }
598
- // OpenCode counts the whole git worktree as inside the project, so a
599
- // node_modules link into it is not a link out.
600
- const boundary = toplevels[0]?.[0] || gitToplevel(pathResolve(workspace)) || workspace;
601
- // The whole worktree OpenCode's tools can reach, sibling packages and .git
602
- // included (Opus review, #3487; every public spawn since F185).
603
- const finding = scanWorkspaceLinks(workspace, spawnRoot, {
604
- boundary,
605
- root: realOrSelf(boundary),
606
- secretRoots,
607
- bounds,
608
- deepDependencies: true,
609
- dependencyBounds,
610
- });
611
- if (finding?.incomplete === 'unreadable' || finding?.incomplete === 'unresolvable') {
612
- const { path, code } = finding.problem;
613
- throw new Error(
614
- `public OpenCode agents could not ${finding.incomplete === 'unreadable' ? 'list the directory' : 'resolve the symlink'} `
615
- + `${path} (${code}) while checking the workspace (${workspace}) for symlinks to the per-spawn credential directory; `
616
- + 'what the walk cannot see is not proven clean; fix its permissions, or remove it',
617
- );
618
- }
619
- if (finding?.reason === 'reaches_spawn_root') {
620
- const folder = !finding.secret || finding.secret === spawnRoot
621
- ? `the per-spawn credential directory (${spawnRoot})`
622
- : `the seat's data folder (${finding.secret})`;
623
- throw new Error(
624
- `public OpenCode agents refuse a workspace (${workspace}) holding a symlink (${finding.link}) that reaches `
625
- + `${folder}: OpenCode's tools could read this spawn's credentials through it; remove the link`,
626
- );
627
- }
628
- if (finding?.reason === 'special_filesystem') {
629
- throw new Error(
630
- `public OpenCode agents refuse a workspace (${workspace}) holding a symlink (${finding.link}) that leads into `
631
- + '/proc or /dev, where a path names something per process and the check cannot follow it; remove the link',
632
- );
633
- }
634
- if (finding?.reason === 'outside_workspace') {
635
- throw new Error(
636
- `public OpenCode agents refuse a workspace (${workspace}) holding a symlink (${finding.link}) that leads outside `
637
- + 'the workspace, into a tree not checked for links to the per-spawn credential directory; '
638
- + 'move the seat to a workspace without links that leave it, or remove the link',
639
- );
640
- }
641
- if (finding?.incomplete?.startsWith('dependency_')) {
642
- throw new Error(
643
- `public OpenCode agents could not finish checking the workspace's node_modules and .git trees (${workspace}) for symlinks `
644
- + `to the per-spawn credential directory: the link walk stopped at its ${finding.incomplete === 'dependency_depth'
645
- ? `depth bound (${dependencyBounds.maxDepth} levels)` : `entry bound (${dependencyBounds.maxEntries} entries)`}, `
646
- + 'prune the dependency tree, or choose a smaller workspace',
647
- );
648
- }
649
- if (finding?.incomplete) {
650
- throw new Error(
651
- `public OpenCode agents could not finish checking the workspace (${workspace}) for symlinks to the per-spawn `
652
- + `credential directory: the walk stopped at its ${finding.incomplete === 'depth'
653
- ? `depth bound (${bounds.maxDepth} levels)` : `entry bound (${bounds.maxEntries} entries)`}; `
654
- + 'choose a smaller workspace',
655
- );
656
- }
657
- };
658
-
659
- const statOrNull = async (path) => {
660
- try {
661
- return await lstat(path);
662
- } catch (err) {
663
- if (err?.code === 'ENOENT') return null;
664
- throw err;
665
- }
666
- };
667
-
668
- const substitutePublicPlaceholders = (value, ctx) => {
669
- if (typeof value !== 'string' || !value.includes('${COMMONLY_')) return value;
670
- const replacements = {
671
- COMMONLY_TOKEN_FILE: ctx.credentialFile || '',
672
- COMMONLY_API_URL: ctx.instanceUrl || '',
673
- COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
674
- };
675
- return value.replace(PLACEHOLDER_RE, (whole, key) => replacements[key] || whole);
676
- };
677
-
678
- const isSafeMcpName = (name) => typeof name === 'string'
679
- && /^[A-Za-z0-9_-]+$/.test(name);
680
-
681
- // How OpenCode spells an MCP server in a tool name, and so in a permission rule
682
- // (1.19.4: `<server>_<tool>`, each part through `/[^a-zA-Z0-9_-]/g` to `_`). The
683
- // hyphen stays. Rewriting it left a rule that matched nothing, and the `'*'`
684
- // deny then took `commonly-grant-broker`'s tools off the model's list, with no
685
- // log line, though the server had connected and listed them.
686
- const permissionMcpName = (name) => name.replace(/[^A-Za-z0-9_-]/g, '_');
687
-
688
- const buildMcpConfig = (servers, ctx = {}) => {
689
- const mcp = {};
690
- const runtimeToken = typeof ctx.runtimeToken === 'string' ? ctx.runtimeToken : '';
691
- for (const server of servers || []) {
692
- if (!isSafeMcpName(server?.name)) continue;
693
- const transport = typeof server.transport === 'string'
694
- ? server.transport.trim().toLowerCase()
695
- : 'stdio';
696
-
697
- if (transport === 'stdio') {
698
- if (!Array.isArray(server.command) || server.command.length === 0) continue;
699
- const command = prepareMcpSpawn(server.command, {
700
- apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat,
701
- });
702
- if (!command) {
703
- ctx.unavailableMcp?.push(server.name);
704
- continue;
705
- }
706
- if (runtimeToken && command.some((part) => typeof part === 'string' && part.includes(runtimeToken))) {
707
- // A bearer token in argv is readable by same-user processes.
708
- // eslint-disable-next-line no-console
709
- console.warn(`[opencode] skipping MCP server ${server.name}: seat credentials cannot be passed in argv`);
710
- continue;
711
- }
712
- const delivery = deliverSeatCredential({ ...server, command }, {
713
- credentialFile: ctx.credentialFile,
714
- label: 'opencode',
715
- });
716
- const env = {};
717
- let unsupportedCredential = false;
718
- for (const [key, rawValue] of Object.entries(delivery.env)) {
719
- if (typeof rawValue !== 'string') continue;
720
- if (rawValue.includes('${COMMONLY_AGENT_TOKEN}')
721
- && delivery.delivered !== 'path') {
722
- unsupportedCredential = true;
723
- break;
724
- }
725
- const value = substitutePublicPlaceholders(rawValue, ctx);
726
- if (runtimeToken && value.includes(runtimeToken)) {
727
- unsupportedCredential = true;
728
- break;
729
- }
730
- env[key] = value;
731
- }
732
- if (unsupportedCredential) {
733
- // eslint-disable-next-line no-console
734
- console.warn(`[opencode] skipping MCP server ${server.name}: its credential declaration has no file-backed delivery`);
735
- continue;
736
- }
737
- // F421: the shipped Commonly server of a public seat names its ready file.
738
- // F460: a warm build that writes none is refused on a public seat.
739
- const readyPath = ctx.readyFiles
740
- ? commonlyMcpReadyFile(server, command, ctx, { writesReadyFile: ctx.mcpWritesReadyFile, env })
741
- : null;
742
- if (readyPath) env[MCP_READY_FILE_ENV] = readyPath;
743
- mcp[server.name] = {
744
- type: 'local',
745
- command: command.map((part) => substitutePublicPlaceholders(part, ctx)),
746
- enabled: true,
747
- ...(Object.keys(env).length ? { environment: env } : {}),
748
- ...(typeof server.cwd === 'string' ? { cwd: server.cwd } : {}),
749
- };
750
- const versionDir = ctx.publicSeat && selectedMcpVersionDir(command);
751
- if (versionDir) ctx.mcpReadDirectories?.push(versionDir);
752
- continue;
753
- }
754
-
755
- if (transport !== 'http' || typeof server.url !== 'string') continue;
756
- if (server.url.includes('${COMMONLY_AGENT_TOKEN}')) continue;
757
- const url = substitutePublicPlaceholders(server.url, ctx);
758
- // Runtime credentials only travel through OpenCode's measured {file:}
759
- // header expansion, never as environment values or literal config text.
760
- if (runtimeToken && url.includes(runtimeToken)) continue;
761
- const headers = {};
762
- let unsupportedCredential = false;
763
- for (const [key, rawValue] of Object.entries(server.headers || {})) {
764
- if (typeof rawValue !== 'string') continue;
765
- let value = substitutePublicPlaceholders(rawValue, ctx);
766
- if (rawValue.includes('${COMMONLY_AGENT_TOKEN}')) {
767
- if (key.toLowerCase() !== 'authorization'
768
- || rawValue !== 'Bearer ${COMMONLY_AGENT_TOKEN}'
769
- || !ctx.credentialFile) {
770
- unsupportedCredential = true;
771
- break;
772
- }
773
- value = `Bearer {file:${ctx.credentialFile}}`;
774
- }
775
- if (runtimeToken && value.includes(runtimeToken)) {
776
- unsupportedCredential = true;
777
- break;
778
- }
779
- headers[key] = value;
780
- }
781
- if (unsupportedCredential) {
782
- // eslint-disable-next-line no-console
783
- console.warn(`[opencode] skipping MCP server ${server.name}: remote credentials must use the bearer file channel`);
784
- continue;
785
- }
786
- mcp[server.name] = {
787
- type: 'remote',
788
- url,
789
- enabled: true,
790
- ...(Object.keys(headers).length ? { headers } : {}),
791
- };
792
- // What delivered the credential file to a remote server, whatever its URL:
793
- // the spawn's grant checks read this, not a guess at which entry is the
794
- // broker (CodeRabbit #3487).
795
- if (Object.values(headers).some((value) => value.includes(`{file:${ctx.credentialFile}}`))) {
796
- ctx.bearerFileServers?.push(server.name);
797
- }
798
- }
799
- return mcp;
800
- };
801
-
802
- const publicPermissions = (mode, mcpNames, ctx = {}) => {
803
- const read = { '*': 'allow' };
804
- const edit = { '*': mode === 'read-only' ? 'deny' : 'allow' };
805
- for (const pattern of PUBLIC_READ_DENIES) {
806
- read[pattern] = 'deny';
807
- }
808
- for (const pattern of PUBLIC_EDIT_DENIES) edit[pattern] = 'deny';
809
- // The spawn-folder rule on the search tools. OpenCode 1.19.4 matches these
810
- // against the tool's own argument (the glob string, the grep regex), not the
811
- // searched path or the results, and ships no `list` tool, so this stops only
812
- // a search that names the folder; scanWorkspaceLinks stops one through a link.
813
- const search = () => ({ '*': 'allow', [SPAWN_ROOT_DENY]: 'deny' });
814
- return {
815
- '*': 'deny',
816
- read,
817
- edit,
818
- glob: search(),
819
- grep: search(),
820
- list: search(),
821
- bash: 'deny',
822
- task: 'deny',
823
- external_directory: 'deny',
824
- question: 'deny',
825
- webfetch: 'deny',
826
- websearch: 'deny',
827
- skill: 'deny',
828
- todowrite: 'allow',
829
- todoread: 'allow',
830
- lsp: 'allow',
831
- ...Object.fromEntries(mcpNames.map((name) => [`${permissionMcpName(name)}_*`, 'allow'])),
832
- // After the wildcard allow, since the last matching rule wins
833
- // (public-withheld-tools.js; self-install and memory writes, F118).
834
- ...Object.fromEntries(mcpNames.flatMap((name) => publicSeatWithheldTools(ctx)
835
- .map((tool) => [`${permissionMcpName(name)}_${tool}`, 'deny']))),
836
- };
837
- };
838
-
839
- const buildArgs = ({ prompt, sessionId, cwd, model, effort, title }) => ([
840
- '--pure',
841
- 'run',
842
- '--format', 'json',
843
- ...(sessionId ? ['-s', String(sessionId)] : []),
844
- ...(model ? ['-m', String(model)] : []),
845
- ...(effort ? ['--variant', String(effort)] : []),
846
- ...(cwd ? ['--dir', cwd] : []),
847
- ...(title ? ['--title', String(title)] : []),
848
- prompt,
849
- ]);
850
-
851
- const qualifiedProviderModel = (provider, model) => (
852
- provider ? `${provider.id}/${model}` : model
853
- );
854
-
855
- const buildProviderConfig = (provider, model, keyFile) => {
856
- if (!provider) return undefined;
857
- if (!model) throw new Error('OpenCode provider configuration requires environment.model');
858
- // OpenCode 1.18.35 resolves {file:} in provider options, but failed before
859
- // making a request when the selected id was absent from provider.models.
860
- const models = { ...(provider.models || {}) };
861
- const selectedModel = models[model];
862
- if (!selectedModel) {
863
- models[model] = { name: model };
864
- } else if (typeof selectedModel.name !== 'string' || !selectedModel.name) {
865
- models[model] = { ...selectedModel, name: model };
866
- }
867
- // anthropic-messages: provider.baseURL is the host (as for Claude Code's
868
- // ANTHROPIC_BASE_URL); @ai-sdk/anthropic appends /messages to a /v1 base.
869
- const anthropic = provider.api === 'anthropic-messages';
870
- return {
871
- [provider.id]: {
872
- npm: anthropic ? '@ai-sdk/anthropic' : '@ai-sdk/openai-compatible',
873
- options: {
874
- baseURL: anthropic ? `${provider.baseURL.replace(/\/+$/, '')}/v1` : provider.baseURL,
875
- apiKey: `{file:${keyFile}}`,
876
- },
877
- models,
878
- },
879
- };
880
- };
881
-
882
- const validateProviderEnvironment = async (environment, workspacePath, options = {}) => {
883
- if (environment?.provider === undefined) return null;
884
- const validation = validateEnvironmentSpec({
885
- provider: environment.provider,
886
- model: environment.model,
887
- });
888
- if (!validation.ok) {
889
- throw new Error(`Invalid OpenCode provider configuration: ${validation.errors.join('; ')}`);
890
- }
891
- return resolveProviderKeyFile(environment.provider, workspacePath, undefined, { environment, ...options });
892
- };
893
-
894
- const STALE_SESSION_PATTERN = /session not found/i;
895
- // Set by runOpenCode only when the rejected run produced no turn output, so a
896
- // phrase that shows up after the model acted cannot replay the turn.
897
- const isStaleSessionError = (err) => err?.staleSession === true;
898
-
899
- const makeEventParser = () => {
900
- let buffer = '';
901
- let sessionId = null;
902
- let text = '';
903
- let usage = null;
904
- let errorMessage = null;
905
- // Any text, tool call or step result means the model acted this run.
906
- let turnOutput = false;
907
- let stepStarted = false;
908
- // Every tool call that came back as an error, for the public-seat check in
909
- // spawn (publicToolFailure).
910
- const toolErrors = [];
911
- const consumeLine = (line) => {
912
- if (!line.trim()) return;
913
- try {
914
- const event = JSON.parse(line);
915
- if (event.sessionID) sessionId = event.sessionID;
916
- // Any step event means the run started a turn, so it is not safe to replay.
917
- if (String(event.type || '').startsWith('step_') || event.type === 'tool_use') turnOutput = true;
918
- if (event.type === 'step_start') stepStarted = true;
919
- if (event.type === 'text' && typeof event.part?.text === 'string') {
920
- turnOutput = true;
921
- text += event.part.text;
922
- } else if (event.type === 'step_finish' && event.part?.tokens) {
923
- usage = event.part.tokens;
924
- }
925
- if (event.type === 'tool_use' && event.part?.state?.status === 'error') {
926
- const { error } = event.part.state;
927
- toolErrors.push({
928
- tool: String(event.part.tool || ''),
929
- error: typeof error === 'string' ? error : JSON.stringify(error ?? ''),
930
- });
931
- } else if (event.type === 'error') {
932
- errorMessage = event.error?.data?.message || event.error?.message || 'OpenCode turn failed';
933
- }
934
- } catch {
935
- // JSONL stdout should be parseable. Ignore an unrelated line rather than
936
- // turning a useful final text event into an adapter failure.
937
- }
938
- };
939
- return {
940
- get turnOutput() { return turnOutput; },
941
- get stepStarted() { return stepStarted; },
942
- consume(chunk) {
943
- buffer += chunk.toString();
944
- let newline;
945
- // eslint-disable-next-line no-cond-assign
946
- while ((newline = buffer.indexOf('\n')) !== -1) {
947
- consumeLine(buffer.slice(0, newline));
948
- buffer = buffer.slice(newline + 1);
949
- }
950
- },
951
- flush() {
952
- consumeLine(buffer);
953
- buffer = '';
954
- },
955
- get sessionId() { return sessionId; },
956
- get text() { return text; },
957
- get usage() { return usage; },
958
- get errorMessage() { return errorMessage; },
959
- get toolErrors() { return toolErrors; },
960
- };
961
- };
962
-
963
- // A public seat must never show a stranger its own plumbing. When a Commonly
964
- // tool can't reach the API, the model is left narrating the failure, and the
965
- // run loop posted that narration: on 2026-10-21 a canary of Commonly HQ's
966
- // support seat (this adapter, its prompt, an unreachable API) told the asker
967
- // "every Commonly tool call (`list_pods`, `list_artifacts`) is returning
968
- // `fetch failed`", and an instruction to answer NO_REPLY in that case held in
969
- // only 1 of 3 turns. So the turn is withheld here instead: nothing is posted,
970
- // and the run loop keeps the event unacked for its retry.
971
- //
972
- // OpenCode names an MCP tool `<server>_<tool>`, and a failed call reports
973
- // `state.status: 'error'` with the message in `state.error` (measured on
974
- // OpenCode 1.19.0). Only tools of the declared Commonly server count. Of
975
- // those, only the errors `isAnswerableToolError` names (an API refusal the
976
- // person can be told about, or a schema mistake the model fixes) leave the
977
- // turn to be posted; any other failure withholds it. This adapter-level check
978
- // stays beside the adapter-agnostic readiness witness (TASK-256): it sees the
979
- // runtime's own view of each call, and the witness sees a server that never
980
- // started, so each catches what the other cannot.
981
- export { PUBLIC_TOOL_FAILED_CODE };
982
-
983
- // F421. OpenCode's stream says nothing about its MCP servers. When the Commonly
984
- // server fails to start, OpenCode 1.19.0 logs one WARN line ("server
985
- // unavailable … status=failed"), runs the model without the Commonly tools, and
986
- // exits 0, so the reply would be an answer written without reading the pod
987
- // (measured with a stub provider on 2026-11-11). The server creates the file
988
- // this adapter names in COMMONLY_MCP_READY_FILE once OpenCode asks for its
989
- // tools (tools/list, sent only after initialize succeeded), inside the folder the sandbox lets the seat write (OpenCode's own
990
- // data home: Seatbelt's statePath, bwrap's write-outside). A turn whose file is
991
- // missing never had the server, so its reply is withheld and the run loop tells
992
- // the waiting pod why (turn-failure-notice.js) and retries.
993
- export { MCP_READY_FILE_ENV };
994
-
995
- export const missingReadyFile = (readyFiles, exists = existsSync) => (
996
- (readyFiles || []).find(({ path }) => !exists(path)) || null
997
- );
998
-
999
- const publicMcpNotReadyError = (name) => {
1000
- const failure = new Error(
1001
- `public seat withheld this turn's reply: Space MCP server ${name} never reported ready (no ready file), `
1002
- + 'so the model ran without its Space tools; the CLI posted no reply for it and does not ack the event',
1003
- );
1004
- failure.code = PUBLIC_MCP_NOT_READY_CODE;
1005
- return failure;
1006
- };
1007
-
1008
- export const publicToolFailure = (toolErrors, mcpServers) => {
1009
- const prefixes = (Array.isArray(mcpServers) ? mcpServers : [])
1010
- .filter((server) => server?.name && isShippedCommonlyMcpCommand(server.command))
1011
- .map((server) => `${server.name}_`);
1012
- if (!prefixes.length) return null;
1013
- return (toolErrors || []).find(({ tool, error }) => (
1014
- prefixes.some((prefix) => tool.startsWith(prefix)) && !isAnswerableToolError(error)
1015
- )) || null;
1016
- };
1017
-
1018
- const publicToolFailedError = ({ tool, error }) => {
1019
- const reason = String(error).replace(/\s+/g, ' ').slice(0, 160) || '(empty error)';
1020
- const failure = new Error(
1021
- `public seat withheld this turn's reply: Space tool ${tool} failed in a way the model must not narrate `
1022
- + `(${reason}), so the CLI posted no reply for it (posts the agent made through its tools stay) and the CLI does not ack the event (one the agent already checkpointed stays acked)`,
1023
- );
1024
- failure.code = PUBLIC_TOOL_FAILED_CODE;
1025
- return failure;
1026
- };
1027
-
1028
- const runOpenCode = ({
1029
- cmd, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {}, onFirstStep,
1030
- }) => new Promise((resolve, reject) => {
1031
- let proc;
1032
- try {
1033
- proc = spawnImpl(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
1034
- } catch (err) {
1035
- reject(err);
1036
- return;
1037
- }
1038
- observeSpawn(proc, onSpawn, controls);
1039
- let stderr = '';
1040
- let timedOut = false;
1041
- let killTimer = null;
1042
- const events = makeEventParser();
1043
- const timer = setTimeout(() => {
1044
- timedOut = true;
1045
- proc.kill('SIGTERM');
1046
- killTimer = setTimeout(() => proc.kill('SIGKILL'), TERMINATION_GRACE_MS);
1047
- }, timeoutMs);
1048
- const clearTimers = () => {
1049
- clearTimeout(timer);
1050
- if (killTimer) clearTimeout(killTimer);
1051
- };
1052
- let stepSeen = false;
1053
- let stepHookError = null;
1054
- proc.stdout?.on('data', (chunk) => {
1055
- events.consume(chunk);
1056
- if (!stepSeen && events.stepStarted) {
1057
- stepSeen = true;
1058
- // Synchronous, in the same tick the event is parsed, so the gap before
1059
- // the model's first tool call is as short as this process can make it.
1060
- // A hook that throws ends the turn: the child is killed, and the error
1061
- // is the turn's result.
1062
- try {
1063
- onFirstStep?.();
1064
- } catch (err) {
1065
- stepHookError = err;
1066
- proc.kill('SIGKILL');
1067
- }
1068
- }
1069
- });
1070
- proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
1071
- proc.on('error', (err) => {
1072
- clearTimers();
1073
- reject(err);
1074
- });
1075
- proc.on('close', (code) => {
1076
- clearTimers();
1077
- events.flush();
1078
- if (stepHookError) return reject(stepHookError);
1079
- if (timedOut) return reject(new Error(`opencode timed out after ${timeoutMs}ms`));
1080
- if (events.errorMessage) {
1081
- const err = adapterFailure('opencode turn failed', events.errorMessage, {
1082
- credentials, limit: 2000,
1083
- });
1084
- err.staleSession = !events.turnOutput && STALE_SESSION_PATTERN.test(events.errorMessage);
1085
- return reject(err);
1086
- }
1087
- if (code !== 0) {
1088
- const err = adapterFailure('opencode', stderr.trim(), {
1089
- credentials, exitCode: code, limit: 500,
1090
- });
1091
- err.staleSession = !events.turnOutput && STALE_SESSION_PATTERN.test(stderr);
1092
- return reject(err);
1093
- }
1094
- resolve({
1095
- sessionId: events.sessionId, text: events.text, usage: events.usage, toolErrors: events.toolErrors,
1096
- });
1097
- });
1098
- });
1099
-
1100
- const seatIdentity = (ctx) => [
1101
- ctx.agentName || 'agent',
1102
- ctx.metadata?.event?.podId || 'default-pod',
1103
- ].join('\0');
1104
-
1105
- /** A public seat's stable data home: Seatbelt's statePath, bwrap's write-outside. */
1106
- const openCodeSeatRoot = (ctx) => ctx._opencodeHomeRoot
1107
- || join(homedir(), '.commonly', 'opencode-homes', createHash('sha256').update(seatIdentity(ctx)).digest('hex').slice(0, 24));
1108
-
1109
- const prepareOpenCodeDataHome = async (ctx, { publicSeat = false } = {}) => {
1110
- const operatorDataHome = ctx.env?.XDG_DATA_HOME
1111
- || process.env.XDG_DATA_HOME
1112
- || join(homedir(), '.local', 'share');
1113
- if (!publicSeat) {
1114
- return {
1115
- root: null,
1116
- appData: null,
1117
- dataHome: ctx.env?.XDG_DATA_HOME || process.env.XDG_DATA_HOME || null,
1118
- authPath: null,
1119
- };
1120
- }
1121
- const root = openCodeSeatRoot(ctx);
1122
- const operatorAppData = join(pathResolve(operatorDataHome), 'opencode');
1123
- if (pathResolve(root) === pathResolve(operatorAppData)) {
1124
- throw new Error('OpenCode seat data must be isolated from the operator OpenCode data directory');
1125
- }
1126
- const rootStat = await statOrNull(root);
1127
- if (rootStat && (!rootStat.isDirectory() || rootStat.isSymbolicLink())) {
1128
- throw new Error(`refusing non-directory OpenCode seat state: ${root}`);
1129
- }
1130
- const dataHome = join(root, 'data');
1131
- const appData = join(dataHome, 'opencode');
1132
- await mkdir(appData, { recursive: true, mode: 0o700 });
1133
- for (const path of [dataHome, appData]) {
1134
- const pathStat = await statOrNull(path);
1135
- if (!pathStat?.isDirectory() || pathStat.isSymbolicLink()) {
1136
- throw new Error(`refusing non-directory OpenCode seat state: ${path}`);
1137
- }
1138
- }
1139
- await chmod(root, 0o700);
1140
- await chmod(dataHome, 0o700);
1141
- await chmod(appData, 0o700);
1142
-
1143
- const sourceAuth = join(operatorAppData, 'auth.json');
1144
- const targetAuth = join(appData, 'auth.json');
1145
- if (publicSeat) {
1146
- // A public model must never inherit the operator's OpenCode login. The
1147
- // provider proxy is the only supported credential path for public seats.
1148
- const targetStat = await statOrNull(targetAuth);
1149
- if (targetStat && !targetStat.isSymbolicLink()) {
1150
- throw new Error(`refusing to replace non-symlink OpenCode seat credential: ${targetAuth}`);
1151
- }
1152
- if (targetStat) await unlink(targetAuth);
1153
- return {
1154
- root,
1155
- appData,
1156
- dataHome,
1157
- authPath: null,
1158
- };
1159
- }
1160
- const sourceStat = await statOrNull(sourceAuth);
1161
- const targetStat = await statOrNull(targetAuth);
1162
- const authPath = sourceStat ? realpathSync(sourceAuth) : null;
1163
- const resolvedSourceStat = authPath ? await statOrNull(authPath) : null;
1164
- if (sourceStat && (!resolvedSourceStat?.isFile() || resolvedSourceStat.isSymbolicLink())) {
1165
- throw new Error(`refusing non-regular operator OpenCode credential: ${sourceAuth}`);
1166
- }
1167
- if (targetStat && !targetStat.isSymbolicLink()) {
1168
- throw new Error(`refusing to replace non-symlink OpenCode seat credential: ${targetAuth}`);
1169
- }
1170
- if (sourceStat) {
1171
- const currentTarget = targetStat
1172
- ? pathResolve(dirname(targetAuth), await readlink(targetAuth))
1173
- : null;
1174
- if (currentTarget !== pathResolve(authPath)) {
1175
- if (targetStat) await unlink(targetAuth);
1176
- await symlink(authPath, targetAuth);
1177
- }
1178
- } else if (targetStat) {
1179
- await unlink(targetAuth);
1180
- }
1181
- return { root, appData, dataHome, authPath };
1182
- };
1183
-
1184
- const sourceAuthEnvNames = (env) => Object.keys(env || {}).filter((name) => (
1185
- /(?:^|_)(?:API_KEY|ACCESS_TOKEN|ACCESS_KEY|CLIENT_SECRET|PASSWORD|TOKEN|SECRET)$/i.test(name)
1186
- ));
1187
-
1188
- const makeChildEnvironment = ({
1189
- source,
1190
- tempDir,
1191
- configPath,
1192
- configDir,
1193
- dataHome,
1194
- stateHome,
1195
- cacheHome,
1196
- home,
1197
- tempWork,
1198
- credentialFile,
1199
- publicSeat = false,
1200
- }) => {
1201
- const env = { ...source };
1202
- // A parent launcher may itself have received injected OpenCode config. These
1203
- // higher-precedence values must not be inherited into a seat spawn.
1204
- for (const key of [
1205
- 'OPENCODE_CONFIG_CONTENT', 'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR',
1206
- 'OPENCODE_TUI_CONFIG', 'OPENCODE_AUTO_SHARE',
1207
- ]) delete env[key];
1208
- env.OPENCODE_CONFIG = configPath;
1209
- env.OPENCODE_CONFIG_DIR = configDir;
1210
- env.OPENCODE_DISABLE_PROJECT_CONFIG = '1';
1211
- env.XDG_CONFIG_HOME = join(tempDir, 'xdg-config');
1212
- if (dataHome) env.XDG_DATA_HOME = dataHome;
1213
- else delete env.XDG_DATA_HOME;
1214
- if (stateHome) env.XDG_STATE_HOME = stateHome;
1215
- if (cacheHome) env.XDG_CACHE_HOME = cacheHome;
1216
- if (home) env.HOME = home;
1217
- env.TMPDIR = tempWork;
1218
- env.OPENCODE_AUTO_SHARE = 'false';
1219
- for (const key of DISABLED_ENV) env[key] = '1';
1220
- if (publicSeat) Object.assign(env, PUBLIC_GIT_ENV);
1221
- return withholdRuntimeCredential(env, { credentialFile });
1222
- };
1223
-
1224
- const spawnBinaryPath = (env) => {
1225
- try {
1226
- const where = spawnSync('which', ['opencode'], { encoding: 'utf8', env });
1227
- if (where.status === 0 && typeof where.stdout === 'string' && where.stdout.trim()) {
1228
- return realpathSync(where.stdout.trim());
1229
- }
1230
- } catch { /* use PATH resolution in spawn */ }
1231
- return 'opencode';
1232
- };
1233
-
1234
- const parseOpenCodeVersion = (output) => {
1235
- const match = String(output || '').trim().match(
1236
- /^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i,
1237
- );
1238
- return match?.[1] || null;
1239
- };
1240
-
1241
- const readOpenCodeVersion = (binary, env) => {
1242
- try {
1243
- const probeEnv = { ...env };
1244
- for (const key of DISABLED_ENV) probeEnv[key] = '1';
1245
- const result = spawnSync(binary, ['--version'], {
1246
- encoding: 'utf8', env: probeEnv, timeout: VERSION_CHECK_TIMEOUT_MS,
1247
- });
1248
- if (result.error || result.status !== 0) return null;
1249
- return parseOpenCodeVersion(result.stdout);
1250
- } catch {
1251
- return null;
1252
- }
1253
- };
1254
-
1255
- const isOpenCodePrerelease = (version) => version.split('+', 1)[0].includes('-');
1256
-
1257
- const compareOpenCodeVersions = (left, right) => {
1258
- const leftParts = left.split(/[+-]/, 1)[0].split('.').map(Number);
1259
- const rightParts = right.split(/[+-]/, 1)[0].split('.').map(Number);
1260
- for (let index = 0; index < 3; index += 1) {
1261
- if (leftParts[index] !== rightParts[index]) {
1262
- return leftParts[index] > rightParts[index] ? 1 : -1;
1263
- }
1264
- }
1265
- // The smoke-tested boundaries are stable releases. A matching prerelease
1266
- // must not inherit their native signature-replay guarantee.
1267
- const leftPrerelease = isOpenCodePrerelease(left);
1268
- const rightPrerelease = isOpenCodePrerelease(right);
1269
- if (leftPrerelease !== rightPrerelease) return leftPrerelease ? -1 : 1;
1270
- return 0;
1271
- };
1272
-
1273
- const describeOpenCodeVersionMismatch = (installedVersion) => {
1274
- const tested = TESTED_OPENCODE_VERSIONS.join(', ');
1275
- if (!installedVersion) return `installed OpenCode version is unknown; smoke-tested versions: ${tested}`;
1276
- const oldest = TESTED_OPENCODE_VERSIONS[0];
1277
- const newest = TESTED_OPENCODE_VERSIONS[TESTED_OPENCODE_VERSIONS.length - 1];
1278
- if (compareOpenCodeVersions(installedVersion, newest) > 0) {
1279
- return `installed OpenCode ${installedVersion} is newer than smoke-tested versions ${tested}`;
1280
- }
1281
- if (compareOpenCodeVersions(installedVersion, oldest) < 0) {
1282
- return `installed OpenCode ${installedVersion} is older than smoke-tested versions ${tested}`;
1283
- }
1284
- return `installed OpenCode ${installedVersion} differs from smoke-tested versions ${tested}`;
1285
- };
1286
-
1287
- export default {
1288
- get requiresModel() { return seatModelRequired(); },
1289
- name: 'opencode',
1290
- runtimeType: 'opencode',
1291
- supportsProvider: true,
1292
- // `provider.api`: an OpenAI-compatible endpoint, or Anthropic's Messages API
1293
- // through OpenCode's own @ai-sdk/anthropic provider.
1294
- providerApis: ['openai-chat', 'anthropic-messages'],
1295
-
1296
- async validateEnvironment(environment, workspacePath) {
1297
- return validateProviderEnvironment(environment, workspacePath);
1298
- },
1299
-
1300
- async detect() {
1301
- try {
1302
- const res = spawnSync('opencode', ['--version'], { encoding: 'utf8', timeout: VERSION_CHECK_TIMEOUT_MS });
1303
- if (res.error || res.status !== 0) return null;
1304
- const stdout = (res.stdout || '').trim();
1305
- const version = parseOpenCodeVersion(stdout) || stdout || 'unknown';
1306
- const where = spawnSync('which', ['opencode'], { encoding: 'utf8' });
1307
- const path = where.status === 0 ? (where.stdout || '').trim() || 'opencode' : 'opencode';
1308
- return { path, version };
1309
- } catch {
1310
- return null;
1311
- }
1312
- },
1313
-
1314
- async spawn(prompt, rawCtx = {}) {
1315
- try {
1316
- return await this.spawnTurn(prompt, rawCtx);
1317
- } catch (err) {
1318
- if (!rawCtx.sessionId || !isStaleSessionError(err)) throw err;
1319
- // The saved id is one OpenCode does not know: a seat switched from another
1320
- // adapter carries a Claude id, and a pruned session fails the same way. Nothing
1321
- // ran, so one fresh turn is safe (pi #1726 does the same fallback).
1322
- return this.spawnTurn(prompt, { ...rawCtx, sessionId: null });
1323
- }
1324
- },
1325
-
1326
- async spawnTurn(prompt, rawCtx = {}) {
1327
- const seatModel = requireSeatModel(rawCtx.environment, 'opencode');
1328
- const sandbox = normalizeSandboxTrust(rawCtx.environment?.sandbox);
1329
- refuseWorkspaceTrust(sandbox, 'opencode');
1330
- const ctx = rawCtx.environment
1331
- ? { ...rawCtx, environment: { ...rawCtx.environment, sandbox } }
1332
- : rawCtx;
1333
- if (isLegacySandboxTrust(rawCtx.environment?.sandbox)) {
1334
- // eslint-disable-next-line no-console
1335
- console.warn('[opencode] legacy sandbox.trust=internal resolves to public and remains confined');
1336
- }
1337
- const isPublic = sandbox?.trust === 'public';
1338
- const sandboxMode = isPublic ? resolvePublicSandboxMode(sandbox) : null;
1339
- if (isPublic && !['workspace', 'read-only', 'bwrap'].includes(sandboxMode)) {
1340
- throw new Error(`public OpenCode agents require an enforced sandbox mode, got ${sandboxMode || 'unset'}`);
1341
- }
1342
-
1343
- const provider = ctx.environment?.provider;
1344
- if (isPublic && !provider) {
1345
- throw new Error('public OpenCode seats require environment.provider; the implicit public tier and operator auth.json are unsupported for public trust');
1346
- }
1347
- if (ctx.providerKey && (!isPublic || !['workspace', 'read-only', 'bwrap'].includes(sandboxMode))) {
1348
- throw new Error('Own provider keys require a confined OpenCode runtime');
1349
- }
1350
- // The public seat home is readable and writable in the sandbox, so a key
1351
- // file there is refused with the platform roots, before anything opens it.
1352
- const providerKeyFile = ctx.providerKey ? null : await validateProviderEnvironment(ctx.environment, ctx.cwd, {
1353
- platform: ctx._platform, seatDirectories: isPublic ? [openCodeSeatRoot(ctx)] : [],
1354
- });
1355
-
1356
- const sourceEnv = ctx.env || process.env;
1357
- const childSourceEnv = Object.fromEntries(Object.entries(sourceEnv)
1358
- .filter(([key]) => key !== ALLOW_UNTESTED_OPENCODE_VERSION_ENV));
1359
- const runtimeEnv = isPublic
1360
- ? Object.fromEntries(Object.entries(childSourceEnv).filter(([key]) => PUBLIC_SAFE_ENV.test(key)))
1361
- : childSourceEnv;
1362
- const binary = ctx._binaryPath || spawnBinaryPath(runtimeEnv);
1363
- const installedVersion = readOpenCodeVersion(binary, runtimeEnv);
1364
- // Trusted seats otherwise allow old binaries. Gemini requires a stable
1365
- // release with native signature replay now that the proxy shim is gone.
1366
- if (provider && new URL(provider.baseURL).hostname.toLowerCase() === 'generativelanguage.googleapis.com'
1367
- && (!installedVersion || isOpenCodePrerelease(installedVersion)
1368
- || compareOpenCodeVersions(installedVersion, TESTED_OPENCODE_VERSIONS[0]) < 0)) {
1369
- throw new Error(
1370
- `Gemini requires stable OpenCode ${TESTED_OPENCODE_VERSIONS[0]} or later for native thought-signature replay; `
1371
- + `installed version is ${installedVersion || 'unknown'}`,
1372
- );
1373
- }
1374
- if (!TESTED_OPENCODE_VERSIONS.includes(installedVersion)) {
1375
- const mismatch = describeOpenCodeVersionMismatch(installedVersion);
1376
- const allowOverride = process.env[ALLOW_UNTESTED_OPENCODE_VERSION_ENV] === '1';
1377
- const patchAfterTested = isPatchAfterNewestTested(installedVersion, TESTED_OPENCODE_VERSIONS);
1378
- if (isPublic && !allowOverride && !patchAfterTested) {
1379
- throw new Error(
1380
- `Public OpenCode seats refuse to run: ${mismatch}. `
1381
- + `To override from the host process, set ${ALLOW_UNTESTED_OPENCODE_VERSION_ENV}=1.`,
1382
- );
1383
- }
1384
- let outcome = 'trusted seat continues';
1385
- if (isPublic) {
1386
- outcome = allowOverride
1387
- ? `continuing because the host process set ${ALLOW_UNTESTED_OPENCODE_VERSION_ENV}=1`
1388
- : 'a patch release on the newest smoke-tested line, so the public seat continues';
1389
- }
1390
- // eslint-disable-next-line no-console
1391
- console.warn(`[opencode] ${mismatch}; ${outcome}`);
1392
- }
1393
-
1394
- const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, {
1395
- freshSession: !ctx.sessionId,
1396
- // F118: a public seat reads its memory but is not offered a write.
1397
- memoryReadOnly: isPublic && ctx.ownerWoken !== true,
1398
- });
1399
- const tempDir = await mkdtemp(join(ctx._tempRoot || tmpdir(), 'commonly-opencode-'));
1400
- await chmod(tempDir, 0o700);
1401
- const configPath = join(tempDir, 'opencode.json');
1402
- const configDir = join(tempDir, 'config-dir');
1403
- const xdgConfig = join(tempDir, 'xdg-config');
1404
- const home = isPublic ? join(tempDir, 'home') : null;
1405
- // OpenCode initializes its global and XDG config directories during
1406
- // startup, so create those roots and their ignore files before wrapping.
1407
- // Keep public HOME per-spawn, but leave $HOME/.opencode absent: OpenCode
1408
- // loads it after OPENCODE_CONFIG when present, which could persist config
1409
- // or MCP injection into later spawns.
1410
- const xdgConfigDir = join(xdgConfig, 'opencode');
1411
- await Promise.all([
1412
- mkdir(configDir),
1413
- mkdir(xdgConfigDir, { recursive: true }),
1414
- ]);
1415
- await Promise.all([configDir, xdgConfigDir].map((directory) => writeFile(
1416
- join(directory, '.gitignore'),
1417
- OPENCODE_CONFIG_GITIGNORE,
1418
- { encoding: 'utf8', mode: 0o600, flag: 'wx' },
1419
- )));
1420
- const credential = writeCredentialFile(ctx.runtimeToken, {
1421
- agentName: ctx.agentName || 'agent',
1422
- root: tempDir,
1423
- });
1424
- let openCodeHome = null;
1425
- let readyFileDir = null;
1426
- const readyFiles = [];
1427
- let providerProxy = null;
1428
- let credentialRelease = null;
1429
- let instanceProxy = null;
1430
- try {
1431
- openCodeHome = await prepareOpenCodeDataHome(ctx, { publicSeat: isPublic });
1432
- if (isPublic && provider) {
1433
- // OpenCode can spend the configured model budget through this proxy;
1434
- // the bearer only prevents unrelated local processes from using it.
1435
- providerProxy = await startProviderProxy({
1436
- provider,
1437
- wire: provider.api || 'openai-chat',
1438
- keyFile: providerKeyFile, keyMaterial: ctx.providerKey,
1439
- tokenFile: join(tempDir, 'provider-proxy-token'),
1440
- ...(sandboxMode === 'bwrap' ? {
1441
- socketPath: join(tempDir, 'provider-proxy.sock'),
1442
- isolatedPort: ISOLATED_PROXY_PORT,
1443
- } : {}),
1444
- });
1445
- }
1446
- if (isPublic && sandboxMode === 'bwrap' && ctx.instanceUrl) {
1447
- instanceProxy = await startInstanceProxy({
1448
- instanceUrl: ctx.instanceUrl,
1449
- socketPath: join(tempDir, 'instance-proxy.sock'),
1450
- });
1451
- }
1452
- const stateHome = isPublic ? join(openCodeHome.root, 'state') : null;
1453
- readyFileDir = isPublic ? join(openCodeHome.root, 'mcp-ready') : null;
1454
- const cacheHome = isPublic ? join(openCodeHome.root, 'cache') : null;
1455
- const tempWork = isPublic ? join(openCodeHome.root, 'tmp') : join(tempDir, 'tmp');
1456
- await Promise.all([
1457
- mkdir(tempWork, { recursive: true, mode: 0o700 }),
1458
- ...(stateHome ? [mkdir(stateHome, { recursive: true, mode: 0o700 })] : []),
1459
- ...(cacheHome ? [mkdir(cacheHome, { recursive: true, mode: 0o700 })] : []),
1460
- ]);
1461
- const unavailableMcp = [];
1462
- const mcpReadDirectories = [];
1463
- const bearerFileServers = [];
1464
- if (readyFileDir) await mkdir(readyFileDir, { recursive: true, mode: 0o700 });
1465
- const mcp = buildMcpConfig(ctx.environment?.mcp, {
1466
- readyFileDir,
1467
- readyFiles,
1468
- mcpWritesReadyFile: ctx._mcpWritesReadyFile,
1469
- runtimeToken: ctx.runtimeToken,
1470
- instanceUrl: instanceProxy
1471
- ? `http://127.0.0.1:${ISOLATED_INSTANCE_PORT}` : ctx.instanceUrl,
1472
- credentialFile: credential?.path || null,
1473
- publicSeat: isPublic,
1474
- unavailableMcp,
1475
- mcpReadDirectories,
1476
- bearerFileServers,
1477
- });
1478
- if (unavailableMcp.length) throw publicMcpUnavailableError(unavailableMcp);
1479
- const config = {
1480
- permission: isPublic
1481
- ? publicPermissions(sandboxMode, Object.keys(mcp), ctx)
1482
- : 'allow',
1483
- share: 'disabled',
1484
- autoupdate: false,
1485
- // A public seat runs no LSP server or formatter: both execute workspace
1486
- // code (a planted tsserver.js, a prettier config) inside the jail, where
1487
- // the credential is readable. Off by OpenCode's default in 1.19.x
1488
- // (`if (!cfg.lsp)`, `if (!cfg.formatter)`); pinned here so a default
1489
- // flip in a later release changes nothing.
1490
- ...(isPublic ? { lsp: false, formatter: false } : {}),
1491
- ...(provider ? {
1492
- enabled_providers: [provider.id],
1493
- provider: buildProviderConfig(providerProxy
1494
- ? { ...provider, baseURL: providerProxy.baseURL }
1495
- : provider, ctx.environment.model, providerProxy?.tokenFile || providerKeyFile),
1496
- } : {}),
1497
- ...(Object.keys(mcp).length ? { mcp } : {}),
1498
- };
1499
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, {
1500
- encoding: 'utf8', mode: 0o600,
1501
- });
1502
- await chmod(configPath, 0o600);
1503
-
1504
- const childEnv = makeChildEnvironment({
1505
- source: runtimeEnv,
1506
- tempDir,
1507
- configPath,
1508
- configDir,
1509
- dataHome: openCodeHome.dataHome,
1510
- stateHome,
1511
- cacheHome,
1512
- home,
1513
- tempWork,
1514
- credentialFile: credential?.path || null,
1515
- publicSeat: isPublic,
1516
- });
1517
- if (isPublic) {
1518
- // After the child env exists: the git check runs under the very env
1519
- // OpenCode gets (its HOME, its XDG config, no GIT_* from the launcher).
1520
- assertSpawnRootOutsideWorkspace(ctx.cwd || process.cwd(), tempDir, {
1521
- secretRoots: [openCodeHome.root],
1522
- bounds: ctx._symlinkWalkBounds || SYMLINK_WALK_BOUNDS,
1523
- dependencyBounds: ctx._dependencyWalkBounds || DEPENDENCY_WALK_BOUNDS,
1524
- gitEnv: childEnv,
1525
- });
1526
- }
1527
- const args = buildArgs({
1528
- prompt: fullPrompt,
1529
- sessionId: ctx.sessionId,
1530
- cwd: ctx.cwd,
1531
- model: qualifiedProviderModel(provider, seatModel),
1532
- effort: ctx.environment?.effort,
1533
- title: ctx.agentName,
1534
- });
1535
- const platform = ctx._platform || process.platform;
1536
- let cmd = binary;
1537
- let spawnArgs = args;
1538
- if (isPublic && sandboxMode === 'bwrap') {
1539
- if (!binary.startsWith('/')) {
1540
- throw new Error('public OpenCode agents require opencode to resolve to an absolute executable path');
1541
- }
1542
- const bwrap = (ctx._detectBwrap || detectBwrap)();
1543
- if (!bwrap.available) throw new Error(`public OpenCode agents require bwrap: ${bwrap.error}`);
1544
- const wrapped = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([
1545
- process.execPath, LOOPBACK_BRIDGE,
1546
- providerProxy.socketPath, String(ISOLATED_PROXY_PORT),
1547
- instanceProxy?.socketPath || '-', String(ISOLATED_INSTANCE_PORT), binary, ...args,
1548
- ], {
1549
- ...ctx.environment,
1550
- sandbox: {
1551
- ...sandbox,
1552
- network: { ...sandbox?.network, policy: 'restricted' },
1553
- filesystem: {
1554
- ...(sandbox?.filesystem || {}),
1555
- 'write-outside': [
1556
- ...(Array.isArray(sandbox?.filesystem?.['write-outside'])
1557
- ? sandbox.filesystem['write-outside'] : []),
1558
- openCodeHome.root,
1559
- ],
1560
- },
1561
- },
1562
- }, {
1563
- workspacePath: ctx.cwd,
1564
- readOnlyPaths: [
1565
- tempDir,
1566
- binary,
1567
- process.execPath,
1568
- LOOPBACK_BRIDGE,
1569
- ...mcpReadDirectories,
1570
- ],
1571
- optionalReadOnlyPaths: ['/usr/sbin', '/sbin'],
1572
- enableLoopback: true,
1573
- });
1574
- [cmd, ...spawnArgs] = wrapped;
1575
- } else if (isPublic) {
1576
- if (platform !== 'darwin') {
1577
- throw new Error(`public OpenCode sandbox.mode=${sandboxMode} requires Seatbelt on macOS or bwrap on Linux`);
1578
- }
1579
- const wrapped = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([binary, ...args], {
1580
- workspacePath: ctx.cwd,
1581
- workspaceAccess: sandboxMode === 'read-only' ? 'read' : 'write',
1582
- executablePath: binary,
1583
- statePath: openCodeHome.root,
1584
- mcpConfigDir: tempDir,
1585
- // Public seat: the CLI made this workspace, so it is pinned (F197).
1586
- pinWorkspace: true,
1587
- loopbackNetworkPorts: providerProxy ? [providerProxy.port] : [],
1588
- readOnlyPaths: [],
1589
- readOnlyDirectories: mcpReadDirectories,
1590
- });
1591
- [cmd, ...spawnArgs] = wrapped;
1592
- }
1593
- const credentials = [...spawnCredentials(ctx, sourceAuthEnvNames(sourceEnv)), ctx.providerKey].filter(Boolean);
1594
- // F460: a public seat's credential goes at OpenCode's first step, once
1595
- // its ready files exist, or at the deadline, whichever comes first.
1596
- let releaseFailure = null;
1597
- let child = null;
1598
- const spawnImpl = (...spawnCall) => {
1599
- child = (ctx._spawnImpl || childSpawn)(...spawnCall);
1600
- return child;
1601
- };
1602
- if (isPublic && credential) {
1603
- credentialRelease = armCredentialRelease(credential, {
1604
- readyFiles: readyFiles.map(({ path }) => path),
1605
- ownTrigger: true,
1606
- label: 'public OpenCode seat',
1607
- // eslint-disable-next-line no-console
1608
- warn: (text) => console.warn(`[opencode] ${text}`),
1609
- onFailure: (err) => { releaseFailure = err; child?.kill?.('SIGKILL'); },
1610
- fs: { rmSync: ctx._rmSync || rmSync, existsSync: ctx._existsSync || existsSync },
1611
- ...ctx._credentialRelease,
1612
- });
1613
- if (credentialRelease.error) throw credentialRelease.error;
1614
- }
1615
- const result = await runOpenCode({
1616
- cmd,
1617
- args: spawnArgs,
1618
- cwd: ctx.cwd,
1619
- env: childEnv,
1620
- timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
1621
- credentials,
1622
- spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(0, -1), { modelFlag: '-m', effortFlag: '--variant', valueFlags: ['-s', '--dir', '--title', '--format'] }),
1623
- // CodeRabbit #3487, F460: a public seat's credential stops existing
1624
- // once OpenCode has read it, grant or not. Measured in
1625
- // the 1.19.4 binary: {file:} is expanded once, over the config text at
1626
- // load; every MCP connect (a reconnect included) takes its headers from
1627
- // that cached config; and `run` emits step_start only after tools are
1628
- // resolved and each MCP server, the shipped stdio one included (it reads
1629
- // COMMONLY_TOKEN_FILE at startup), has connected. A config reload after
1630
- // this point would fail on the missing file, closed, not open.
1631
- // A delete that fails leaves the credential readable, so the turn ends
1632
- // rather than run on with it. The ready files and the deadline (F460)
1633
- // cover a turn that never reaches its first step.
1634
- onFirstStep: credentialRelease
1635
- ? () => {
1636
- const failure = credentialRelease.release('stream');
1637
- if (failure) throw failure;
1638
- }
1639
- : undefined,
1640
- }).catch((err) => { throw releaseFailure || err; });
1641
- if (releaseFailure) throw releaseFailure;
1642
- if (isPublic) {
1643
- const notReady = missingReadyFile(readyFiles, ctx._existsSync);
1644
- if (notReady) throw publicMcpNotReadyError(notReady.name);
1645
- const failure = publicToolFailure(result.toolErrors, ctx.environment?.mcp);
1646
- if (failure) throw publicToolFailedError(failure);
1647
- }
1648
- return {
1649
- text: result.text.trim(),
1650
- newSessionId: result.sessionId || ctx.sessionId || null,
1651
- ...(result.usage ? { usage: result.usage } : {}),
1652
- };
1653
- } finally {
1654
- credentialRelease?.stop();
1655
- try { await instanceProxy?.close(); } catch { /* ignore */ }
1656
- try { await providerProxy?.close(); } catch { /* ignore */ }
1657
- try { await rm(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
1658
- for (const { path } of readyFiles) {
1659
- try { await rm(path, { force: true }); } catch { /* ignore */ }
1660
- }
1661
- }
1662
- },
1663
- };
1664
-
1665
- export {
1666
- ALLOW_UNTESTED_OPENCODE_VERSION_ENV,
1667
- DISABLED_ENV,
1668
- TESTED_OPENCODE_VERSIONS,
1669
- buildArgs,
1670
- buildMcpConfig,
1671
- buildProviderConfig,
1672
- makeEventParser,
1673
- prepareOpenCodeDataHome,
1674
- publicPermissions,
1675
- readOpenCodeVersion,
1676
- qualifiedProviderModel,
1677
- resolveProviderKeyFile,
1678
- scanWorkspaceLinks,
1679
- assertSpawnRootOutsideWorkspace,
1680
- startProviderProxy,
1681
- };