@commonlyai/cli 0.1.96 → 0.1.98
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 +14 -13
- package/src/lib/adapters/index.js +2 -0
- package/src/lib/adapters/opencode.js +4 -221
- package/src/lib/adapters/qwen.js +347 -0
- package/src/lib/daemon-supervisor.js +4 -3
- package/src/lib/default-environment.js +4 -3
- package/src/lib/hooks-config.js +10 -1
- package/src/lib/provider-proxy.js +222 -0
- package/src/lib/sandbox/bwrap.js +1 -1
- package/src/lib/sandbox/seatbelt.js +21 -2
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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()
|
|
625
|
-
throw new Error('environment.provider
|
|
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' || !
|
|
704
|
+
&& (trust !== 'public' || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName))) {
|
|
704
705
|
throw new Error(
|
|
705
|
-
`sandbox.mode=${mode}
|
|
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 &&
|
|
747
|
-
throw new Error('environment.provider
|
|
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 =
|
|
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?.
|
|
1110
|
-
throw new Error('environment.provider
|
|
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
|
|
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,
|
|
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
|
|
237
|
-
log(`[${row.agentName}] host-local provider requires
|
|
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
|
|
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
|
|
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
|
package/src/lib/hooks-config.js
CHANGED
|
@@ -215,7 +215,16 @@ export const forwardHookEvent = async ({
|
|
|
215
215
|
const body = await response.json().catch(() => ({}));
|
|
216
216
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
217
217
|
if (preTool && body.permissionDecision === 'deny') {
|
|
218
|
-
|
|
218
|
+
// Claude Code reads a PreToolUse decision only from `hookSpecificOutput`. The top-level
|
|
219
|
+
// `{permissionDecision}` this printed before was ignored and the tool ran (measured on
|
|
220
|
+
// Claude Code 2.1.296, 2026-10-14, issue #2145).
|
|
221
|
+
stdout(JSON.stringify({
|
|
222
|
+
hookSpecificOutput: {
|
|
223
|
+
hookEventName: 'PreToolUse',
|
|
224
|
+
permissionDecision: 'deny',
|
|
225
|
+
...(body.reason ? { permissionDecisionReason: body.reason } : {}),
|
|
226
|
+
},
|
|
227
|
+
}));
|
|
219
228
|
}
|
|
220
229
|
return body;
|
|
221
230
|
} catch (error) {
|
|
@@ -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
|
+
|
package/src/lib/sandbox/bwrap.js
CHANGED
|
@@ -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
|
|
|
@@ -302,6 +302,14 @@ const realpathOrSelf = (path) => {
|
|
|
302
302
|
}
|
|
303
303
|
};
|
|
304
304
|
|
|
305
|
+
const isSymlink = (path) => {
|
|
306
|
+
try {
|
|
307
|
+
return lstatSync(path).isSymbolicLink();
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
305
313
|
export const detectSeatbelt = () => {
|
|
306
314
|
if (process.platform !== 'darwin') {
|
|
307
315
|
return { available: false, error: DARWIN_MESSAGE };
|
|
@@ -332,6 +340,7 @@ export const buildSeatbeltProfile = ({
|
|
|
332
340
|
mcpConfigDir = null,
|
|
333
341
|
executablePaths = [],
|
|
334
342
|
readOnlyPaths = [],
|
|
343
|
+
readOnlyDirectories = [],
|
|
335
344
|
loopbackNetworkPorts = [],
|
|
336
345
|
allowClaudeRuntimeAccess = false,
|
|
337
346
|
}) => {
|
|
@@ -344,6 +353,7 @@ export const buildSeatbeltProfile = ({
|
|
|
344
353
|
}
|
|
345
354
|
if (mcpConfigDir) assertAbsolutePath(mcpConfigDir, 'mcpConfigDir');
|
|
346
355
|
for (const path of readOnlyPaths) assertAbsolutePath(path, 'readOnlyPaths entry');
|
|
356
|
+
for (const path of readOnlyDirectories) assertAbsolutePath(path, 'readOnlyDirectories entry');
|
|
347
357
|
for (const port of loopbackNetworkPorts) {
|
|
348
358
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
349
359
|
throw new Error('Seatbelt loopbackNetworkPorts entries must be valid TCP ports');
|
|
@@ -358,6 +368,7 @@ export const buildSeatbeltProfile = ({
|
|
|
358
368
|
const resolvedState = realpathOrSelf(statePath);
|
|
359
369
|
const resolvedMcpConfig = mcpConfigDir ? realpathOrSelf(mcpConfigDir) : null;
|
|
360
370
|
const resolvedReadOnlyPaths = [...new Set(readOnlyPaths.map(realpathOrSelf))];
|
|
371
|
+
const resolvedReadOnlyDirectories = [...new Set(readOnlyDirectories.map(realpathOrSelf))];
|
|
361
372
|
const executables = [...new Set(
|
|
362
373
|
[resolvedExecutable, process.execPath, ...executablePaths]
|
|
363
374
|
.filter(Boolean)
|
|
@@ -374,12 +385,19 @@ export const buildSeatbeltProfile = ({
|
|
|
374
385
|
`(allow file-read* file-test-existence file-map-executable ${literal(executable)})`,
|
|
375
386
|
`(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(executable)}"))`,
|
|
376
387
|
].join('\n')).join('\n');
|
|
388
|
+
const executableSymlinkRules = isSymlink(executablePath) ? [
|
|
389
|
+
`(allow file-read* file-test-existence ${literal(executablePath)})`,
|
|
390
|
+
`(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(executablePath)}"))`,
|
|
391
|
+
].join('\n') : '';
|
|
377
392
|
const mcpRule = resolvedMcpConfig
|
|
378
393
|
? `(allow file-read* file-test-existence ${subpath(resolvedMcpConfig)})`
|
|
379
394
|
: '';
|
|
380
395
|
const readOnlyRules = resolvedReadOnlyPaths.map((path) => (
|
|
381
396
|
`(allow file-read* file-test-existence ${literal(path)})`
|
|
382
|
-
)).
|
|
397
|
+
)).concat(resolvedReadOnlyDirectories.map((path) => [
|
|
398
|
+
`(allow file-read* file-test-existence ${subpath(path)})`,
|
|
399
|
+
`(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(path)}"))`,
|
|
400
|
+
].join('\n'))).join('\n');
|
|
383
401
|
const loopbackNetworkRules = [...new Set(loopbackNetworkPorts)].map((port) => (
|
|
384
402
|
`(allow network-outbound (remote tcp "localhost:${port}"))`
|
|
385
403
|
)).join('\n');
|
|
@@ -402,6 +420,7 @@ export const buildSeatbeltProfile = ({
|
|
|
402
420
|
return [
|
|
403
421
|
PLATFORM_BASELINE.trim(),
|
|
404
422
|
executableRules,
|
|
423
|
+
executableSymlinkRules,
|
|
405
424
|
`(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedWorkspace)}"))`,
|
|
406
425
|
`(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedState)}"))`,
|
|
407
426
|
workspaceRule,
|