@commonlyai/cli 0.1.88 → 0.1.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/agent.js +217 -14
- package/src/commands/daemon.js +2 -2
- package/src/lib/adapters/claude.js +2 -1
- package/src/lib/adapters/index.js +2 -0
- package/src/lib/adapters/opencode.js +923 -0
- package/src/lib/daemon-supervisor.js +34 -12
- package/src/lib/declared-mcp-guard.js +1 -1
- package/src/lib/default-environment.js +13 -5
- package/src/lib/enforcement.js +42 -7
- package/src/lib/environment.js +93 -3
- package/src/lib/grant-broker-guard.js +9 -9
- package/src/lib/sandbox/seatbelt.js +49 -30
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode adapter — wraps `opencode run --format json` as a Commonly seat.
|
|
3
|
+
*
|
|
4
|
+
* Tested against opencode-ai 1.18.35. OpenCode stores conversation sessions
|
|
5
|
+
* under XDG_DATA_HOME. Trusted seats use the operator's data home directly;
|
|
6
|
+
* public seats get per-identity state without operator auth.json and a child
|
|
7
|
+
* environment limited to safe launch/locale variables. Public provider keys
|
|
8
|
+
* stay in the host adapter and reach the configured provider through a
|
|
9
|
+
* per-spawn loopback proxy; OpenCode receives only that proxy's bearer.
|
|
10
|
+
* This protects the key, not provider spend: the model can use its seat's key
|
|
11
|
+
* budget, and bwrap's existing shared networking is not a per-port egress
|
|
12
|
+
* fence.
|
|
13
|
+
* Config is generated per spawn. OpenCode merges config sources, therefore the
|
|
14
|
+
* global config directory is isolated and project config is explicitly off;
|
|
15
|
+
* the environment spec is the sole source of MCP servers for this run.
|
|
16
|
+
*
|
|
17
|
+
* OpenCode's JSON stream carries `sessionID` on step_start, final text in
|
|
18
|
+
* `text.part.text`, and token counts in `step_finish.part.tokens`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
22
|
+
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
23
|
+
import { realpathSync } from 'fs';
|
|
24
|
+
import { constants as fsConstants } from 'fs';
|
|
25
|
+
import { createServer, request as httpRequest } from 'http';
|
|
26
|
+
import { request as httpsRequest } from 'https';
|
|
27
|
+
import {
|
|
28
|
+
chmod,
|
|
29
|
+
lstat,
|
|
30
|
+
mkdir,
|
|
31
|
+
mkdtemp,
|
|
32
|
+
open,
|
|
33
|
+
readlink,
|
|
34
|
+
rm,
|
|
35
|
+
unlink,
|
|
36
|
+
symlink,
|
|
37
|
+
writeFile,
|
|
38
|
+
} from 'fs/promises';
|
|
39
|
+
import { homedir, tmpdir } from 'os';
|
|
40
|
+
import {
|
|
41
|
+
dirname, isAbsolute, join, relative, resolve as pathResolve, sep,
|
|
42
|
+
} from 'path';
|
|
43
|
+
import { writeCredentialFile } from '../credential-file.js';
|
|
44
|
+
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
45
|
+
import { prepareMcpSpawn } from '../mcp-home.js';
|
|
46
|
+
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
47
|
+
import {
|
|
48
|
+
isLegacySandboxTrust,
|
|
49
|
+
normalizeSandboxTrust,
|
|
50
|
+
validateEnvironmentSpec,
|
|
51
|
+
} from '../environment.js';
|
|
52
|
+
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
53
|
+
import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
|
|
54
|
+
import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
|
|
55
|
+
import { resolvePublicSandboxMode } from '../sandbox/mode.js';
|
|
56
|
+
|
|
57
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
58
|
+
const TERMINATION_GRACE_MS = 5000;
|
|
59
|
+
const OPENCODE_CONFIG_GITIGNORE = [
|
|
60
|
+
'node_modules',
|
|
61
|
+
'package.json',
|
|
62
|
+
'package-lock.json',
|
|
63
|
+
'bun.lock',
|
|
64
|
+
'.gitignore',
|
|
65
|
+
].join('\n');
|
|
66
|
+
const DISABLED_ENV = [
|
|
67
|
+
'OPENCODE_DISABLE_CLAUDE_CODE',
|
|
68
|
+
'OPENCODE_DISABLE_CLAUDE_CODE_PROMPT',
|
|
69
|
+
'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS',
|
|
70
|
+
'OPENCODE_DISABLE_EXTERNAL_SKILLS',
|
|
71
|
+
'OPENCODE_DISABLE_DEFAULT_PLUGINS',
|
|
72
|
+
'OPENCODE_DISABLE_AUTOUPDATE',
|
|
73
|
+
];
|
|
74
|
+
const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
|
|
75
|
+
const PUBLIC_READ_DENIES = [
|
|
76
|
+
'**/.commonly/**',
|
|
77
|
+
'**/.codex/**',
|
|
78
|
+
'**/.env',
|
|
79
|
+
'**/*.env',
|
|
80
|
+
'**/.env.*',
|
|
81
|
+
'**/*.env.*',
|
|
82
|
+
];
|
|
83
|
+
const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
|
|
84
|
+
|
|
85
|
+
const statOrNull = async (path) => {
|
|
86
|
+
try {
|
|
87
|
+
return await lstat(path);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err?.code === 'ENOENT') return null;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const substitutePublicPlaceholders = (value, ctx) => {
|
|
95
|
+
if (typeof value !== 'string' || !value.includes('${COMMONLY_')) return value;
|
|
96
|
+
const replacements = {
|
|
97
|
+
COMMONLY_TOKEN_FILE: ctx.credentialFile || '',
|
|
98
|
+
COMMONLY_API_URL: ctx.instanceUrl || '',
|
|
99
|
+
COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
|
|
100
|
+
};
|
|
101
|
+
return value.replace(PLACEHOLDER_RE, (whole, key) => replacements[key] || whole);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const isSafeMcpName = (name) => typeof name === 'string'
|
|
105
|
+
&& /^[A-Za-z0-9_-]+$/.test(name);
|
|
106
|
+
|
|
107
|
+
const permissionMcpName = (name) => name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
108
|
+
|
|
109
|
+
const buildMcpConfig = (servers, ctx = {}) => {
|
|
110
|
+
const mcp = {};
|
|
111
|
+
const runtimeToken = typeof ctx.runtimeToken === 'string' ? ctx.runtimeToken : '';
|
|
112
|
+
for (const server of servers || []) {
|
|
113
|
+
if (!isSafeMcpName(server?.name)) continue;
|
|
114
|
+
const transport = typeof server.transport === 'string'
|
|
115
|
+
? server.transport.trim().toLowerCase()
|
|
116
|
+
: 'stdio';
|
|
117
|
+
|
|
118
|
+
if (transport === 'stdio') {
|
|
119
|
+
if (!Array.isArray(server.command) || server.command.length === 0) continue;
|
|
120
|
+
const command = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
|
|
121
|
+
if (runtimeToken && command.some((part) => typeof part === 'string' && part.includes(runtimeToken))) {
|
|
122
|
+
// A bearer token in argv is readable by same-user processes.
|
|
123
|
+
// eslint-disable-next-line no-console
|
|
124
|
+
console.warn(`[opencode] skipping MCP server ${server.name}: seat credentials cannot be passed in argv`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const delivery = deliverSeatCredential({ ...server, command }, {
|
|
128
|
+
credentialFile: ctx.credentialFile,
|
|
129
|
+
label: 'opencode',
|
|
130
|
+
});
|
|
131
|
+
const env = {};
|
|
132
|
+
let unsupportedCredential = false;
|
|
133
|
+
for (const [key, rawValue] of Object.entries(delivery.env)) {
|
|
134
|
+
if (typeof rawValue !== 'string') continue;
|
|
135
|
+
if (rawValue.includes('${COMMONLY_AGENT_TOKEN}')
|
|
136
|
+
&& delivery.delivered !== 'path') {
|
|
137
|
+
unsupportedCredential = true;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
const value = substitutePublicPlaceholders(rawValue, ctx);
|
|
141
|
+
if (runtimeToken && value.includes(runtimeToken)) {
|
|
142
|
+
unsupportedCredential = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
env[key] = value;
|
|
146
|
+
}
|
|
147
|
+
if (unsupportedCredential) {
|
|
148
|
+
// eslint-disable-next-line no-console
|
|
149
|
+
console.warn(`[opencode] skipping MCP server ${server.name}: its credential declaration has no file-backed delivery`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
mcp[server.name] = {
|
|
153
|
+
type: 'local',
|
|
154
|
+
command: command.map((part) => substitutePublicPlaceholders(part, ctx)),
|
|
155
|
+
enabled: true,
|
|
156
|
+
...(Object.keys(env).length ? { environment: env } : {}),
|
|
157
|
+
...(typeof server.cwd === 'string' ? { cwd: server.cwd } : {}),
|
|
158
|
+
};
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (transport !== 'http' || typeof server.url !== 'string') continue;
|
|
163
|
+
if (server.url.includes('${COMMONLY_AGENT_TOKEN}')) continue;
|
|
164
|
+
const url = substitutePublicPlaceholders(server.url, ctx);
|
|
165
|
+
// Runtime credentials only travel through OpenCode's measured {file:}
|
|
166
|
+
// header expansion, never as environment values or literal config text.
|
|
167
|
+
if (runtimeToken && url.includes(runtimeToken)) continue;
|
|
168
|
+
const headers = {};
|
|
169
|
+
let unsupportedCredential = false;
|
|
170
|
+
for (const [key, rawValue] of Object.entries(server.headers || {})) {
|
|
171
|
+
if (typeof rawValue !== 'string') continue;
|
|
172
|
+
let value = substitutePublicPlaceholders(rawValue, ctx);
|
|
173
|
+
if (rawValue.includes('${COMMONLY_AGENT_TOKEN}')) {
|
|
174
|
+
if (key.toLowerCase() !== 'authorization'
|
|
175
|
+
|| rawValue !== 'Bearer ${COMMONLY_AGENT_TOKEN}'
|
|
176
|
+
|| !ctx.credentialFile) {
|
|
177
|
+
unsupportedCredential = true;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
value = `Bearer {file:${ctx.credentialFile}}`;
|
|
181
|
+
}
|
|
182
|
+
if (runtimeToken && value.includes(runtimeToken)) {
|
|
183
|
+
unsupportedCredential = true;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
headers[key] = value;
|
|
187
|
+
}
|
|
188
|
+
if (unsupportedCredential) {
|
|
189
|
+
// eslint-disable-next-line no-console
|
|
190
|
+
console.warn(`[opencode] skipping MCP server ${server.name}: remote credentials must use the bearer file channel`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
mcp[server.name] = {
|
|
194
|
+
type: 'remote',
|
|
195
|
+
url,
|
|
196
|
+
enabled: true,
|
|
197
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return mcp;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const publicPermissions = (mode, mcpNames) => {
|
|
204
|
+
const read = { '*': 'allow' };
|
|
205
|
+
const edit = { '*': mode === 'read-only' ? 'deny' : 'allow' };
|
|
206
|
+
for (const pattern of PUBLIC_READ_DENIES) {
|
|
207
|
+
read[pattern] = 'deny';
|
|
208
|
+
edit[pattern] = 'deny';
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
'*': 'deny',
|
|
212
|
+
read,
|
|
213
|
+
edit,
|
|
214
|
+
glob: 'allow',
|
|
215
|
+
grep: 'allow',
|
|
216
|
+
list: 'allow',
|
|
217
|
+
bash: 'deny',
|
|
218
|
+
task: 'deny',
|
|
219
|
+
external_directory: 'deny',
|
|
220
|
+
question: 'deny',
|
|
221
|
+
webfetch: 'deny',
|
|
222
|
+
websearch: 'deny',
|
|
223
|
+
skill: 'deny',
|
|
224
|
+
todowrite: 'allow',
|
|
225
|
+
todoread: 'allow',
|
|
226
|
+
lsp: 'allow',
|
|
227
|
+
...Object.fromEntries(mcpNames.map((name) => [`${permissionMcpName(name)}_*`, 'allow'])),
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const buildArgs = ({ prompt, sessionId, cwd, model, effort, title }) => ([
|
|
232
|
+
'--pure',
|
|
233
|
+
'run',
|
|
234
|
+
'--format', 'json',
|
|
235
|
+
...(sessionId ? ['-s', String(sessionId)] : []),
|
|
236
|
+
...(model ? ['-m', String(model)] : []),
|
|
237
|
+
...(effort ? ['--variant', String(effort)] : []),
|
|
238
|
+
...(cwd ? ['--dir', cwd] : []),
|
|
239
|
+
...(title ? ['--title', String(title)] : []),
|
|
240
|
+
prompt,
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
const qualifiedProviderModel = (provider, model) => (
|
|
244
|
+
provider ? `${provider.id}/${model}` : model
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const buildProviderConfig = (provider, model, keyFile) => {
|
|
248
|
+
if (!provider) return undefined;
|
|
249
|
+
if (!model) throw new Error('OpenCode provider configuration requires environment.model');
|
|
250
|
+
// OpenCode 1.18.35 resolves {file:} in provider options, but failed before
|
|
251
|
+
// making a request when the selected id was absent from provider.models.
|
|
252
|
+
const models = { ...(provider.models || {}) };
|
|
253
|
+
const selectedModel = models[model];
|
|
254
|
+
if (!selectedModel) {
|
|
255
|
+
models[model] = { name: model };
|
|
256
|
+
} else if (typeof selectedModel.name !== 'string' || !selectedModel.name) {
|
|
257
|
+
models[model] = { ...selectedModel, name: model };
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
[provider.id]: {
|
|
261
|
+
npm: '@ai-sdk/openai-compatible',
|
|
262
|
+
options: {
|
|
263
|
+
baseURL: provider.baseURL,
|
|
264
|
+
apiKey: `{file:${keyFile}}`,
|
|
265
|
+
},
|
|
266
|
+
models,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const isPathWithin = (parent, candidate) => {
|
|
272
|
+
const fromParent = relative(parent, candidate);
|
|
273
|
+
return fromParent === '' || (fromParent !== '..'
|
|
274
|
+
&& !fromParent.startsWith(`..${sep}`)
|
|
275
|
+
&& !isAbsolute(fromParent));
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Validate without reading the key; OpenCode resolves its value from the file reference. */
|
|
279
|
+
const resolveProviderKeyFile = async (provider, workspacePath) => {
|
|
280
|
+
if (!provider) return null;
|
|
281
|
+
const keyFile = provider.keyFile;
|
|
282
|
+
if (typeof keyFile !== 'string' || !isAbsolute(keyFile)) {
|
|
283
|
+
throw new Error('OpenCode provider.keyFile must be an absolute path');
|
|
284
|
+
}
|
|
285
|
+
if (/[\r\n}]/.test(keyFile)) {
|
|
286
|
+
throw new Error('OpenCode provider.keyFile contains characters unsafe for a file reference');
|
|
287
|
+
}
|
|
288
|
+
let details;
|
|
289
|
+
try {
|
|
290
|
+
details = await lstat(keyFile);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new Error(`OpenCode provider key file is unavailable: ${err.message}`);
|
|
293
|
+
}
|
|
294
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
295
|
+
throw new Error('OpenCode provider.keyFile must be a regular, non-symlink file');
|
|
296
|
+
}
|
|
297
|
+
if ((details.mode & 0o777) !== 0o600) {
|
|
298
|
+
throw new Error('OpenCode provider.keyFile must have mode 0600');
|
|
299
|
+
}
|
|
300
|
+
if (typeof workspacePath !== 'string' || !isAbsolute(workspacePath)) {
|
|
301
|
+
throw new Error('OpenCode provider.keyFile requires an absolute workspace path');
|
|
302
|
+
}
|
|
303
|
+
let realKeyFile;
|
|
304
|
+
let realWorkspace;
|
|
305
|
+
try {
|
|
306
|
+
realKeyFile = realpathSync(keyFile);
|
|
307
|
+
realWorkspace = realpathSync(workspacePath);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
throw new Error(`OpenCode provider key path could not be resolved: ${err.message}`);
|
|
310
|
+
}
|
|
311
|
+
if (isPathWithin(realWorkspace, realKeyFile)) {
|
|
312
|
+
throw new Error('OpenCode provider.keyFile must be outside the workspace');
|
|
313
|
+
}
|
|
314
|
+
return realKeyFile;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const readProviderKey = async (keyFile) => {
|
|
318
|
+
const noFollow = fsConstants.O_NOFOLLOW || 0;
|
|
319
|
+
const handle = await open(keyFile, fsConstants.O_RDONLY | noFollow);
|
|
320
|
+
try {
|
|
321
|
+
const details = await handle.stat();
|
|
322
|
+
if (!details.isFile() || (details.mode & 0o777) !== 0o600) {
|
|
323
|
+
throw new Error('OpenCode provider.keyFile changed after validation; expected a regular mode-0600 file');
|
|
324
|
+
}
|
|
325
|
+
const value = (await handle.readFile()).toString('utf8').trim();
|
|
326
|
+
if (!value) throw new Error('OpenCode provider.keyFile must not be empty');
|
|
327
|
+
return value;
|
|
328
|
+
} finally {
|
|
329
|
+
await handle.close();
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const startProviderProxy = async ({ provider, keyFile, tokenFile }) => {
|
|
334
|
+
const upstream = new URL(provider.baseURL);
|
|
335
|
+
if (!['http:', 'https:'].includes(upstream.protocol)
|
|
336
|
+
|| upstream.username || upstream.password || upstream.search || upstream.hash) {
|
|
337
|
+
throw new Error('OpenCode provider.baseURL must be an http(s) URL without embedded credentials or query data');
|
|
338
|
+
}
|
|
339
|
+
const basePath = upstream.pathname.replace(/\/+$/, '') || '/';
|
|
340
|
+
const upstreamKey = await readProviderKey(keyFile);
|
|
341
|
+
const proxyToken = randomBytes(32).toString('base64url');
|
|
342
|
+
await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
|
|
343
|
+
await chmod(tokenFile, 0o600);
|
|
344
|
+
|
|
345
|
+
const server = createServer((incoming, outgoing) => {
|
|
346
|
+
const supplied = Buffer.from(incoming.headers.authorization || '');
|
|
347
|
+
const expected = Buffer.from(`Bearer ${proxyToken}`);
|
|
348
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
|
|
349
|
+
incoming.resume();
|
|
350
|
+
outgoing.writeHead(401).end();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!['GET', 'POST'].includes(incoming.method || '')) {
|
|
354
|
+
incoming.resume();
|
|
355
|
+
outgoing.writeHead(405).end();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const rejectRoute = (path) => {
|
|
359
|
+
incoming.resume();
|
|
360
|
+
outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
361
|
+
outgoing.end(`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
let target;
|
|
365
|
+
try {
|
|
366
|
+
const requestPath = incoming.url || '/';
|
|
367
|
+
if (!requestPath.startsWith('/') || requestPath.startsWith('//')) {
|
|
368
|
+
incoming.resume();
|
|
369
|
+
outgoing.writeHead(400).end();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (/%(?:2f|5c|2e)/i.test(requestPath)) {
|
|
373
|
+
incoming.resume();
|
|
374
|
+
outgoing.writeHead(400).end();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// OpenCode owns this local request path, so never use it as an outbound
|
|
378
|
+
// URL. The OpenAI-compatible chat provider needs only these fixed routes;
|
|
379
|
+
// parse the incoming URL against a loopback sentinel, then select a
|
|
380
|
+
// constant endpoint after matching the configured base path exactly.
|
|
381
|
+
// This keeps an OpenCode request from turning the proxy into an arbitrary
|
|
382
|
+
// path/URL fetcher (including paths on a local provider host).
|
|
383
|
+
const requested = new URL(requestPath, 'http://127.0.0.1');
|
|
384
|
+
if (requested.origin !== 'http://127.0.0.1' || requested.search || requested.hash) {
|
|
385
|
+
rejectRoute(requested.pathname);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const configuredPath = basePath === '/' ? '' : basePath;
|
|
389
|
+
let endpoint;
|
|
390
|
+
if (incoming.method === 'POST'
|
|
391
|
+
&& requested.pathname === `${configuredPath}/chat/completions`) {
|
|
392
|
+
endpoint = 'chat/completions';
|
|
393
|
+
} else if (incoming.method === 'GET'
|
|
394
|
+
&& requested.pathname === `${configuredPath}/models`) {
|
|
395
|
+
endpoint = 'models';
|
|
396
|
+
} else {
|
|
397
|
+
rejectRoute(requested.pathname);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
target = {
|
|
401
|
+
path: `${configuredPath}/${endpoint}`,
|
|
402
|
+
protocol: upstream.protocol,
|
|
403
|
+
hostname: upstream.hostname,
|
|
404
|
+
port: upstream.port || undefined,
|
|
405
|
+
host: upstream.host,
|
|
406
|
+
};
|
|
407
|
+
} catch {
|
|
408
|
+
incoming.resume();
|
|
409
|
+
outgoing.writeHead(400).end();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const headers = Object.fromEntries(Object.entries(incoming.headers)
|
|
414
|
+
.filter(([name]) => ![
|
|
415
|
+
'authorization', 'connection', 'host', 'keep-alive', 'proxy-authenticate',
|
|
416
|
+
'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
417
|
+
'x-api-key',
|
|
418
|
+
].includes(name.toLowerCase())));
|
|
419
|
+
headers.authorization = `Bearer ${upstreamKey}`;
|
|
420
|
+
headers.host = target.host;
|
|
421
|
+
const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
|
|
422
|
+
protocol: target.protocol,
|
|
423
|
+
hostname: target.hostname,
|
|
424
|
+
port: target.port,
|
|
425
|
+
path: target.path,
|
|
426
|
+
method: incoming.method,
|
|
427
|
+
headers,
|
|
428
|
+
}, (response) => {
|
|
429
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
430
|
+
response.resume();
|
|
431
|
+
outgoing.writeHead(502).end('Provider redirects are not supported');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const responseHeaders = Object.fromEntries(Object.entries(response.headers)
|
|
435
|
+
.filter(([name]) => ![
|
|
436
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
437
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
438
|
+
].includes(name.toLowerCase())));
|
|
439
|
+
outgoing.writeHead(response.statusCode || 502, responseHeaders);
|
|
440
|
+
response.pipe(outgoing);
|
|
441
|
+
});
|
|
442
|
+
request.on('error', () => {
|
|
443
|
+
if (!outgoing.headersSent) outgoing.writeHead(502);
|
|
444
|
+
outgoing.end('Provider request failed');
|
|
445
|
+
});
|
|
446
|
+
outgoing.on('close', () => request.destroy());
|
|
447
|
+
incoming.on('aborted', () => request.destroy());
|
|
448
|
+
incoming.pipe(request);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await new Promise((resolve, reject) => {
|
|
452
|
+
server.once('error', reject);
|
|
453
|
+
server.listen(0, '127.0.0.1', () => {
|
|
454
|
+
server.removeListener('error', reject);
|
|
455
|
+
resolve();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
const { port } = server.address();
|
|
459
|
+
const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString();
|
|
460
|
+
return {
|
|
461
|
+
baseURL: proxyBaseURL,
|
|
462
|
+
tokenFile,
|
|
463
|
+
port,
|
|
464
|
+
close: () => new Promise((resolve) => {
|
|
465
|
+
server.close(() => resolve());
|
|
466
|
+
server.closeAllConnections?.();
|
|
467
|
+
}),
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const validateProviderEnvironment = async (environment, workspacePath) => {
|
|
472
|
+
if (environment?.provider === undefined) return null;
|
|
473
|
+
const validation = validateEnvironmentSpec({
|
|
474
|
+
provider: environment.provider,
|
|
475
|
+
model: environment.model,
|
|
476
|
+
});
|
|
477
|
+
if (!validation.ok) {
|
|
478
|
+
throw new Error(`Invalid OpenCode provider configuration: ${validation.errors.join('; ')}`);
|
|
479
|
+
}
|
|
480
|
+
return resolveProviderKeyFile(environment.provider, workspacePath);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const makeEventParser = () => {
|
|
484
|
+
let buffer = '';
|
|
485
|
+
let sessionId = null;
|
|
486
|
+
let text = '';
|
|
487
|
+
let usage = null;
|
|
488
|
+
let errorMessage = null;
|
|
489
|
+
const consumeLine = (line) => {
|
|
490
|
+
if (!line.trim()) return;
|
|
491
|
+
try {
|
|
492
|
+
const event = JSON.parse(line);
|
|
493
|
+
if (event.sessionID) sessionId = event.sessionID;
|
|
494
|
+
if (event.type === 'text' && typeof event.part?.text === 'string') {
|
|
495
|
+
text += event.part.text;
|
|
496
|
+
} else if (event.type === 'step_finish' && event.part?.tokens) {
|
|
497
|
+
usage = event.part.tokens;
|
|
498
|
+
} else if (event.type === 'error') {
|
|
499
|
+
errorMessage = event.error?.data?.message || event.error?.message || 'OpenCode turn failed';
|
|
500
|
+
}
|
|
501
|
+
} catch {
|
|
502
|
+
// JSONL stdout should be parseable. Ignore an unrelated line rather than
|
|
503
|
+
// turning a useful final text event into an adapter failure.
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
return {
|
|
507
|
+
consume(chunk) {
|
|
508
|
+
buffer += chunk.toString();
|
|
509
|
+
let newline;
|
|
510
|
+
// eslint-disable-next-line no-cond-assign
|
|
511
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
512
|
+
consumeLine(buffer.slice(0, newline));
|
|
513
|
+
buffer = buffer.slice(newline + 1);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
flush() {
|
|
517
|
+
consumeLine(buffer);
|
|
518
|
+
buffer = '';
|
|
519
|
+
},
|
|
520
|
+
get sessionId() { return sessionId; },
|
|
521
|
+
get text() { return text; },
|
|
522
|
+
get usage() { return usage; },
|
|
523
|
+
get errorMessage() { return errorMessage; },
|
|
524
|
+
};
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
528
|
+
let proc;
|
|
529
|
+
try {
|
|
530
|
+
proc = spawnImpl(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
531
|
+
} catch (err) {
|
|
532
|
+
reject(err);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
let stderr = '';
|
|
536
|
+
let timedOut = false;
|
|
537
|
+
let killTimer = null;
|
|
538
|
+
const events = makeEventParser();
|
|
539
|
+
const timer = setTimeout(() => {
|
|
540
|
+
timedOut = true;
|
|
541
|
+
proc.kill('SIGTERM');
|
|
542
|
+
killTimer = setTimeout(() => proc.kill('SIGKILL'), TERMINATION_GRACE_MS);
|
|
543
|
+
}, timeoutMs);
|
|
544
|
+
const clearTimers = () => {
|
|
545
|
+
clearTimeout(timer);
|
|
546
|
+
if (killTimer) clearTimeout(killTimer);
|
|
547
|
+
};
|
|
548
|
+
proc.stdout?.on('data', (chunk) => events.consume(chunk));
|
|
549
|
+
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
550
|
+
proc.on('error', (err) => {
|
|
551
|
+
clearTimers();
|
|
552
|
+
reject(err);
|
|
553
|
+
});
|
|
554
|
+
proc.on('close', (code) => {
|
|
555
|
+
clearTimers();
|
|
556
|
+
events.flush();
|
|
557
|
+
if (timedOut) return reject(new Error(`opencode timed out after ${timeoutMs}ms`));
|
|
558
|
+
if (events.errorMessage) {
|
|
559
|
+
return reject(adapterFailure('opencode turn failed', events.errorMessage, {
|
|
560
|
+
credentials, limit: 2000,
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
if (code !== 0) {
|
|
564
|
+
return reject(adapterFailure('opencode', stderr.trim(), {
|
|
565
|
+
credentials, exitCode: code, limit: 500,
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
resolve({ sessionId: events.sessionId, text: events.text, usage: events.usage });
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const seatIdentity = (ctx) => [
|
|
573
|
+
ctx.agentName || 'agent',
|
|
574
|
+
ctx.metadata?.event?.podId || 'default-pod',
|
|
575
|
+
].join('\0');
|
|
576
|
+
|
|
577
|
+
const prepareOpenCodeDataHome = async (ctx, { publicSeat = false } = {}) => {
|
|
578
|
+
const operatorDataHome = ctx.env?.XDG_DATA_HOME
|
|
579
|
+
|| process.env.XDG_DATA_HOME
|
|
580
|
+
|| join(homedir(), '.local', 'share');
|
|
581
|
+
if (!publicSeat) {
|
|
582
|
+
return {
|
|
583
|
+
root: null,
|
|
584
|
+
appData: null,
|
|
585
|
+
dataHome: ctx.env?.XDG_DATA_HOME || process.env.XDG_DATA_HOME || null,
|
|
586
|
+
authPath: null,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const identity = createHash('sha256').update(seatIdentity(ctx)).digest('hex').slice(0, 24);
|
|
590
|
+
const root = ctx._opencodeHomeRoot || join(homedir(), '.commonly', 'opencode-homes', identity);
|
|
591
|
+
const operatorAppData = join(pathResolve(operatorDataHome), 'opencode');
|
|
592
|
+
if (pathResolve(root) === pathResolve(operatorAppData)) {
|
|
593
|
+
throw new Error('OpenCode seat data must be isolated from the operator OpenCode data directory');
|
|
594
|
+
}
|
|
595
|
+
const rootStat = await statOrNull(root);
|
|
596
|
+
if (rootStat && (!rootStat.isDirectory() || rootStat.isSymbolicLink())) {
|
|
597
|
+
throw new Error(`refusing non-directory OpenCode seat state: ${root}`);
|
|
598
|
+
}
|
|
599
|
+
const dataHome = join(root, 'data');
|
|
600
|
+
const appData = join(dataHome, 'opencode');
|
|
601
|
+
await mkdir(appData, { recursive: true, mode: 0o700 });
|
|
602
|
+
for (const path of [dataHome, appData]) {
|
|
603
|
+
const pathStat = await statOrNull(path);
|
|
604
|
+
if (!pathStat?.isDirectory() || pathStat.isSymbolicLink()) {
|
|
605
|
+
throw new Error(`refusing non-directory OpenCode seat state: ${path}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
await chmod(root, 0o700);
|
|
609
|
+
await chmod(dataHome, 0o700);
|
|
610
|
+
await chmod(appData, 0o700);
|
|
611
|
+
|
|
612
|
+
const sourceAuth = join(operatorAppData, 'auth.json');
|
|
613
|
+
const targetAuth = join(appData, 'auth.json');
|
|
614
|
+
if (publicSeat) {
|
|
615
|
+
// A public model must never inherit the operator's OpenCode login. The
|
|
616
|
+
// provider proxy is the only supported credential path for public seats.
|
|
617
|
+
const targetStat = await statOrNull(targetAuth);
|
|
618
|
+
if (targetStat && !targetStat.isSymbolicLink()) {
|
|
619
|
+
throw new Error(`refusing to replace non-symlink OpenCode seat credential: ${targetAuth}`);
|
|
620
|
+
}
|
|
621
|
+
if (targetStat) await unlink(targetAuth);
|
|
622
|
+
return {
|
|
623
|
+
root,
|
|
624
|
+
appData,
|
|
625
|
+
dataHome,
|
|
626
|
+
authPath: null,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const sourceStat = await statOrNull(sourceAuth);
|
|
630
|
+
const targetStat = await statOrNull(targetAuth);
|
|
631
|
+
const authPath = sourceStat ? realpathSync(sourceAuth) : null;
|
|
632
|
+
const resolvedSourceStat = authPath ? await statOrNull(authPath) : null;
|
|
633
|
+
if (sourceStat && (!resolvedSourceStat?.isFile() || resolvedSourceStat.isSymbolicLink())) {
|
|
634
|
+
throw new Error(`refusing non-regular operator OpenCode credential: ${sourceAuth}`);
|
|
635
|
+
}
|
|
636
|
+
if (targetStat && !targetStat.isSymbolicLink()) {
|
|
637
|
+
throw new Error(`refusing to replace non-symlink OpenCode seat credential: ${targetAuth}`);
|
|
638
|
+
}
|
|
639
|
+
if (sourceStat) {
|
|
640
|
+
const currentTarget = targetStat
|
|
641
|
+
? pathResolve(dirname(targetAuth), await readlink(targetAuth))
|
|
642
|
+
: null;
|
|
643
|
+
if (currentTarget !== pathResolve(authPath)) {
|
|
644
|
+
if (targetStat) await unlink(targetAuth);
|
|
645
|
+
await symlink(authPath, targetAuth);
|
|
646
|
+
}
|
|
647
|
+
} else if (targetStat) {
|
|
648
|
+
await unlink(targetAuth);
|
|
649
|
+
}
|
|
650
|
+
return { root, appData, dataHome, authPath };
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const sourceAuthEnvNames = (env) => Object.keys(env || {}).filter((name) => (
|
|
654
|
+
/(?:^|_)(?:API_KEY|ACCESS_TOKEN|ACCESS_KEY|CLIENT_SECRET|PASSWORD|TOKEN|SECRET)$/i.test(name)
|
|
655
|
+
));
|
|
656
|
+
|
|
657
|
+
const makeChildEnvironment = ({
|
|
658
|
+
source,
|
|
659
|
+
tempDir,
|
|
660
|
+
configPath,
|
|
661
|
+
configDir,
|
|
662
|
+
dataHome,
|
|
663
|
+
stateHome,
|
|
664
|
+
cacheHome,
|
|
665
|
+
home,
|
|
666
|
+
tempWork,
|
|
667
|
+
credentialFile,
|
|
668
|
+
}) => {
|
|
669
|
+
const env = { ...source };
|
|
670
|
+
// A parent launcher may itself have received injected OpenCode config. These
|
|
671
|
+
// higher-precedence values must not be inherited into a seat spawn.
|
|
672
|
+
for (const key of [
|
|
673
|
+
'OPENCODE_CONFIG_CONTENT', 'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR',
|
|
674
|
+
'OPENCODE_TUI_CONFIG', 'OPENCODE_AUTO_SHARE',
|
|
675
|
+
]) delete env[key];
|
|
676
|
+
env.OPENCODE_CONFIG = configPath;
|
|
677
|
+
env.OPENCODE_CONFIG_DIR = configDir;
|
|
678
|
+
env.OPENCODE_DISABLE_PROJECT_CONFIG = '1';
|
|
679
|
+
env.XDG_CONFIG_HOME = join(tempDir, 'xdg-config');
|
|
680
|
+
if (dataHome) env.XDG_DATA_HOME = dataHome;
|
|
681
|
+
else delete env.XDG_DATA_HOME;
|
|
682
|
+
if (stateHome) env.XDG_STATE_HOME = stateHome;
|
|
683
|
+
if (cacheHome) env.XDG_CACHE_HOME = cacheHome;
|
|
684
|
+
if (home) env.HOME = home;
|
|
685
|
+
env.TMPDIR = tempWork;
|
|
686
|
+
env.OPENCODE_AUTO_SHARE = 'false';
|
|
687
|
+
for (const key of DISABLED_ENV) env[key] = '1';
|
|
688
|
+
return withholdRuntimeCredential(env, { credentialFile });
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const spawnBinaryPath = (env) => {
|
|
692
|
+
try {
|
|
693
|
+
const where = spawnSync('which', ['opencode'], { encoding: 'utf8', env });
|
|
694
|
+
if (where.status === 0 && typeof where.stdout === 'string' && where.stdout.trim()) {
|
|
695
|
+
return realpathSync(where.stdout.trim());
|
|
696
|
+
}
|
|
697
|
+
} catch { /* use PATH resolution in spawn */ }
|
|
698
|
+
return 'opencode';
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
export default {
|
|
702
|
+
name: 'opencode',
|
|
703
|
+
runtimeType: 'opencode',
|
|
704
|
+
|
|
705
|
+
async validateEnvironment(environment, workspacePath) {
|
|
706
|
+
return validateProviderEnvironment(environment, workspacePath);
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
async detect() {
|
|
710
|
+
try {
|
|
711
|
+
const res = spawnSync('opencode', ['--version'], { encoding: 'utf8' });
|
|
712
|
+
if (res.error || res.status !== 0) return null;
|
|
713
|
+
const stdout = (res.stdout || '').trim();
|
|
714
|
+
const versionMatch = stdout.match(/(\d+\.\d+(?:\.\d+)?)/);
|
|
715
|
+
const version = versionMatch ? versionMatch[1] : (stdout || 'unknown');
|
|
716
|
+
const where = spawnSync('which', ['opencode'], { encoding: 'utf8' });
|
|
717
|
+
const path = where.status === 0 ? (where.stdout || '').trim() || 'opencode' : 'opencode';
|
|
718
|
+
return { path, version };
|
|
719
|
+
} catch {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
async spawn(prompt, rawCtx = {}) {
|
|
725
|
+
const sandbox = normalizeSandboxTrust(rawCtx.environment?.sandbox);
|
|
726
|
+
const ctx = rawCtx.environment
|
|
727
|
+
? { ...rawCtx, environment: { ...rawCtx.environment, sandbox } }
|
|
728
|
+
: rawCtx;
|
|
729
|
+
if (isLegacySandboxTrust(rawCtx.environment?.sandbox)) {
|
|
730
|
+
// eslint-disable-next-line no-console
|
|
731
|
+
console.warn('[opencode] legacy sandbox.trust=internal resolves to public and remains confined');
|
|
732
|
+
}
|
|
733
|
+
const isPublic = sandbox?.trust === 'public';
|
|
734
|
+
const sandboxMode = isPublic ? resolvePublicSandboxMode(sandbox) : null;
|
|
735
|
+
if (isPublic && !['workspace', 'read-only', 'bwrap'].includes(sandboxMode)) {
|
|
736
|
+
throw new Error(`public OpenCode agents require an enforced sandbox mode, got ${sandboxMode || 'unset'}`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const provider = ctx.environment?.provider;
|
|
740
|
+
if (isPublic && !provider) {
|
|
741
|
+
throw new Error('public OpenCode seats require environment.provider; the implicit public tier and operator auth.json are unsupported for public trust');
|
|
742
|
+
}
|
|
743
|
+
if (isPublic && sandboxMode === 'bwrap' && provider
|
|
744
|
+
&& sandbox?.network?.policy === 'restricted') {
|
|
745
|
+
throw new Error('public OpenCode provider proxy requires shared bwrap networking; sandbox.network.policy=restricted cannot reach the loopback proxy');
|
|
746
|
+
}
|
|
747
|
+
const providerKeyFile = await validateProviderEnvironment(ctx.environment, ctx.cwd);
|
|
748
|
+
|
|
749
|
+
const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, {
|
|
750
|
+
freshSession: !ctx.sessionId,
|
|
751
|
+
});
|
|
752
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'commonly-opencode-'));
|
|
753
|
+
await chmod(tempDir, 0o700);
|
|
754
|
+
const configPath = join(tempDir, 'opencode.json');
|
|
755
|
+
const configDir = join(tempDir, 'config-dir');
|
|
756
|
+
const xdgConfig = join(tempDir, 'xdg-config');
|
|
757
|
+
// OpenCode initializes Path.config with mkdir and ensureGitignore during
|
|
758
|
+
// startup. Seatbelt grants this per-spawn tree read-only, so create both
|
|
759
|
+
// config roots and the exact ignore files it would otherwise write.
|
|
760
|
+
const xdgConfigDir = join(xdgConfig, 'opencode');
|
|
761
|
+
await Promise.all([
|
|
762
|
+
mkdir(configDir),
|
|
763
|
+
mkdir(xdgConfigDir, { recursive: true }),
|
|
764
|
+
]);
|
|
765
|
+
await Promise.all([configDir, xdgConfigDir].map((directory) => writeFile(
|
|
766
|
+
join(directory, '.gitignore'),
|
|
767
|
+
OPENCODE_CONFIG_GITIGNORE,
|
|
768
|
+
{ encoding: 'utf8', mode: 0o600, flag: 'wx' },
|
|
769
|
+
)));
|
|
770
|
+
const credential = writeCredentialFile(ctx.runtimeToken, {
|
|
771
|
+
agentName: ctx.agentName || 'agent',
|
|
772
|
+
root: tempDir,
|
|
773
|
+
});
|
|
774
|
+
let openCodeHome = null;
|
|
775
|
+
let providerProxy = null;
|
|
776
|
+
try {
|
|
777
|
+
openCodeHome = await prepareOpenCodeDataHome(ctx, { publicSeat: isPublic });
|
|
778
|
+
if (isPublic && provider) {
|
|
779
|
+
// OpenCode can spend the configured model budget through this proxy;
|
|
780
|
+
// the bearer only prevents unrelated local processes from using it.
|
|
781
|
+
providerProxy = await startProviderProxy({
|
|
782
|
+
provider,
|
|
783
|
+
keyFile: providerKeyFile,
|
|
784
|
+
tokenFile: join(tempDir, 'provider-proxy-token'),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
const stateHome = isPublic ? join(openCodeHome.root, 'state') : null;
|
|
788
|
+
const cacheHome = isPublic ? join(openCodeHome.root, 'cache') : null;
|
|
789
|
+
const tempWork = isPublic ? join(openCodeHome.root, 'tmp') : join(tempDir, 'tmp');
|
|
790
|
+
await Promise.all([
|
|
791
|
+
mkdir(tempWork, { recursive: true, mode: 0o700 }),
|
|
792
|
+
...(stateHome ? [mkdir(stateHome, { recursive: true, mode: 0o700 })] : []),
|
|
793
|
+
...(cacheHome ? [mkdir(cacheHome, { recursive: true, mode: 0o700 })] : []),
|
|
794
|
+
]);
|
|
795
|
+
const mcp = buildMcpConfig(ctx.environment?.mcp, {
|
|
796
|
+
runtimeToken: ctx.runtimeToken,
|
|
797
|
+
instanceUrl: ctx.instanceUrl,
|
|
798
|
+
credentialFile: credential?.path || null,
|
|
799
|
+
});
|
|
800
|
+
const config = {
|
|
801
|
+
permission: isPublic
|
|
802
|
+
? publicPermissions(sandboxMode, Object.keys(mcp))
|
|
803
|
+
: 'allow',
|
|
804
|
+
share: 'disabled',
|
|
805
|
+
autoupdate: false,
|
|
806
|
+
...(provider ? {
|
|
807
|
+
enabled_providers: [provider.id],
|
|
808
|
+
provider: buildProviderConfig(providerProxy
|
|
809
|
+
? { ...provider, baseURL: providerProxy.baseURL }
|
|
810
|
+
: provider, ctx.environment.model, providerProxy?.tokenFile || providerKeyFile),
|
|
811
|
+
} : {}),
|
|
812
|
+
...(Object.keys(mcp).length ? { mcp } : {}),
|
|
813
|
+
};
|
|
814
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, {
|
|
815
|
+
encoding: 'utf8', mode: 0o600,
|
|
816
|
+
});
|
|
817
|
+
await chmod(configPath, 0o600);
|
|
818
|
+
|
|
819
|
+
const sourceEnv = ctx.env || process.env;
|
|
820
|
+
const runtimeEnv = isPublic
|
|
821
|
+
? Object.fromEntries(Object.entries(sourceEnv).filter(([key]) => PUBLIC_SAFE_ENV.test(key)))
|
|
822
|
+
: sourceEnv;
|
|
823
|
+
const childEnv = makeChildEnvironment({
|
|
824
|
+
source: runtimeEnv,
|
|
825
|
+
tempDir,
|
|
826
|
+
configPath,
|
|
827
|
+
configDir,
|
|
828
|
+
dataHome: openCodeHome.dataHome,
|
|
829
|
+
stateHome,
|
|
830
|
+
cacheHome,
|
|
831
|
+
home: isPublic ? openCodeHome.root : null,
|
|
832
|
+
tempWork,
|
|
833
|
+
credentialFile: credential?.path || null,
|
|
834
|
+
});
|
|
835
|
+
const args = buildArgs({
|
|
836
|
+
prompt: fullPrompt,
|
|
837
|
+
sessionId: ctx.sessionId,
|
|
838
|
+
cwd: ctx.cwd,
|
|
839
|
+
model: qualifiedProviderModel(provider, ctx.environment?.model),
|
|
840
|
+
effort: ctx.environment?.effort,
|
|
841
|
+
title: ctx.agentName,
|
|
842
|
+
});
|
|
843
|
+
const binary = ctx._binaryPath || spawnBinaryPath(childEnv);
|
|
844
|
+
const platform = ctx._platform || process.platform;
|
|
845
|
+
let cmd = binary;
|
|
846
|
+
let spawnArgs = args;
|
|
847
|
+
if (isPublic && sandboxMode === 'bwrap') {
|
|
848
|
+
if (!binary.startsWith('/')) {
|
|
849
|
+
throw new Error('public OpenCode agents require opencode to resolve to an absolute executable path');
|
|
850
|
+
}
|
|
851
|
+
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
852
|
+
if (!bwrap.available) throw new Error(`public OpenCode agents require bwrap: ${bwrap.error}`);
|
|
853
|
+
const wrapped = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([binary, ...args], {
|
|
854
|
+
...ctx.environment,
|
|
855
|
+
sandbox: {
|
|
856
|
+
...sandbox,
|
|
857
|
+
filesystem: {
|
|
858
|
+
...(sandbox?.filesystem || {}),
|
|
859
|
+
'write-outside': [
|
|
860
|
+
...(Array.isArray(sandbox?.filesystem?.['write-outside'])
|
|
861
|
+
? sandbox.filesystem['write-outside'] : []),
|
|
862
|
+
openCodeHome.root,
|
|
863
|
+
],
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
}, {
|
|
867
|
+
workspacePath: ctx.cwd,
|
|
868
|
+
readOnlyPaths: [
|
|
869
|
+
tempDir,
|
|
870
|
+
binary,
|
|
871
|
+
process.execPath,
|
|
872
|
+
],
|
|
873
|
+
});
|
|
874
|
+
[cmd, ...spawnArgs] = wrapped;
|
|
875
|
+
} else if (isPublic) {
|
|
876
|
+
if (platform !== 'darwin') {
|
|
877
|
+
throw new Error(`public OpenCode sandbox.mode=${sandboxMode} requires Seatbelt on macOS or bwrap on Linux`);
|
|
878
|
+
}
|
|
879
|
+
const wrapped = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([binary, ...args], {
|
|
880
|
+
workspacePath: ctx.cwd,
|
|
881
|
+
workspaceAccess: sandboxMode === 'read-only' ? 'read' : 'write',
|
|
882
|
+
executablePath: binary,
|
|
883
|
+
statePath: openCodeHome.root,
|
|
884
|
+
mcpConfigDir: tempDir,
|
|
885
|
+
loopbackNetworkPorts: providerProxy ? [providerProxy.port] : [],
|
|
886
|
+
readOnlyPaths: [],
|
|
887
|
+
});
|
|
888
|
+
[cmd, ...spawnArgs] = wrapped;
|
|
889
|
+
}
|
|
890
|
+
const credentials = spawnCredentials(ctx, sourceAuthEnvNames(sourceEnv));
|
|
891
|
+
const result = await runOpenCode({
|
|
892
|
+
cmd,
|
|
893
|
+
args: spawnArgs,
|
|
894
|
+
cwd: ctx.cwd,
|
|
895
|
+
env: childEnv,
|
|
896
|
+
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
897
|
+
credentials,
|
|
898
|
+
spawnImpl: ctx._spawnImpl,
|
|
899
|
+
});
|
|
900
|
+
return {
|
|
901
|
+
text: result.text.trim(),
|
|
902
|
+
newSessionId: result.sessionId || ctx.sessionId || null,
|
|
903
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
904
|
+
};
|
|
905
|
+
} finally {
|
|
906
|
+
try { await providerProxy?.close(); } catch { /* ignore */ }
|
|
907
|
+
try { await rm(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
export {
|
|
913
|
+
DISABLED_ENV,
|
|
914
|
+
buildArgs,
|
|
915
|
+
buildMcpConfig,
|
|
916
|
+
buildProviderConfig,
|
|
917
|
+
makeEventParser,
|
|
918
|
+
prepareOpenCodeDataHome,
|
|
919
|
+
publicPermissions,
|
|
920
|
+
qualifiedProviderModel,
|
|
921
|
+
resolveProviderKeyFile,
|
|
922
|
+
startProviderProxy,
|
|
923
|
+
};
|