@commonlyai/cli 0.1.97 → 0.1.99

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.97",
3
+ "version": "0.1.99",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -49,6 +49,7 @@ import {
49
49
  import {
50
50
  ADAPTERS_WITH_DEFAULT_MCP,
51
51
  ADAPTERS_WITH_GRANT_BROKER,
52
+ ADAPTERS_WITH_DEFAULT_SANDBOX,
52
53
  defaultMcpServers,
53
54
  } from '../lib/default-environment.js';
54
55
  import { withholdGrantBroker } from '../lib/grant-broker-guard.js';
@@ -621,8 +622,8 @@ export const updateAgentConfiguration = async ({
621
622
  environment = { ...environmentRuntime };
622
623
  }
623
624
  if (environment?.provider !== undefined
624
- && String(runtime.adapter || record.adapter || '').trim().toLowerCase() !== 'opencode') {
625
- throw new Error('environment.provider is supported only by the opencode adapter');
625
+ && !adapterRegistry.getAdapter(String(runtime.adapter || record.adapter || '').trim().toLowerCase())?.supportsProvider) {
626
+ throw new Error('environment.provider requires a provider-capable adapter');
626
627
  }
627
628
  if (environment) config.environment = environmentForServer(environment);
628
629
  if (!Object.keys(config).length) {
@@ -700,10 +701,9 @@ export const resolveAttachSandbox = ({
700
701
  // pure — a mode and an adapter name — so it belongs with the derivation, and
701
702
  // its witnesses live beside it.
702
703
  if ((mode === 'workspace' || mode === 'read-only')
703
- && (trust !== 'public' || !['codex', 'claude', 'opencode'].includes(adapterName))) {
704
+ && (trust !== 'public' || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName))) {
704
705
  throw new Error(
705
- `sandbox.mode=${mode} is currently implemented only for public `
706
- + 'Claude, Codex, or OpenCode adapters',
706
+ `sandbox.mode=${mode} requires public trust and an adapter with sandbox enforcement`,
707
707
  );
708
708
  }
709
709
  return { mode, trust };
@@ -743,8 +743,8 @@ export const performAttach = async ({
743
743
  let workspace = null;
744
744
  if (envPath) {
745
745
  environment = await parseEnvironmentFile(envPath);
746
- if (environment?.provider !== undefined && adapterName !== 'opencode') {
747
- throw new Error('environment.provider is supported only by the opencode adapter');
746
+ if (environment?.provider !== undefined && !adapter.supportsProvider) {
747
+ throw new Error('environment.provider requires a provider-capable adapter');
748
748
  }
749
749
  workspace = await resolveWorkspace(environment, agentName, dirname(envPath));
750
750
  if (typeof adapter.validateEnvironment === 'function') {
@@ -784,7 +784,8 @@ export const performAttach = async ({
784
784
  + `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`,
785
785
  );
786
786
  }
787
- const adapterLabel = adapterName === 'opencode' ? 'OpenCode' : 'Claude';
787
+ const adapterLabel = ({ opencode: 'OpenCode', qwen: 'Qwen', claude: 'Claude' })[adapterName]
788
+ || adapterName;
788
789
  log(
789
790
  `sandbox: public ${sandboxMode} via macOS Seatbelt `
790
791
  + `(deny-by-default host filesystem; ${adapterLabel}/MCP network retained)`,
@@ -793,7 +794,7 @@ export const performAttach = async ({
793
794
  } else if (sandboxMode !== 'none' && sandboxMode !== undefined) {
794
795
  throw new Error(
795
796
  `sandbox.mode=${sandboxMode} is not yet implemented in the local-CLI driver. `
796
- + `Supported locally: none, bwrap, and public Codex/Claude/OpenCode workspace/read-only.`,
797
+ + `Supported locally: none, bwrap, and public Codex/Claude/OpenCode/Qwen workspace/read-only.`,
797
798
  );
798
799
  }
799
800
  } else {
@@ -1106,8 +1107,8 @@ export const performRun = ({
1106
1107
  revokeOrphansImpl = revokeOrphanedSpawnCredentials,
1107
1108
  sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
1108
1109
  }) => {
1109
- if (environment?.provider !== undefined && adapter?.name !== 'opencode') {
1110
- throw new Error('environment.provider is supported only by the opencode adapter');
1110
+ if (environment?.provider !== undefined && !adapter?.supportsProvider) {
1111
+ throw new Error('environment.provider requires a provider-capable adapter');
1111
1112
  }
1112
1113
  const client = createClient({ instance: instanceUrl, token });
1113
1114
 
@@ -2711,7 +2712,7 @@ Docs:
2711
2712
  // ── attach (ADR-005, ADR-008) ─────────────────────────────────────────────
2712
2713
  agent
2713
2714
  .command('attach <adapter>')
2714
- .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|opencode|…)')
2715
+ .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|opencode|qwen|…)')
2715
2716
  .requiredOption('--pod <podId>', 'Pod ID to install into')
2716
2717
  .requiredOption('--name <name>', 'Agent name (e.g. my-claude)')
2717
2718
  .option('--display <name>', 'Display name shown in the pod')
@@ -2893,7 +2894,7 @@ Docs:
2893
2894
  .command('run <name>')
2894
2895
  .description('Run the local-CLI wrapper loop for an attached agent')
2895
2896
  .option('--interval <ms>', 'Poll interval in ms', '5000')
2896
- .option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex|pi|opencode); an existing token file bound to a different adapter stops the run')
2897
+ .option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex|pi|opencode|qwen); an existing token file bound to a different adapter stops the run')
2897
2898
  .option('--cascade-cap <n>', `Consecutive agent-triggered turns allowed per pod (env ${CASCADE_ENV_VARS.cap}, default ${CASCADE_DEFAULTS.cap})`)
2898
2899
  .option('--cascade-grace <n>', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`)
2899
2900
  .option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
@@ -12,6 +12,7 @@ import claude from './claude.js';
12
12
  import codex from './codex.js';
13
13
  import pi from './pi.js';
14
14
  import opencode from './opencode.js';
15
+ import qwen from './qwen.js';
15
16
 
16
17
  const ADAPTERS = {
17
18
  [stub.name]: stub,
@@ -19,6 +20,7 @@ const ADAPTERS = {
19
20
  [codex.name]: codex,
20
21
  [pi.name]: pi,
21
22
  [opencode.name]: opencode,
23
+ [qwen.name]: qwen,
22
24
  };
23
25
 
24
26
  export const listAdapterNames = () => Object.keys(ADAPTERS);
@@ -20,17 +20,13 @@
20
20
  */
21
21
 
22
22
  import { spawn as childSpawn, spawnSync } from 'child_process';
23
- import { createHash, randomBytes, timingSafeEqual } from 'crypto';
23
+ import { createHash } from 'crypto';
24
24
  import { realpathSync } from 'fs';
25
- import { constants as fsConstants } from 'fs';
26
- import { createServer, request as httpRequest } from 'http';
27
- import { request as httpsRequest } from 'https';
28
25
  import {
29
26
  chmod,
30
27
  lstat,
31
28
  mkdir,
32
29
  mkdtemp,
33
- open,
34
30
  readlink,
35
31
  rm,
36
32
  unlink,
@@ -39,7 +35,7 @@ import {
39
35
  } from 'fs/promises';
40
36
  import { homedir, tmpdir } from 'os';
41
37
  import {
42
- dirname, isAbsolute, join, relative, resolve as pathResolve, sep,
38
+ dirname, join, resolve as pathResolve,
43
39
  } from 'path';
44
40
  import { writeCredentialFile } from '../credential-file.js';
45
41
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
@@ -54,6 +50,7 @@ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
54
50
  import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
55
51
  import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
56
52
  import { resolvePublicSandboxMode } from '../sandbox/mode.js';
53
+ import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
57
54
 
58
55
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
59
56
  const TERMINATION_GRACE_MS = 5000;
@@ -273,221 +270,6 @@ const buildProviderConfig = (provider, model, keyFile) => {
273
270
  };
274
271
  };
275
272
 
276
- const isPathWithin = (parent, candidate) => {
277
- const fromParent = relative(parent, candidate);
278
- return fromParent === '' || (fromParent !== '..'
279
- && !fromParent.startsWith(`..${sep}`)
280
- && !isAbsolute(fromParent));
281
- };
282
-
283
- /** Validate without reading the key; OpenCode resolves its value from the file reference. */
284
- const resolveProviderKeyFile = async (provider, workspacePath) => {
285
- if (!provider) return null;
286
- const keyFile = provider.keyFile;
287
- if (typeof keyFile !== 'string' || !isAbsolute(keyFile)) {
288
- throw new Error('OpenCode provider.keyFile must be an absolute path');
289
- }
290
- if (/[\r\n}]/.test(keyFile)) {
291
- throw new Error('OpenCode provider.keyFile contains characters unsafe for a file reference');
292
- }
293
- let details;
294
- try {
295
- details = await lstat(keyFile);
296
- } catch (err) {
297
- throw new Error(`OpenCode provider key file is unavailable: ${err.message}`);
298
- }
299
- if (details.isSymbolicLink() || !details.isFile()) {
300
- throw new Error('OpenCode provider.keyFile must be a regular, non-symlink file');
301
- }
302
- if ((details.mode & 0o777) !== 0o600) {
303
- throw new Error('OpenCode provider.keyFile must have mode 0600');
304
- }
305
- if (typeof workspacePath !== 'string' || !isAbsolute(workspacePath)) {
306
- throw new Error('OpenCode provider.keyFile requires an absolute workspace path');
307
- }
308
- let realKeyFile;
309
- let realWorkspace;
310
- try {
311
- realKeyFile = realpathSync(keyFile);
312
- realWorkspace = realpathSync(workspacePath);
313
- } catch (err) {
314
- throw new Error(`OpenCode provider key path could not be resolved: ${err.message}`);
315
- }
316
- if (isPathWithin(realWorkspace, realKeyFile)) {
317
- throw new Error('OpenCode provider.keyFile must be outside the workspace');
318
- }
319
- return realKeyFile;
320
- };
321
-
322
- const readProviderKey = async (keyFile) => {
323
- const noFollow = fsConstants.O_NOFOLLOW || 0;
324
- const handle = await open(keyFile, fsConstants.O_RDONLY | noFollow);
325
- try {
326
- const details = await handle.stat();
327
- if (!details.isFile() || (details.mode & 0o777) !== 0o600) {
328
- throw new Error('OpenCode provider.keyFile changed after validation; expected a regular mode-0600 file');
329
- }
330
- const value = (await handle.readFile()).toString('utf8').trim();
331
- if (!value) throw new Error('OpenCode provider.keyFile must not be empty');
332
- return value;
333
- } finally {
334
- await handle.close();
335
- }
336
- };
337
-
338
- const startProviderProxy = async ({ provider, keyFile, tokenFile }) => {
339
- const upstream = new URL(provider.baseURL);
340
- if (!['http:', 'https:'].includes(upstream.protocol)
341
- || upstream.username || upstream.password || upstream.search || upstream.hash) {
342
- throw new Error('OpenCode provider.baseURL must be an http(s) URL without embedded credentials or query data');
343
- }
344
- const basePath = upstream.pathname.replace(/\/+$/, '') || '/';
345
- const upstreamKey = await readProviderKey(keyFile);
346
- const proxyToken = randomBytes(32).toString('base64url');
347
- await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
348
- await chmod(tokenFile, 0o600);
349
-
350
- const logRefusedRequest = (method, requestPath) => {
351
- let normalizedPath = '/';
352
- try {
353
- normalizedPath = new URL(requestPath || '/', 'http://127.0.0.1').pathname || '/';
354
- } catch {
355
- // Do not include an unparseable raw request target in logs.
356
- }
357
- const safeMethod = typeof method === 'string' && /^[A-Z]+$/.test(method)
358
- ? method : 'UNKNOWN';
359
- process.stderr.write(`[opencode] provider proxy refused ${safeMethod} ${normalizedPath}\n`);
360
- };
361
-
362
- const server = createServer((incoming, outgoing) => {
363
- const supplied = Buffer.from(incoming.headers.authorization || '');
364
- const expected = Buffer.from(`Bearer ${proxyToken}`);
365
- if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
366
- incoming.resume();
367
- outgoing.writeHead(401).end();
368
- return;
369
- }
370
- if (!['GET', 'POST'].includes(incoming.method || '')) {
371
- incoming.resume();
372
- outgoing.writeHead(405).end();
373
- return;
374
- }
375
- const rejectRoute = (path) => {
376
- logRefusedRequest(incoming.method, path);
377
- incoming.resume();
378
- outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
379
- outgoing.end(`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
380
- };
381
- const rejectBadRequest = () => {
382
- logRefusedRequest(incoming.method, incoming.url);
383
- incoming.resume();
384
- outgoing.writeHead(400).end();
385
- };
386
-
387
- let target;
388
- try {
389
- const requestPath = incoming.url || '/';
390
- if (!requestPath.startsWith('/') || requestPath.startsWith('//')) {
391
- rejectBadRequest();
392
- return;
393
- }
394
- if (/%(?:2f|5c|2e)/i.test(requestPath)) {
395
- rejectBadRequest();
396
- return;
397
- }
398
- // OpenCode owns this local request path, so never use it as an outbound
399
- // URL. The OpenAI-compatible chat provider needs only these fixed routes;
400
- // parse the incoming URL against a loopback sentinel, then select a
401
- // constant endpoint after matching the configured base path exactly.
402
- // This keeps an OpenCode request from turning the proxy into an arbitrary
403
- // path/URL fetcher (including paths on a local provider host).
404
- const requested = new URL(requestPath, 'http://127.0.0.1');
405
- if (requested.origin !== 'http://127.0.0.1' || requested.search || requested.hash) {
406
- rejectRoute(requested.pathname);
407
- return;
408
- }
409
- const configuredPath = basePath === '/' ? '' : basePath;
410
- let endpoint;
411
- if (incoming.method === 'POST'
412
- && requested.pathname === `${configuredPath}/chat/completions`) {
413
- endpoint = 'chat/completions';
414
- } else if (incoming.method === 'GET'
415
- && requested.pathname === `${configuredPath}/models`) {
416
- endpoint = 'models';
417
- } else {
418
- rejectRoute(requested.pathname);
419
- return;
420
- }
421
- target = {
422
- path: `${configuredPath}/${endpoint}`,
423
- protocol: upstream.protocol,
424
- hostname: upstream.hostname,
425
- port: upstream.port || undefined,
426
- host: upstream.host,
427
- };
428
- } catch {
429
- rejectBadRequest();
430
- return;
431
- }
432
-
433
- const headers = Object.fromEntries(Object.entries(incoming.headers)
434
- .filter(([name]) => ![
435
- 'authorization', 'connection', 'host', 'keep-alive', 'proxy-authenticate',
436
- 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade',
437
- 'x-api-key',
438
- ].includes(name.toLowerCase())));
439
- headers.authorization = `Bearer ${upstreamKey}`;
440
- headers.host = target.host;
441
- const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
442
- protocol: target.protocol,
443
- hostname: target.hostname,
444
- port: target.port,
445
- path: target.path,
446
- method: incoming.method,
447
- headers,
448
- }, (response) => {
449
- if (response.statusCode >= 300 && response.statusCode < 400) {
450
- response.resume();
451
- outgoing.writeHead(502).end('Provider redirects are not supported');
452
- return;
453
- }
454
- const responseHeaders = Object.fromEntries(Object.entries(response.headers)
455
- .filter(([name]) => ![
456
- 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
457
- 'te', 'trailer', 'transfer-encoding', 'upgrade',
458
- ].includes(name.toLowerCase())));
459
- outgoing.writeHead(response.statusCode || 502, responseHeaders);
460
- response.pipe(outgoing);
461
- });
462
- request.on('error', () => {
463
- if (!outgoing.headersSent) outgoing.writeHead(502);
464
- outgoing.end('Provider request failed');
465
- });
466
- outgoing.on('close', () => request.destroy());
467
- incoming.on('aborted', () => request.destroy());
468
- incoming.pipe(request);
469
- });
470
-
471
- await new Promise((resolve, reject) => {
472
- server.once('error', reject);
473
- server.listen(0, '127.0.0.1', () => {
474
- server.removeListener('error', reject);
475
- resolve();
476
- });
477
- });
478
- const { port } = server.address();
479
- const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString();
480
- return {
481
- baseURL: proxyBaseURL,
482
- tokenFile,
483
- port,
484
- close: () => new Promise((resolve) => {
485
- server.close(() => resolve());
486
- server.closeAllConnections?.();
487
- }),
488
- };
489
- };
490
-
491
273
  const validateProviderEnvironment = async (environment, workspacePath) => {
492
274
  if (environment?.provider === undefined) return null;
493
275
  const validation = validateEnvironmentSpec({
@@ -767,6 +549,7 @@ const describeOpenCodeVersionMismatch = (installedVersion) => {
767
549
  export default {
768
550
  name: 'opencode',
769
551
  runtimeType: 'opencode',
552
+ supportsProvider: true,
770
553
 
771
554
  async validateEnvironment(environment, workspacePath) {
772
555
  return validateProviderEnvironment(environment, workspacePath);
@@ -0,0 +1,347 @@
1
+ /** Qwen Code 0.25.1 wrapper. Public turns have fresh HOME, config and keys. */
2
+ import { spawn as childSpawn, spawnSync } from 'child_process';
3
+ import { lstatSync, realpathSync } from 'fs';
4
+ import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
5
+ import { tmpdir } from 'os';
6
+ import { basename, dirname, isAbsolute, join } from 'path';
7
+ import { buildMemoryPreamble } from '../memory-bridge.js';
8
+ import { deliverSeatCredential } from '../mcp-credential-delivery.js';
9
+ import { prepareMcpSpawn } from '../mcp-home.js';
10
+ import { writeCredentialFile } from '../credential-file.js';
11
+ import { normalizeSandboxTrust, validateEnvironmentSpec } from '../environment.js';
12
+ import { resolvePublicSandboxMode } from '../sandbox/mode.js';
13
+ import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
14
+ import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
15
+ import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
16
+ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
17
+
18
+ // Add a version only with a passing real clean-box sandbox smoke that names it.
19
+ const TESTED_QWEN_VERSIONS = Object.freeze(['0.25.1']);
20
+ const ALLOW_UNTESTED_QWEN_VERSION_ENV = 'COMMONLY_QWEN_ALLOW_UNTESTED_VERSION';
21
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
22
+ const TERMINATION_GRACE_MS = 5000;
23
+ const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
24
+ const PROXY_ENV = [
25
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
26
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
27
+ ];
28
+ const EXCLUDED_TOOLS = [
29
+ 'run_shell_command', 'exec', 'edit', 'notebook_edit', 'write_file',
30
+ 'agent', 'create_sub_session', 'task_create', 'team_create',
31
+ 'workflow', 'advisor',
32
+ // Legacy aliases. Deny both names even if Qwen changes when it canonicalizes.
33
+ 'replace', 'task',
34
+ 'web_fetch', 'web_search',
35
+ ];
36
+ // Qwen can read its own config directory. That directory also contains the
37
+ // per-spawn MCP credential file, so public turns cannot expose OS read tools.
38
+ const PUBLIC_READ_TOOLS = [
39
+ 'read_file', 'grep_search', 'search_file_content', 'glob', 'list_directory', 'lsp',
40
+ ];
41
+
42
+ const parseVersion = (output) => {
43
+ const match = String(output || '').trim().match(/^(?:qwen(?:\s+code|-code)?\s+)?v?(\d+\.\d+\.\d+)$/i);
44
+ return match ? match[1] : null;
45
+ };
46
+
47
+ const readVersion = (binary, env) => {
48
+ const result = spawnSync(binary, ['--version'], { encoding: 'utf8', env, timeout: 5000 });
49
+ return result.status === 0 && !result.error ? parseVersion(result.stdout) : null;
50
+ };
51
+
52
+ const resolvedBinary = (env) => {
53
+ const where = spawnSync('which', ['qwen'], { encoding: 'utf8', env });
54
+ return where.status === 0 ? (where.stdout || '').trim() || 'qwen' : 'qwen';
55
+ };
56
+
57
+ const withProbeEnv = async (source, run) => {
58
+ const home = await mkdtemp(join(tmpdir(), 'commonly-qwen-version-'));
59
+ try {
60
+ const settingsPath = join(home, 'settings.json');
61
+ await writeFile(settingsPath, JSON.stringify({
62
+ privacy: { usageStatisticsEnabled: false },
63
+ }), { mode: 0o600 });
64
+ const env = Object.fromEntries(Object.entries(source)
65
+ .filter(([name]) => PUBLIC_SAFE_ENV.test(name)));
66
+ env.HOME = home;
67
+ env.QWEN_HOME = home;
68
+ env.QWEN_RUNTIME_DIR = home;
69
+ env.QWEN_CODE_SYSTEM_SETTINGS_PATH = settingsPath;
70
+ env.QWEN_USAGE_STATISTICS_ENABLED = 'false';
71
+ for (const name of PROXY_ENV) env[name] = '';
72
+ return run(env);
73
+ } finally {
74
+ await rm(home, { recursive: true, force: true });
75
+ }
76
+ };
77
+
78
+ const substitute = (value, ctx) => String(value).replace(/\$\{(COMMONLY_[A-Z_]+)\}/g, (whole, name) => ({
79
+ COMMONLY_TOKEN_FILE: ctx.credentialFile,
80
+ COMMONLY_API_URL: ctx.instanceUrl,
81
+ COMMONLY_INSTANCE_URL: ctx.instanceUrl,
82
+ }[name] || whole));
83
+
84
+ export const buildQwenMcpConfig = (servers, ctx) => {
85
+ const mcpServers = {};
86
+ for (const server of servers || []) {
87
+ if (!/^[A-Za-z0-9_-]+$/.test(server?.name || '')) continue;
88
+ const transport = server.transport || 'stdio';
89
+ if (transport === 'stdio') {
90
+ if (!Array.isArray(server.command) || !server.command.length) continue;
91
+ const command = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
92
+ const delivery = deliverSeatCredential({ ...server, command }, {
93
+ credentialFile: ctx.credentialFile,
94
+ label: 'qwen',
95
+ });
96
+ const args = command.slice(1).map((part) => substitute(part, ctx));
97
+ const env = Object.fromEntries(Object.entries(delivery.env)
98
+ .map(([name, value]) => [name, substitute(value, ctx)]));
99
+ const serialized = JSON.stringify({ command: command[0], args, env });
100
+ if (serialized.includes('${COMMONLY_AGENT_TOKEN}')
101
+ || (ctx.runtimeToken && serialized.includes(ctx.runtimeToken))) {
102
+ throw new Error(`Qwen MCP server ${server.name} has no file-backed credential delivery`);
103
+ }
104
+ mcpServers[server.name] = {
105
+ command: command[0], args, env,
106
+ ...(server.cwd ? { cwd: server.cwd } : {}),
107
+ };
108
+ continue;
109
+ }
110
+ if (transport !== 'http' || typeof server.url !== 'string') {
111
+ throw new Error(`Qwen MCP server ${server.name} has unsupported transport`);
112
+ }
113
+ const httpUrl = substitute(server.url, ctx);
114
+ const headers = Object.fromEntries(Object.entries(server.headers || {})
115
+ .map(([name, value]) => [name, substitute(value, ctx)]));
116
+ const serialized = JSON.stringify({ httpUrl, headers });
117
+ if (serialized.includes('${COMMONLY_AGENT_TOKEN}')
118
+ || (ctx.runtimeToken && serialized.includes(ctx.runtimeToken))) {
119
+ throw new Error(`Qwen MCP server ${server.name} requires a credential transport Qwen can keep out of config`);
120
+ }
121
+ mcpServers[server.name] = { httpUrl, headers };
122
+ }
123
+ return { mcpServers };
124
+ };
125
+
126
+ const makeChildEnv = (source, home, settingsPath, proxy) => {
127
+ const env = Object.fromEntries(Object.entries(source)
128
+ .filter(([name]) => PUBLIC_SAFE_ENV.test(name)));
129
+ for (const name of PROXY_ENV) env[name] = '';
130
+ env.HOME = home;
131
+ env.QWEN_HOME = home;
132
+ env.QWEN_RUNTIME_DIR = home;
133
+ env.QWEN_CODE_SYSTEM_SETTINGS_PATH = settingsPath;
134
+ env.QWEN_USAGE_STATISTICS_ENABLED = 'false';
135
+ env.OPENAI_BASE_URL = proxy.baseURL;
136
+ env.OPENAI_API_KEY = proxy.bearer;
137
+ return env;
138
+ };
139
+
140
+ const parseStream = (line, state) => {
141
+ let event;
142
+ try { event = JSON.parse(line); } catch { return; }
143
+ if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
144
+ state.text = event.message.content
145
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
146
+ .map((part) => part.text).join('');
147
+ }
148
+ if (event.type === 'result') {
149
+ if (typeof event.result === 'string') state.text = event.result;
150
+ if (event.is_error) state.error = event.result || 'Qwen returned an error result';
151
+ }
152
+ };
153
+
154
+ const runQwen = ({ cmd, args, cwd, env, timeoutMs, spawnImpl, credentials }) => new Promise((resolve, reject) => {
155
+ const child = (spawnImpl || childSpawn)(cmd, args, {
156
+ cwd, env, stdio: ['ignore', 'pipe', 'pipe'],
157
+ });
158
+ const state = { text: '', error: null, buffer: '' };
159
+ let stderr = '';
160
+ let timedOut = false;
161
+ let settled = false;
162
+ const finish = (error, result) => {
163
+ if (settled) return;
164
+ settled = true;
165
+ clearTimeout(timer);
166
+ clearTimeout(killTimer);
167
+ if (error) reject(error); else resolve(result);
168
+ };
169
+ const timer = setTimeout(() => {
170
+ timedOut = true;
171
+ child.kill('SIGTERM');
172
+ killTimer = setTimeout(() => child.kill('SIGKILL'), TERMINATION_GRACE_MS);
173
+ }, timeoutMs);
174
+ let killTimer;
175
+ child.stdout?.on('data', (chunk) => {
176
+ state.buffer += chunk.toString();
177
+ if (state.buffer.length > 1024 * 1024) {
178
+ finish(new Error('Qwen stream line exceeded 1 MiB'));
179
+ child.kill('SIGTERM');
180
+ return;
181
+ }
182
+ while (state.buffer.includes('\n')) {
183
+ const end = state.buffer.indexOf('\n');
184
+ parseStream(state.buffer.slice(0, end), state);
185
+ state.buffer = state.buffer.slice(end + 1);
186
+ }
187
+ });
188
+ child.stderr?.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-8192); });
189
+ child.on('error', (error) => finish(error));
190
+ child.on('close', (code) => {
191
+ if (state.buffer) parseStream(state.buffer, state);
192
+ if (timedOut) return finish(new Error(`qwen timed out after ${timeoutMs}ms`));
193
+ if (code !== 0 || state.error) {
194
+ return finish(adapterFailure('qwen', state.error || stderr.trim() || `exit ${code}`, {
195
+ credentials, exitCode: code, limit: 500,
196
+ }));
197
+ }
198
+ return finish(null, { text: state.text.trim() });
199
+ });
200
+ });
201
+
202
+ export default {
203
+ name: 'qwen',
204
+ runtimeType: 'qwen',
205
+ supportsProvider: true,
206
+
207
+ async validateEnvironment(environment, workspacePath) {
208
+ const validation = validateEnvironmentSpec(environment);
209
+ if (!validation.ok) throw new Error(validation.errors.join('; '));
210
+ if (!environment?.provider || !environment?.model) {
211
+ throw new Error('Qwen requires environment.provider and environment.model');
212
+ }
213
+ return resolveProviderKeyFile(environment.provider, workspacePath, 'Qwen');
214
+ },
215
+
216
+ async detect() {
217
+ return withProbeEnv(process.env, (env) => {
218
+ const result = spawnSync('qwen', ['--version'], { encoding: 'utf8', timeout: 5000, env });
219
+ if (result.status !== 0 || result.error) return null;
220
+ return { path: resolvedBinary(env), version: parseVersion(result.stdout) || 'unknown' };
221
+ });
222
+ },
223
+
224
+ async spawn(prompt, ctx = {}) {
225
+ const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
226
+ const isPublic = sandbox?.trust === 'public';
227
+ const mode = isPublic ? resolvePublicSandboxMode(sandbox) : null;
228
+ if (isPublic && !['workspace', 'read-only', 'bwrap'].includes(mode)) {
229
+ throw new Error('public Qwen seats require an enforced sandbox');
230
+ }
231
+ const provider = ctx.environment?.provider;
232
+ if (!provider || !ctx.environment?.model) {
233
+ throw new Error('Qwen seats require environment.provider and environment.model');
234
+ }
235
+ if (isPublic && mode === 'bwrap' && sandbox?.network?.policy === 'restricted') {
236
+ throw new Error('public Qwen provider proxy requires shared bwrap networking');
237
+ }
238
+ const keyFile = await this.validateEnvironment(ctx.environment, ctx.cwd);
239
+ const sourceEnv = ctx.env || process.env;
240
+ const { binary, version } = await withProbeEnv(sourceEnv, (probeEnv) => {
241
+ const path = ctx._binaryPath || resolvedBinary(probeEnv);
242
+ return { binary: path, version: readVersion(path, probeEnv) };
243
+ });
244
+ if (!TESTED_QWEN_VERSIONS.includes(version)) {
245
+ const versions = TESTED_QWEN_VERSIONS.join(', ');
246
+ if (isPublic && process.env[ALLOW_UNTESTED_QWEN_VERSION_ENV] !== '1') {
247
+ throw new Error(`Public Qwen seats refuse version ${version || 'unknown'}; smoke-tested: ${versions}. Set ${ALLOW_UNTESTED_QWEN_VERSION_ENV}=1 in the host process to override.`);
248
+ }
249
+ // eslint-disable-next-line no-console
250
+ console.warn(`[qwen] installed ${version || 'unknown'} differs from smoke-tested ${versions}; ${isPublic ? 'host override accepted' : 'trusted seat continues'}`);
251
+ }
252
+ // npm launches through a symlink into the package. Qwen loads chunks next
253
+ // to that entry file, so public sandboxes need that exact read-only tree.
254
+ const symlinkedBinary = isPublic && lstatSync(binary).isSymbolicLink();
255
+ const executable = symlinkedBinary
256
+ ? join(realpathSync(dirname(binary)), basename(binary)) : binary;
257
+ const packageDir = symlinkedBinary ? dirname(realpathSync(executable)) : null;
258
+ const tempDir = await mkdtemp(join(tmpdir(), 'commonly-qwen-'));
259
+ await chmod(tempDir, 0o700);
260
+ // Keep child-writable state separate from adapter-owned config and keys.
261
+ const home = await mkdtemp(join(tmpdir(), 'commonly-qwen-home-'));
262
+ await chmod(home, 0o700);
263
+ const settingsPath = join(tempDir, 'settings.json');
264
+ const mcpPath = join(tempDir, 'mcp.json');
265
+ let proxy;
266
+ try {
267
+ const credential = writeCredentialFile(ctx.runtimeToken, {
268
+ agentName: ctx.agentName || 'agent', root: tempDir,
269
+ });
270
+ proxy = await startProviderProxy({
271
+ provider, keyFile, tokenFile: join(tempDir, 'provider-proxy-token'), adapterLabel: 'Qwen',
272
+ });
273
+ const proxyWithBearer = { ...proxy, bearer: (await readFile(proxy.tokenFile, 'utf8')).trim() };
274
+ const mcp = buildQwenMcpConfig(ctx.environment?.mcp, {
275
+ credentialFile: credential?.path || '',
276
+ instanceUrl: ctx.instanceUrl || '',
277
+ runtimeToken: ctx.runtimeToken || '',
278
+ });
279
+ await Promise.all([
280
+ writeFile(settingsPath, JSON.stringify({
281
+ privacy: { usageStatisticsEnabled: false },
282
+ general: { enableAutoUpdate: false },
283
+ }), { mode: 0o600 }),
284
+ writeFile(mcpPath, JSON.stringify(mcp), { mode: 0o600 }),
285
+ ]);
286
+ const env = makeChildEnv(sourceEnv, home, settingsPath, proxyWithBearer);
287
+ // QWEN_HOME is discarded after every turn, so Qwen session ids cannot
288
+ // be resumed. The kernel memory preamble supplies continuity instead.
289
+ const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, {
290
+ freshSession: true,
291
+ });
292
+ const args = [
293
+ '--bare', '--safe-mode', '--auth-type', 'openai', '-m', ctx.environment.model,
294
+ '--mcp-config', mcpPath, '--exclude-tools', [
295
+ ...EXCLUDED_TOOLS, ...(isPublic ? PUBLIC_READ_TOOLS : []),
296
+ ].join(','),
297
+ '-o', 'stream-json', '-p', fullPrompt,
298
+ ];
299
+ let cmd = executable;
300
+ let spawnArgs = args;
301
+ if (isPublic && mode === 'bwrap') {
302
+ if (!isAbsolute(executable)) throw new Error('public Qwen seats require an absolute qwen executable path');
303
+ const bwrap = (ctx._detectBwrap || detectBwrap)();
304
+ if (!bwrap.available) throw new Error(`public Qwen seats require bwrap: ${bwrap.error}`);
305
+ [cmd, ...spawnArgs] = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([executable, ...args], {
306
+ ...ctx.environment,
307
+ sandbox: {
308
+ ...sandbox,
309
+ filesystem: {
310
+ ...(sandbox?.filesystem || {}),
311
+ 'write-outside': [
312
+ ...(sandbox?.filesystem?.['write-outside'] || []), home,
313
+ ],
314
+ },
315
+ },
316
+ }, { workspacePath: ctx.cwd, homePath: home, readOnlyPaths: [tempDir, executable, packageDir, process.execPath].filter(Boolean) });
317
+ } else if (isPublic) {
318
+ if ((ctx._platform || process.platform) !== 'darwin') {
319
+ throw new Error(`public Qwen sandbox.mode=${mode} requires Seatbelt on macOS or bwrap on Linux`);
320
+ }
321
+ [cmd, ...spawnArgs] = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([executable, ...args], {
322
+ workspacePath: ctx.cwd,
323
+ workspaceAccess: mode === 'read-only' ? 'read' : 'write',
324
+ executablePath: executable,
325
+ statePath: home,
326
+ mcpConfigDir: tempDir,
327
+ loopbackNetworkPorts: [proxy.port],
328
+ readOnlyDirectories: packageDir ? [packageDir] : [],
329
+ });
330
+ }
331
+ const requestedTimeout = Number(ctx.timeoutMs);
332
+ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
333
+ ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
334
+ const result = await runQwen({
335
+ cmd, args: spawnArgs, cwd: ctx.cwd, env, timeoutMs,
336
+ spawnImpl: ctx._spawnImpl,
337
+ credentials: [...spawnCredentials(ctx, []), proxyWithBearer.bearer],
338
+ });
339
+ return { text: result.text, newSessionId: null };
340
+ } finally {
341
+ try { await proxy?.close(); } catch { /* best effort */ }
342
+ await Promise.all([tempDir, home].map((path) => rm(path, { recursive: true, force: true })));
343
+ }
344
+ },
345
+ };
346
+
347
+ export { TESTED_QWEN_VERSIONS, ALLOW_UNTESTED_QWEN_VERSION_ENV, makeChildEnv, parseVersion };
@@ -7,6 +7,7 @@ import {
7
7
  } from './declared-mcp-guard.js';
8
8
 
9
9
  import { seatBaseline } from './default-environment.js';
10
+ import { getAdapter } from './adapters/index.js';
10
11
  import { withholdGrantBroker } from './grant-broker-guard.js';
11
12
 
12
13
  /**
@@ -233,8 +234,8 @@ export const createDaemonSupervisor = ({
233
234
  nextAdapter = declaredAdapter;
234
235
  adapterChanged = existing.adapter !== nextAdapter;
235
236
  }
236
- if (existing.environment?.provider && nextAdapter !== 'opencode') {
237
- log(`[${row.agentName}] host-local provider requires the opencode adapter — keeping the current seat`);
237
+ if (existing.environment?.provider && !getAdapter(nextAdapter)?.supportsProvider) {
238
+ log(`[${row.agentName}] host-local provider requires a provider-capable adapter — keeping the current seat`);
238
239
  return false;
239
240
  }
240
241
  if (wanted) {
@@ -347,7 +348,7 @@ export const createDaemonSupervisor = ({
347
348
  }
348
349
  if (!adapter) adapter = await resolveAdapter(row.runtime || null);
349
350
  if (!adapter) {
350
- log(`[${row.agentName}] no usable CLI adapter on this machine — install claude, codex, or opencode, or attach manually`);
351
+ log(`[${row.agentName}] no usable CLI adapter on this machine — install claude, codex, opencode, or qwen, or attach manually`);
351
352
  return false;
352
353
  }
353
354
  const environment = environmentFor(row);
@@ -17,6 +17,7 @@
17
17
  * it had no commonly_* tool of its own)
18
18
  * pi — stdio servers from the environment spec (adapters/pi.js)
19
19
  * opencode — per-spawn config file with local/remote MCP entries (adapters/opencode.js)
20
+ * qwen — per-spawn --mcp-config with file-backed stdio credentials (adapters/qwen.js)
20
21
  * `stub` has no consumption path and must keep being handed `environment: null`.
21
22
  *
22
23
  * The placeholders are substituted at spawn time by the adapter, so the
@@ -25,7 +26,7 @@
25
26
 
26
27
  import { normalizeSandboxTrust } from './environment.js';
27
28
 
28
- export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'opencode']);
29
+ export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'opencode', 'qwen']);
29
30
 
30
31
  /**
31
32
  * The adapters that can ENFORCE the default sandbox — a strict subset of the
@@ -42,7 +43,7 @@ export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'open
42
43
  * here and hoped for. A derived pi seat used to be exactly this shape and would
43
44
  * have failed every spawn with `public-trust seats are not supported`.
44
45
  */
45
- export const ADAPTERS_WITH_DEFAULT_SANDBOX = new Set(['claude', 'codex', 'opencode']);
46
+ export const ADAPTERS_WITH_DEFAULT_SANDBOX = new Set(['claude', 'codex', 'opencode', 'qwen']);
46
47
 
47
48
  // The server has not yet admitted OpenCode to its grant-broker confinement
48
49
  // allowlist. Keep runtime sandbox support separate from the narrower set of
@@ -70,7 +71,7 @@ export const ADAPTERS_WITH_GRANT_BROKER = new Set(['claude', 'codex']);
70
71
  * moves a host fact into the database and breaks the day the seat is re-homed.
71
72
  * An explicit mode in a record still wins over the derived one.
72
73
  *
73
- * Confinement holds for claude, codex, and opencode, and only they are handed
74
+ * Confinement holds for claude, codex, opencode, and qwen, and only they are handed
74
75
  * this block (ADAPTERS_WITH_DEFAULT_SANDBOX). Grant-broker eligibility is
75
76
  * narrower until the server admits OpenCode after its confinement proof.
76
77
  * A pi seat must never be handed this block: it fails closed on a declared
@@ -0,0 +1,222 @@
1
+ /** The per-spawn loopback proxy shared by OpenAI-compatible CLI adapters. */
2
+ import { randomBytes, timingSafeEqual } from 'crypto';
3
+ import { constants as fsConstants, realpathSync } from 'fs';
4
+ import { createServer, request as httpRequest } from 'http';
5
+ import { request as httpsRequest } from 'https';
6
+ import { chmod, lstat, open, writeFile } from 'fs/promises';
7
+ import { isAbsolute, relative, sep } from 'path';
8
+
9
+ const isPathWithin = (parent, candidate) => {
10
+ const fromParent = relative(parent, candidate);
11
+ return fromParent === '' || (fromParent !== '..'
12
+ && !fromParent.startsWith(`..${sep}`)
13
+ && !isAbsolute(fromParent));
14
+ };
15
+ /** Validate the key path without reading the key. The proxy reads it at spawn. */
16
+ export const resolveProviderKeyFile = async (provider, workspacePath, adapterLabel = 'OpenCode') => {
17
+ if (!provider) return null;
18
+ const keyFile = provider.keyFile;
19
+ if (typeof keyFile !== 'string' || !isAbsolute(keyFile)) {
20
+ throw new Error(`${adapterLabel} provider.keyFile must be an absolute path`);
21
+ }
22
+ if (/[\r\n}]/.test(keyFile)) {
23
+ throw new Error(`${adapterLabel} provider.keyFile contains characters unsafe for a file reference`);
24
+ }
25
+ let details;
26
+ try {
27
+ details = await lstat(keyFile);
28
+ } catch (err) {
29
+ throw new Error(`${adapterLabel} provider key file is unavailable: ${err.message}`);
30
+ }
31
+ if (details.isSymbolicLink() || !details.isFile()) {
32
+ throw new Error(`${adapterLabel} provider.keyFile must be a regular, non-symlink file`);
33
+ }
34
+ if ((details.mode & 0o777) !== 0o600) {
35
+ throw new Error(`${adapterLabel} provider.keyFile must have mode 0600`);
36
+ }
37
+ if (typeof workspacePath !== 'string' || !isAbsolute(workspacePath)) {
38
+ throw new Error(`${adapterLabel} provider.keyFile requires an absolute workspace path`);
39
+ }
40
+ let realKeyFile;
41
+ let realWorkspace;
42
+ try {
43
+ realKeyFile = realpathSync(keyFile);
44
+ realWorkspace = realpathSync(workspacePath);
45
+ } catch (err) {
46
+ throw new Error(`${adapterLabel} provider key path could not be resolved: ${err.message}`);
47
+ }
48
+ if (isPathWithin(realWorkspace, realKeyFile)) {
49
+ throw new Error(`${adapterLabel} provider.keyFile must be outside the workspace`);
50
+ }
51
+ return realKeyFile;
52
+ };
53
+
54
+ const readProviderKey = async (keyFile, adapterLabel) => {
55
+ const noFollow = fsConstants.O_NOFOLLOW || 0;
56
+ const handle = await open(keyFile, fsConstants.O_RDONLY | noFollow);
57
+ try {
58
+ const details = await handle.stat();
59
+ if (!details.isFile() || (details.mode & 0o777) !== 0o600) {
60
+ throw new Error(`${adapterLabel} provider.keyFile changed after validation; expected a regular mode-0600 file`);
61
+ }
62
+ const value = (await handle.readFile()).toString('utf8').trim();
63
+ if (!value) throw new Error(`${adapterLabel} provider.keyFile must not be empty`);
64
+ return value;
65
+ } finally {
66
+ await handle.close();
67
+ }
68
+ };
69
+
70
+ export const startProviderProxy = async ({ provider, keyFile, tokenFile, adapterLabel = 'OpenCode' }) => {
71
+ const upstream = new URL(provider.baseURL);
72
+ if (!['http:', 'https:'].includes(upstream.protocol)
73
+ || upstream.username || upstream.password || upstream.search || upstream.hash) {
74
+ throw new Error(`${adapterLabel} provider.baseURL must be an http(s) URL without embedded credentials or query data`);
75
+ }
76
+ const basePath = upstream.pathname.replace(/\/+$/, '') || '/';
77
+ const upstreamKey = await readProviderKey(keyFile, adapterLabel);
78
+ const proxyToken = randomBytes(32).toString('base64url');
79
+ await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
80
+ await chmod(tokenFile, 0o600);
81
+
82
+ const logRefusedRequest = (method, requestPath) => {
83
+ let normalizedPath = '/';
84
+ try {
85
+ normalizedPath = new URL(requestPath || '/', 'http://127.0.0.1').pathname || '/';
86
+ } catch {
87
+ // Do not include an unparseable raw request target in logs.
88
+ }
89
+ const safeMethod = typeof method === 'string' && /^[A-Z]+$/.test(method)
90
+ ? method : 'UNKNOWN';
91
+ process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused ${safeMethod} ${normalizedPath}\n`);
92
+ };
93
+
94
+ const server = createServer((incoming, outgoing) => {
95
+ const supplied = Buffer.from(incoming.headers.authorization || '');
96
+ const expected = Buffer.from(`Bearer ${proxyToken}`);
97
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
98
+ incoming.resume();
99
+ outgoing.writeHead(401).end();
100
+ return;
101
+ }
102
+ if (!['GET', 'POST'].includes(incoming.method || '')) {
103
+ incoming.resume();
104
+ outgoing.writeHead(405).end();
105
+ return;
106
+ }
107
+ const rejectRoute = (path) => {
108
+ logRefusedRequest(incoming.method, path);
109
+ incoming.resume();
110
+ outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
111
+ outgoing.end(`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
112
+ };
113
+ const rejectBadRequest = () => {
114
+ logRefusedRequest(incoming.method, incoming.url);
115
+ incoming.resume();
116
+ outgoing.writeHead(400).end();
117
+ };
118
+
119
+ let target;
120
+ try {
121
+ const requestPath = incoming.url || '/';
122
+ if (!requestPath.startsWith('/') || requestPath.startsWith('//')) {
123
+ rejectBadRequest();
124
+ return;
125
+ }
126
+ if (/%(?:2f|5c|2e)/i.test(requestPath)) {
127
+ rejectBadRequest();
128
+ return;
129
+ }
130
+ // The CLI owns this local request path, so never use it as an outbound
131
+ // URL. The OpenAI-compatible chat provider needs only these fixed routes;
132
+ // parse the incoming URL against a loopback sentinel, then select a
133
+ // constant endpoint after matching the configured base path exactly.
134
+ // This keeps a CLI request from turning the proxy into an arbitrary
135
+ // path/URL fetcher (including paths on a local provider host).
136
+ const requested = new URL(requestPath, 'http://127.0.0.1');
137
+ if (requested.origin !== 'http://127.0.0.1' || requested.search || requested.hash) {
138
+ rejectRoute(requested.pathname);
139
+ return;
140
+ }
141
+ const configuredPath = basePath === '/' ? '' : basePath;
142
+ let endpoint;
143
+ if (incoming.method === 'POST'
144
+ && requested.pathname === `${configuredPath}/chat/completions`) {
145
+ endpoint = 'chat/completions';
146
+ } else if (incoming.method === 'GET'
147
+ && requested.pathname === `${configuredPath}/models`) {
148
+ endpoint = 'models';
149
+ } else {
150
+ rejectRoute(requested.pathname);
151
+ return;
152
+ }
153
+ target = {
154
+ path: `${configuredPath}/${endpoint}`,
155
+ protocol: upstream.protocol,
156
+ hostname: upstream.hostname,
157
+ port: upstream.port || undefined,
158
+ host: upstream.host,
159
+ };
160
+ } catch {
161
+ rejectBadRequest();
162
+ return;
163
+ }
164
+
165
+ const headers = Object.fromEntries(Object.entries(incoming.headers)
166
+ .filter(([name]) => ![
167
+ 'authorization', 'connection', 'host', 'keep-alive', 'proxy-authenticate',
168
+ 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade',
169
+ 'x-api-key',
170
+ ].includes(name.toLowerCase())));
171
+ headers.authorization = `Bearer ${upstreamKey}`;
172
+ headers.host = target.host;
173
+ const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
174
+ protocol: target.protocol,
175
+ hostname: target.hostname,
176
+ port: target.port,
177
+ path: target.path,
178
+ method: incoming.method,
179
+ headers,
180
+ }, (response) => {
181
+ if (response.statusCode >= 300 && response.statusCode < 400) {
182
+ response.resume();
183
+ outgoing.writeHead(502).end('Provider redirects are not supported');
184
+ return;
185
+ }
186
+ const responseHeaders = Object.fromEntries(Object.entries(response.headers)
187
+ .filter(([name]) => ![
188
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
189
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
190
+ ].includes(name.toLowerCase())));
191
+ outgoing.writeHead(response.statusCode || 502, responseHeaders);
192
+ response.pipe(outgoing);
193
+ });
194
+ request.on('error', () => {
195
+ if (!outgoing.headersSent) outgoing.writeHead(502);
196
+ outgoing.end('Provider request failed');
197
+ });
198
+ outgoing.on('close', () => request.destroy());
199
+ incoming.on('aborted', () => request.destroy());
200
+ incoming.pipe(request);
201
+ });
202
+
203
+ await new Promise((resolve, reject) => {
204
+ server.once('error', reject);
205
+ server.listen(0, '127.0.0.1', () => {
206
+ server.removeListener('error', reject);
207
+ resolve();
208
+ });
209
+ });
210
+ const { port } = server.address();
211
+ const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString();
212
+ return {
213
+ baseURL: proxyBaseURL,
214
+ tokenFile,
215
+ port,
216
+ close: () => new Promise((resolve) => {
217
+ server.close(() => resolve());
218
+ server.closeAllConnections?.();
219
+ }),
220
+ };
221
+ };
222
+
@@ -130,7 +130,7 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => {
130
130
 
131
131
  flags.push('--bind', opts.workspacePath, opts.workspacePath);
132
132
  flags.push('--chdir', opts.workspacePath);
133
- flags.push('--setenv', 'HOME', opts.workspacePath);
133
+ flags.push('--setenv', 'HOME', opts.homePath || opts.workspacePath);
134
134
 
135
135
  return ['bwrap', ...flags, '--', ...innerArgv];
136
136
  };
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { spawnSync } from 'child_process';
23
- import { realpathSync } from 'fs';
23
+ import { lstatSync, realpathSync } from 'fs';
24
24
  import { homedir } from 'os';
25
25
  import { isAbsolute, join } from 'path';
26
26
 
@@ -148,6 +148,10 @@ const PLATFORM_BASELINE = String.raw`
148
148
  (subpath "/opt/homebrew/Cellar"))
149
149
  (allow file-read* file-test-existence
150
150
  (subpath "/opt/homebrew/etc/openssl@3"))
151
+ ; openssl@3/cert.pem resolves to this Homebrew CA bundle. Seatbelt checks the
152
+ ; target path, so the openssl@3 directory grant alone does not admit TLS roots.
153
+ (allow file-read* file-test-existence
154
+ (literal "/opt/homebrew/etc/ca-certificates/cert.pem"))
151
155
  (allow file-read* (subpath "/opt/homebrew/lib"))
152
156
  (allow file-read* file-test-existence file-map-executable
153
157
  (subpath "/opt/homebrew/opt"))
@@ -302,6 +306,14 @@ const realpathOrSelf = (path) => {
302
306
  }
303
307
  };
304
308
 
309
+ const isSymlink = (path) => {
310
+ try {
311
+ return lstatSync(path).isSymbolicLink();
312
+ } catch {
313
+ return false;
314
+ }
315
+ };
316
+
305
317
  export const detectSeatbelt = () => {
306
318
  if (process.platform !== 'darwin') {
307
319
  return { available: false, error: DARWIN_MESSAGE };
@@ -332,6 +344,7 @@ export const buildSeatbeltProfile = ({
332
344
  mcpConfigDir = null,
333
345
  executablePaths = [],
334
346
  readOnlyPaths = [],
347
+ readOnlyDirectories = [],
335
348
  loopbackNetworkPorts = [],
336
349
  allowClaudeRuntimeAccess = false,
337
350
  }) => {
@@ -344,6 +357,7 @@ export const buildSeatbeltProfile = ({
344
357
  }
345
358
  if (mcpConfigDir) assertAbsolutePath(mcpConfigDir, 'mcpConfigDir');
346
359
  for (const path of readOnlyPaths) assertAbsolutePath(path, 'readOnlyPaths entry');
360
+ for (const path of readOnlyDirectories) assertAbsolutePath(path, 'readOnlyDirectories entry');
347
361
  for (const port of loopbackNetworkPorts) {
348
362
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
349
363
  throw new Error('Seatbelt loopbackNetworkPorts entries must be valid TCP ports');
@@ -358,6 +372,7 @@ export const buildSeatbeltProfile = ({
358
372
  const resolvedState = realpathOrSelf(statePath);
359
373
  const resolvedMcpConfig = mcpConfigDir ? realpathOrSelf(mcpConfigDir) : null;
360
374
  const resolvedReadOnlyPaths = [...new Set(readOnlyPaths.map(realpathOrSelf))];
375
+ const resolvedReadOnlyDirectories = [...new Set(readOnlyDirectories.map(realpathOrSelf))];
361
376
  const executables = [...new Set(
362
377
  [resolvedExecutable, process.execPath, ...executablePaths]
363
378
  .filter(Boolean)
@@ -374,12 +389,19 @@ export const buildSeatbeltProfile = ({
374
389
  `(allow file-read* file-test-existence file-map-executable ${literal(executable)})`,
375
390
  `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(executable)}"))`,
376
391
  ].join('\n')).join('\n');
392
+ const executableSymlinkRules = isSymlink(executablePath) ? [
393
+ `(allow file-read* file-test-existence ${literal(executablePath)})`,
394
+ `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(executablePath)}"))`,
395
+ ].join('\n') : '';
377
396
  const mcpRule = resolvedMcpConfig
378
397
  ? `(allow file-read* file-test-existence ${subpath(resolvedMcpConfig)})`
379
398
  : '';
380
399
  const readOnlyRules = resolvedReadOnlyPaths.map((path) => (
381
400
  `(allow file-read* file-test-existence ${literal(path)})`
382
- )).join('\n');
401
+ )).concat(resolvedReadOnlyDirectories.map((path) => [
402
+ `(allow file-read* file-test-existence ${subpath(path)})`,
403
+ `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(path)}"))`,
404
+ ].join('\n'))).join('\n');
383
405
  const loopbackNetworkRules = [...new Set(loopbackNetworkPorts)].map((port) => (
384
406
  `(allow network-outbound (remote tcp "localhost:${port}"))`
385
407
  )).join('\n');
@@ -402,6 +424,7 @@ export const buildSeatbeltProfile = ({
402
424
  return [
403
425
  PLATFORM_BASELINE.trim(),
404
426
  executableRules,
427
+ executableSymlinkRules,
405
428
  `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedWorkspace)}"))`,
406
429
  `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedState)}"))`,
407
430
  workspaceRule,