@commonlyai/cli 0.1.235 → 0.1.236
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.
|
@@ -169,10 +169,14 @@ const readProviderKey = async (keyFile, adapterLabel) => {
|
|
|
169
169
|
|
|
170
170
|
// The routes a CLI on each wire may call, relative to the configured base
|
|
171
171
|
// path, and the header the real key goes out in. Anything else is refused.
|
|
172
|
+
// `search` lists the exact query strings a wire's client sends, forwarded as
|
|
173
|
+
// sent: Claude Code calls `/v1/messages?beta=true` (F96). Any other query is
|
|
174
|
+
// refused.
|
|
172
175
|
const WIRES = Object.freeze({
|
|
173
176
|
'openai-chat': { routes: [['POST', 'chat/completions'], ['GET', 'models']], auth: 'bearer' },
|
|
174
177
|
'anthropic-messages': {
|
|
175
178
|
routes: [['POST', 'v1/messages'], ['POST', 'v1/messages/count_tokens'], ['GET', 'v1/models']],
|
|
179
|
+
search: ['?beta=true'],
|
|
176
180
|
auth: 'x-api-key',
|
|
177
181
|
},
|
|
178
182
|
'openai-responses': { routes: [['POST', 'responses'], ['GET', 'models']], auth: 'bearer' },
|
|
@@ -276,7 +280,8 @@ export const startProviderProxy = async ({
|
|
|
276
280
|
// This keeps a CLI request from turning the proxy into an arbitrary
|
|
277
281
|
// path/URL fetcher (including paths on a local provider host).
|
|
278
282
|
const requested = new URL(requestPath, 'http://127.0.0.1');
|
|
279
|
-
if (requested.origin !== 'http://127.0.0.1' || requested.
|
|
283
|
+
if (requested.origin !== 'http://127.0.0.1' || requested.hash
|
|
284
|
+
|| (requested.search && !(wireSpec.search || []).includes(requested.search))) {
|
|
280
285
|
rejectRoute(requested.pathname);
|
|
281
286
|
return;
|
|
282
287
|
}
|
|
@@ -289,7 +294,7 @@ export const startProviderProxy = async ({
|
|
|
289
294
|
}
|
|
290
295
|
const endpoint = route[1];
|
|
291
296
|
target = {
|
|
292
|
-
path: `${configuredPath}/${endpoint}`,
|
|
297
|
+
path: `${configuredPath}/${endpoint}${requested.search}`,
|
|
293
298
|
protocol: upstream.protocol,
|
|
294
299
|
hostname: upstream.hostname,
|
|
295
300
|
port: upstream.port || undefined,
|
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1625,6 +1625,8 @@ export const performRun = ({
|
|
|
1625
1625
|
setTimeoutImpl = setTimeout,
|
|
1626
1626
|
setIntervalImpl = setInterval,
|
|
1627
1627
|
clearIntervalImpl = clearInterval,
|
|
1628
|
+
// The host's bubblewrap probe, for a turn placed public; tests pin it.
|
|
1629
|
+
detectBwrapImpl,
|
|
1628
1630
|
setDeadlineTimeoutImpl = setTimeout,
|
|
1629
1631
|
clearDeadlineTimeoutImpl = clearTimeout,
|
|
1630
1632
|
turnTimeoutMs,
|
|
@@ -2139,14 +2141,19 @@ export const performRun = ({
|
|
|
2139
2141
|
const placement = await placeTurn();
|
|
2140
2142
|
// A workspace turn placed public (its pod turned public, or its pods could
|
|
2141
2143
|
// not be read) is confined as a public seat, which this host may not be
|
|
2142
|
-
// able to do
|
|
2143
|
-
//
|
|
2144
|
+
// able to do (a Linux host without bubblewrap). Refuse it as the run start
|
|
2145
|
+
// would, before anything is read or spawned. The model
|
|
2144
2146
|
// never runs and nothing is posted. A public pod is a settled answer, so a
|
|
2145
2147
|
// human's message is handed to another listener (claim-outcome.js). A
|
|
2146
2148
|
// failed read is not: it throws, so the event stays unacked and comes back
|
|
2147
2149
|
// through the spawn-retry backoff instead of being refused for good.
|
|
2148
2150
|
if (placement.reason) {
|
|
2149
|
-
const turnRefusal = hostSandboxRefusal({
|
|
2151
|
+
const turnRefusal = hostSandboxRefusal({
|
|
2152
|
+
environment: placement.environment,
|
|
2153
|
+
adapterName: adapter.name,
|
|
2154
|
+
hostUnconfined,
|
|
2155
|
+
...(detectBwrapImpl ? { detectBwrap: detectBwrapImpl } : {}),
|
|
2156
|
+
});
|
|
2150
2157
|
if (turnRefusal && placement.readError) {
|
|
2151
2158
|
throw Object.assign(
|
|
2152
2159
|
new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
|
|
@@ -64,9 +64,11 @@ import {
|
|
|
64
64
|
mkdtemp,
|
|
65
65
|
open,
|
|
66
66
|
readFile,
|
|
67
|
+
readdir,
|
|
67
68
|
readlink,
|
|
68
69
|
rename,
|
|
69
70
|
rm,
|
|
71
|
+
rmdir,
|
|
70
72
|
symlink,
|
|
71
73
|
unlink,
|
|
72
74
|
writeFile,
|
|
@@ -98,11 +100,12 @@ import {
|
|
|
98
100
|
import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
99
101
|
import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
|
|
100
102
|
import { skillsAsText } from '../skill-prompt.js';
|
|
101
|
-
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
103
|
+
import { adapterFailure, redactKnownCredentials, spawnCredentials } from '../upstream-refusal.js';
|
|
102
104
|
import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
|
|
103
105
|
import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
|
|
104
106
|
import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
|
|
105
107
|
import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
|
|
108
|
+
import { startProviderProxy } from '../provider-proxy.js';
|
|
106
109
|
|
|
107
110
|
// See codex.js for the rationale on bumping the default + env override.
|
|
108
111
|
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
@@ -325,14 +328,71 @@ const replacePrivateFile = async (dir, name, content) => {
|
|
|
325
328
|
return target;
|
|
326
329
|
};
|
|
327
330
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
331
|
+
// The host's ANTHROPIC_BASE_URL, for a seat that reaches the model on the
|
|
332
|
+
// person's login. Only when it is the route the login uses: with an
|
|
333
|
+
// ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN beside it, it is that key's
|
|
334
|
+
// gateway, and the jail (which never gets the key) would send the person's
|
|
335
|
+
// subscription token there instead. A URL carrying a user or password is not
|
|
336
|
+
// forwarded either.
|
|
337
|
+
export const loginRouteBaseUrl = (source = {}) => {
|
|
338
|
+
if (!source.ANTHROPIC_BASE_URL || source.ANTHROPIC_API_KEY || source.ANTHROPIC_AUTH_TOKEN) return null;
|
|
339
|
+
try {
|
|
340
|
+
const url = new URL(source.ANTHROPIC_BASE_URL);
|
|
341
|
+
return url.username || url.password ? null : source.ANTHROPIC_BASE_URL;
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Two turns of one public agent share its folder (a daemon and a separate
|
|
348
|
+
// `agent run`, or two events at once). Each registers itself in a sibling of
|
|
349
|
+
// that folder, outside the jail's writable bind so a turn cannot forge one,
|
|
350
|
+
// and the login copy is removed only by the last live turn to finish.
|
|
351
|
+
const registerLoginTurn = async (statePath) => {
|
|
352
|
+
const dir = `${statePath}.login-turns`;
|
|
353
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
354
|
+
const marker = join(dir, `${process.pid}-${randomUUID()}`);
|
|
355
|
+
await writeFile(marker, '', { mode: 0o600, flag: 'wx' });
|
|
356
|
+
return marker;
|
|
357
|
+
};
|
|
358
|
+
const pidAlive = (pid) => {
|
|
359
|
+
try {
|
|
360
|
+
process.kill(pid, 0);
|
|
361
|
+
return true;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
return err?.code === 'EPERM';
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const releaseLoginTurn = async (marker, loginPath) => {
|
|
367
|
+
await rm(marker, { force: true });
|
|
368
|
+
const dir = dirname(marker);
|
|
369
|
+
let others = [];
|
|
370
|
+
try {
|
|
371
|
+
others = await readdir(dir);
|
|
372
|
+
} catch { /* gone: nobody else is registered */ }
|
|
373
|
+
let live = 0;
|
|
374
|
+
for (const name of others) {
|
|
375
|
+
const pid = Number(name.split('-')[0]);
|
|
376
|
+
if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) live += 1;
|
|
377
|
+
else await rm(join(dir, name), { force: true });
|
|
378
|
+
}
|
|
379
|
+
if (!live) {
|
|
380
|
+
await rm(loginPath, { force: true });
|
|
381
|
+
// Only an empty folder goes: a turn registering at this moment keeps it.
|
|
382
|
+
try { await rmdir(dir); } catch { /* not empty, or already gone */ }
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// A seat on Linux runs under bwrap, which has no keychain. Claude Code on
|
|
387
|
+
// Linux keeps its login in <config dir>/.credentials.json, so the seat's own
|
|
388
|
+
// state dir gets a copy of the person's, taken fresh each spawn. Only the copy
|
|
389
|
+
// is inside the jail. Nothing is written back: the jail can write its copy, and
|
|
390
|
+
// a write-back would let it replace the person's login with one of its
|
|
391
|
+
// choosing. Since F96 the copy is access-only for every seat, workspace ones
|
|
392
|
+
// included: a jail that refreshed would spend the host's refresh token and
|
|
393
|
+
// leave the person signed out (CI, 11-04: Claude Code rewrites the jail's copy
|
|
394
|
+
// when its session check fails). A missing login removes the copy, so a person
|
|
395
|
+
// who logged out is not still signed in here.
|
|
336
396
|
const claudeLoginSource = (env) => join(
|
|
337
397
|
env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
|
|
338
398
|
'.credentials.json',
|
|
@@ -340,7 +400,39 @@ const claudeLoginSource = (env) => join(
|
|
|
340
400
|
|
|
341
401
|
// Opened once, without following a symlink, and checked on the open handle,
|
|
342
402
|
// so the file read is the file checked (a FIFO cannot hold the open either).
|
|
343
|
-
|
|
403
|
+
// A public seat's copy (F96) holds the access token alone. Claude Code
|
|
404
|
+
// rotates the refresh token when it refreshes, so a jail holding one could
|
|
405
|
+
// spend it and leave the host signed out; without one it cannot refresh at
|
|
406
|
+
// all, so a login that would expire mid-turn is refused here instead.
|
|
407
|
+
// What a public seat's access-only login keeps: the token and the account
|
|
408
|
+
// metadata Claude Code reads beside it.
|
|
409
|
+
const ACCESS_ONLY_LOGIN_FIELDS = ['accessToken', 'expiresAt', 'scopes', 'subscriptionType', 'rateLimitTier'];
|
|
410
|
+
|
|
411
|
+
const accessOnlyLogin = (text, { source, mustOutlastMs, apiKeyHint = true }) => {
|
|
412
|
+
let login;
|
|
413
|
+
try {
|
|
414
|
+
login = JSON.parse(text);
|
|
415
|
+
} catch {
|
|
416
|
+
throw new Error(`refusing a Claude login that is not JSON: ${source}`);
|
|
417
|
+
}
|
|
418
|
+
const oauth = login?.claudeAiOauth;
|
|
419
|
+
if (!oauth || typeof oauth.accessToken !== 'string' || !oauth.accessToken) {
|
|
420
|
+
throw new Error(`refusing a Claude login with no access token: ${source}`);
|
|
421
|
+
}
|
|
422
|
+
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt - Date.now() < mustOutlastMs) {
|
|
423
|
+
const minutes = Math.max(0, Math.floor((oauth.expiresAt - Date.now()) / 60000));
|
|
424
|
+
throw new Error(`the Claude login on this machine expires in ${minutes} min, before this turn could end. `
|
|
425
|
+
+ `Sign in again on this machine (\`claude\`, then /login)${apiKeyHint ? ', or set ANTHROPIC_API_KEY for the agent' : ''}.`);
|
|
426
|
+
}
|
|
427
|
+
// An allowlist, not "everything but refreshToken": a field the host record
|
|
428
|
+
// gains later, secret or not, never reaches the seat's writable HOME.
|
|
429
|
+
const access = Object.fromEntries(ACCESS_ONLY_LOGIN_FIELDS
|
|
430
|
+
.filter((field) => oauth[field] !== undefined)
|
|
431
|
+
.map((field) => [field, oauth[field]]));
|
|
432
|
+
return `${JSON.stringify({ claudeAiOauth: access })}\n`;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const carryClaudeLogin = async (env, configDir, { accessOnly = false, mustOutlastMs = 0, apiKeyHint = true } = {}) => {
|
|
344
436
|
const source = claudeLoginSource(env);
|
|
345
437
|
const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
|
|
346
438
|
let handle;
|
|
@@ -356,20 +448,33 @@ const carryClaudeLogin = async (env, configDir) => {
|
|
|
356
448
|
}
|
|
357
449
|
try {
|
|
358
450
|
if (!(await handle.stat()).isFile()) throw refuse();
|
|
359
|
-
|
|
451
|
+
const text = await handle.readFile('utf8');
|
|
452
|
+
return replacePrivateFile(configDir, '.credentials.json', accessOnly
|
|
453
|
+
? accessOnlyLogin(text, { source, mustOutlastMs, apiKeyHint })
|
|
454
|
+
: text);
|
|
360
455
|
} finally {
|
|
361
456
|
await handle.close();
|
|
362
457
|
}
|
|
363
458
|
};
|
|
364
459
|
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// and the
|
|
368
|
-
//
|
|
369
|
-
const
|
|
460
|
+
// The per-seat folder a bwrap seat runs in. A public seat (F96) gets its own,
|
|
461
|
+
// never the workspace seat's: that one holds the transcripts of private-pod
|
|
462
|
+
// turns, and the same agent can run both kinds of
|
|
463
|
+
// turn, even at once (a daemon and a separate `agent run`).
|
|
464
|
+
export const bwrapClaudeStatePath = (ctx, seat = 'workspace') => {
|
|
465
|
+
if (ctx._publicClaudeState) return ctx._publicClaudeState;
|
|
370
466
|
const identity = ctx.agentName || ctx.cwd || 'anonymous';
|
|
371
467
|
const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
|
|
372
|
-
|
|
468
|
+
return publicClaudeStateRoot(seat === 'public' ? `${identityHash}-public` : identityHash);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// State for a seat under bwrap, outside the person's project, with the auth
|
|
472
|
+
// metadata whitelist and the login copy. The jail binds this folder writable
|
|
473
|
+
// and makes it HOME, so transcripts land here rather than in the project.
|
|
474
|
+
// Every seat carries an access-only copy of the login, or a public seat none
|
|
475
|
+
// when an API key reaches the model through the provider proxy instead.
|
|
476
|
+
const prepareWorkspaceBwrapClaudeState = async (ctx, { login = 'carry', seat = 'workspace' } = {}) => {
|
|
477
|
+
const statePath = bwrapClaudeStatePath(ctx, seat);
|
|
373
478
|
const tmpPath = join(statePath, 'tmp');
|
|
374
479
|
const configDir = join(statePath, '.claude');
|
|
375
480
|
await mkdir(statePath, { recursive: true, mode: 0o700 });
|
|
@@ -386,7 +491,15 @@ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
|
|
|
386
491
|
const configPath = await replacePrivateFile(
|
|
387
492
|
statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
|
|
388
493
|
);
|
|
389
|
-
|
|
494
|
+
if (login === 'none') {
|
|
495
|
+
await rm(join(configDir, '.credentials.json'), { force: true });
|
|
496
|
+
return { statePath, tmpPath, configPath, loginPath: null };
|
|
497
|
+
}
|
|
498
|
+
const loginPath = await carryClaudeLogin(ctx.env, configDir, login === 'access-only'
|
|
499
|
+
// A workspace seat's jail never sees the host's API key, so only a public
|
|
500
|
+
// seat is told to set one.
|
|
501
|
+
? { accessOnly: true, mustOutlastMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS, apiKeyHint: seat === 'public' }
|
|
502
|
+
: {});
|
|
390
503
|
return { statePath, tmpPath, configPath, loginPath };
|
|
391
504
|
};
|
|
392
505
|
|
|
@@ -640,12 +753,14 @@ const publicWitnessError = ({ code, tool, error }) => {
|
|
|
640
753
|
};
|
|
641
754
|
|
|
642
755
|
// The reply a turn posts. A public seat's turn is witnessed first (TASK-256).
|
|
756
|
+
// Both ways out of a turn are redacted: a reply that echoes a credential the
|
|
757
|
+
// child was handed (the per-turn proxy token included), and the witness error.
|
|
643
758
|
const turnReply = (stdout, { credentials, publicMcp }) => {
|
|
644
759
|
if (publicMcp) {
|
|
645
760
|
const failure = claudePublicMcpWitness(stdout, publicMcp);
|
|
646
|
-
if (failure) throw publicWitnessError(failure);
|
|
761
|
+
if (failure) throw publicWitnessError({ ...failure, error: redactKnownCredentials(String(failure.error), credentials) });
|
|
647
762
|
}
|
|
648
|
-
return claudeResultText(stdout, { credentials });
|
|
763
|
+
return redactKnownCredentials(claudeResultText(stdout, { credentials }), credentials);
|
|
649
764
|
};
|
|
650
765
|
|
|
651
766
|
// The failure tail for a non-zero exit. Stream events are mostly system init
|
|
@@ -1063,7 +1178,7 @@ const resolveClaudeInstall = (env) => {
|
|
|
1063
1178
|
|
|
1064
1179
|
// A jail that cannot carry claude would fail inside at every spawn.
|
|
1065
1180
|
const claudeInstallRefusal = (claudeBin) => new Error(
|
|
1066
|
-
'
|
|
1181
|
+
'Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
|
|
1067
1182
|
+ `\`${claudeBin}\` could not be resolved`,
|
|
1068
1183
|
);
|
|
1069
1184
|
|
|
@@ -1231,8 +1346,8 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
1231
1346
|
if (!bwrap.available) {
|
|
1232
1347
|
throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
|
|
1233
1348
|
}
|
|
1234
|
-
if (
|
|
1235
|
-
throw new Error(
|
|
1349
|
+
if (!ctx.publicClaudeState) {
|
|
1350
|
+
throw new Error(`${sandboxTrust} Claude state was not prepared before argv construction`);
|
|
1236
1351
|
}
|
|
1237
1352
|
innerArgv = [
|
|
1238
1353
|
...innerArgv,
|
|
@@ -1244,7 +1359,7 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
1244
1359
|
} else if (allowedPatterns.length > 0) {
|
|
1245
1360
|
innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
|
|
1246
1361
|
}
|
|
1247
|
-
if (workspaceBwrapSandbox) {
|
|
1362
|
+
if (workspaceBwrapSandbox || publicBwrapSandbox) {
|
|
1248
1363
|
// Seatbelt denies writes under .git by path, present or not. A bwrap mount
|
|
1249
1364
|
// needs the path to exist, so a writable project without a .git could get
|
|
1250
1365
|
// one from the child, hooks included, that the person's next git command
|
|
@@ -1432,6 +1547,10 @@ export default {
|
|
|
1432
1547
|
|
|
1433
1548
|
let mcpConfig = null;
|
|
1434
1549
|
let publicClaudeState = null;
|
|
1550
|
+
let providerProxy = null;
|
|
1551
|
+
// A login seat's gateway, when the host names one: config, not a secret.
|
|
1552
|
+
let publicLoginBaseUrl = null;
|
|
1553
|
+
let loginTurn = null;
|
|
1435
1554
|
let publicSkillArgs = [];
|
|
1436
1555
|
// D245: no jail to prepare for on a policy-only Windows host.
|
|
1437
1556
|
const jailed = !policyOnlyHost(ctx);
|
|
@@ -1441,6 +1560,18 @@ export default {
|
|
|
1441
1560
|
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
1442
1561
|
const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
|
|
1443
1562
|
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
1563
|
+
// The proxy's address and per-turn token, never the host's key.
|
|
1564
|
+
const withProvider = (env) => {
|
|
1565
|
+
if (providerProxy) {
|
|
1566
|
+
return Object.assign(env, { ANTHROPIC_BASE_URL: providerProxy.baseURL, ANTHROPIC_API_KEY: providerProxy.token });
|
|
1567
|
+
}
|
|
1568
|
+
return publicLoginBaseUrl ? Object.assign(env, { ANTHROPIC_BASE_URL: publicLoginBaseUrl }) : env;
|
|
1569
|
+
};
|
|
1570
|
+
// The values a failure tail or reply must never echo: the seat's own, and
|
|
1571
|
+
// the per-turn proxy token the child was handed in place of the host key.
|
|
1572
|
+
const turnCredentials = (spawnContext) => [
|
|
1573
|
+
...spawnCredentials(spawnContext), providerProxy?.token,
|
|
1574
|
+
].filter(Boolean);
|
|
1444
1575
|
try {
|
|
1445
1576
|
// Trust is normalized at entry (legacy `internal` reads as public). The
|
|
1446
1577
|
// MCP policy follows trust alone, whatever confines the seat: a public
|
|
@@ -1455,7 +1586,50 @@ export default {
|
|
|
1455
1586
|
if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
|
|
1456
1587
|
const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
|
|
1457
1588
|
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1458
|
-
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
|
|
1589
|
+
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only' });
|
|
1590
|
+
// The host's model route, when it is the one the login uses.
|
|
1591
|
+
publicLoginBaseUrl = loginRouteBaseUrl(ctx.env || process.env);
|
|
1592
|
+
} else if (publicBwrapSandbox) {
|
|
1593
|
+
// F96: the workspace tier's jail, reaching the model one of two ways.
|
|
1594
|
+
// An API key stays on the host behind a per-turn proxy; without one,
|
|
1595
|
+
// the seat gets an access-only copy of the login, deleted after the turn.
|
|
1596
|
+
// Either way the model is across the network: restricted networking
|
|
1597
|
+
// (--unshare-net) cuts off the host proxy and the vendor alike, so the
|
|
1598
|
+
// seat is refused before a proxy starts or a login is copied.
|
|
1599
|
+
if (ctx.environment?.sandbox?.network?.policy === 'restricted') {
|
|
1600
|
+
throw new Error('public Claude agents on Linux need shared bwrap networking to reach the model; '
|
|
1601
|
+
+ 'sandbox.network.policy "restricted" cuts it off.');
|
|
1602
|
+
}
|
|
1603
|
+
// A public seat sees its project and its own folder, nothing more: the
|
|
1604
|
+
// server's environment cannot bind more of the host into a jail that
|
|
1605
|
+
// strangers drive (goose refuses the same).
|
|
1606
|
+
for (const key of ['read-outside', 'write-outside']) {
|
|
1607
|
+
if (ctx.environment?.sandbox?.filesystem?.[key]?.length) {
|
|
1608
|
+
throw new Error(`public Claude agents on Linux do not allow sandbox.filesystem.${key}`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
1612
|
+
if (!bwrap.available) throw new Error(`public Claude agents require bwrap: ${bwrap.error}`);
|
|
1613
|
+
const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
|
|
1614
|
+
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1615
|
+
const source = ctx.env || process.env;
|
|
1616
|
+
if (source.ANTHROPIC_API_KEY) {
|
|
1617
|
+
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'none', seat: 'public' });
|
|
1618
|
+
providerProxy = await (ctx._startProviderProxy || startProviderProxy)({
|
|
1619
|
+
provider: { baseURL: source.ANTHROPIC_BASE_URL || 'https://api.anthropic.com' },
|
|
1620
|
+
keyMaterial: source.ANTHROPIC_API_KEY,
|
|
1621
|
+
wire: 'anthropic-messages',
|
|
1622
|
+
adapterLabel: 'Claude',
|
|
1623
|
+
});
|
|
1624
|
+
} else {
|
|
1625
|
+
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only', seat: 'public' });
|
|
1626
|
+
publicLoginBaseUrl = loginRouteBaseUrl(source);
|
|
1627
|
+
if (!publicClaudeState.loginPath) {
|
|
1628
|
+
throw new Error('public Claude agents on Linux need ANTHROPIC_API_KEY in the agent\'s environment, '
|
|
1629
|
+
+ 'or sign in with `claude` on this machine first.');
|
|
1630
|
+
}
|
|
1631
|
+
loginTurn = await registerLoginTurn(publicClaudeState.statePath);
|
|
1632
|
+
}
|
|
1459
1633
|
}
|
|
1460
1634
|
if (publicTrust) {
|
|
1461
1635
|
const prompt = await publicSkillPromptArgs(mountedSkills);
|
|
@@ -1480,14 +1654,14 @@ export default {
|
|
|
1480
1654
|
});
|
|
1481
1655
|
}
|
|
1482
1656
|
if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
|
|
1483
|
-
const claudeEnv = buildClaudeEnv(
|
|
1657
|
+
const claudeEnv = withProvider(buildClaudeEnv(
|
|
1484
1658
|
ctx.env,
|
|
1485
1659
|
publicClaudeState,
|
|
1486
1660
|
mcpConfig?.expansionEnv,
|
|
1487
1661
|
mcpConfig?.credential?.path || null,
|
|
1488
1662
|
publicTrust,
|
|
1489
1663
|
ctx._platform || process.platform,
|
|
1490
|
-
);
|
|
1664
|
+
));
|
|
1491
1665
|
if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
|
|
1492
1666
|
Object.assign(claudeEnv, ctx.commitAttributionEnv);
|
|
1493
1667
|
}
|
|
@@ -1511,7 +1685,7 @@ export default {
|
|
|
1511
1685
|
// check. Snapshot before the spawn: claude is subscription-authenticated
|
|
1512
1686
|
// and its provider key is not an env var here, so the seat credential is
|
|
1513
1687
|
// the value a tail can echo back.
|
|
1514
|
-
credentials:
|
|
1688
|
+
credentials: turnCredentials(spawnCtx),
|
|
1515
1689
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
|
|
1516
1690
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1517
1691
|
transcriptPath: claudeTranscriptPath({
|
|
@@ -1530,7 +1704,7 @@ export default {
|
|
|
1530
1704
|
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
1531
1705
|
});
|
|
1532
1706
|
return {
|
|
1533
|
-
text: turnReply(stdout, { credentials:
|
|
1707
|
+
text: turnReply(stdout, { credentials: turnCredentials(spawnCtx), publicMcp: witnessedMcp }),
|
|
1534
1708
|
newSessionId: sessionId,
|
|
1535
1709
|
contextTokens,
|
|
1536
1710
|
// D245 item 4: what the run said about itself, for the reply receipt.
|
|
@@ -1564,14 +1738,14 @@ export default {
|
|
|
1564
1738
|
mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
|
|
1565
1739
|
publicClaudeState,
|
|
1566
1740
|
publicSkillArgs,
|
|
1567
|
-
claudeEnv: buildClaudeEnv(
|
|
1741
|
+
claudeEnv: withProvider(buildClaudeEnv(
|
|
1568
1742
|
ctx.env,
|
|
1569
1743
|
publicClaudeState,
|
|
1570
1744
|
mcpConfig?.expansionEnv,
|
|
1571
1745
|
mcpConfig?.credential?.path || null,
|
|
1572
1746
|
publicTrust,
|
|
1573
1747
|
ctx._platform || process.platform,
|
|
1574
|
-
),
|
|
1748
|
+
)),
|
|
1575
1749
|
};
|
|
1576
1750
|
if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
|
|
1577
1751
|
Object.assign(retryCtx.claudeEnv, ctx.commitAttributionEnv);
|
|
@@ -1583,7 +1757,7 @@ export default {
|
|
|
1583
1757
|
cwd: ctx.cwd,
|
|
1584
1758
|
env: retry.env,
|
|
1585
1759
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
1586
|
-
credentials:
|
|
1760
|
+
credentials: turnCredentials(retryCtx),
|
|
1587
1761
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
1588
1762
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1589
1763
|
transcriptPath: claudeTranscriptPath({
|
|
@@ -1602,7 +1776,7 @@ export default {
|
|
|
1602
1776
|
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
1603
1777
|
});
|
|
1604
1778
|
return {
|
|
1605
|
-
text: turnReply(stdout, { credentials:
|
|
1779
|
+
text: turnReply(stdout, { credentials: turnCredentials(retryCtx), publicMcp: witnessedMcp }),
|
|
1606
1780
|
newSessionId: freshId,
|
|
1607
1781
|
contextTokens,
|
|
1608
1782
|
stats: claudeRunStats(stdout),
|
|
@@ -1613,6 +1787,13 @@ export default {
|
|
|
1613
1787
|
if (mcpConfig?.dir) {
|
|
1614
1788
|
try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1615
1789
|
}
|
|
1790
|
+
try { await providerProxy?.close(); } catch { /* ignore */ }
|
|
1791
|
+
// A public seat's login copy lives for its turns (F96): the last live
|
|
1792
|
+
// turn of the agent to finish removes it.
|
|
1793
|
+
if (publicBwrapSandbox && !workspaceBwrapSandbox && publicClaudeState?.loginPath) {
|
|
1794
|
+
if (loginTurn) await releaseLoginTurn(loginTurn, publicClaudeState.loginPath);
|
|
1795
|
+
else await rm(publicClaudeState.loginPath, { force: true });
|
|
1796
|
+
}
|
|
1616
1797
|
}
|
|
1617
1798
|
},
|
|
1618
1799
|
};
|
package/src/lib/sandbox/bwrap.js
CHANGED
|
@@ -35,9 +35,10 @@ const expandHome = (p) => {
|
|
|
35
35
|
// mechanisms that do exist are named instead.
|
|
36
36
|
const MACOS_MESSAGE = 'bwrap is Linux-only. On macOS a public seat runs under Seatbelt '
|
|
37
37
|
+ '(sandbox.mode workspace or read-only).';
|
|
38
|
-
// D245: Windows has no sandbox for agents
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// D245: native Windows has no sandbox for agents. WSL runs the Linux one,
|
|
39
|
+
// Claude Code included since F96, and host-check.js offers it; this message
|
|
40
|
+
// names the native path, which is the user's own opt-in
|
|
41
|
+
// (sandbox/windows-unsandboxed.js).
|
|
41
42
|
const WINDOWS_MESSAGE = 'bwrap is Linux-only, and native Windows has no sandbox for agents yet. '
|
|
42
43
|
+ 'To run this agent without one, with your own access to files and commands, add --unsandboxed.';
|
|
43
44
|
const nonLinuxMessage = (platform) => (platform === 'win32' ? WINDOWS_MESSAGE : MACOS_MESSAGE);
|
|
@@ -35,11 +35,13 @@ const HOSTED_LINE = ' - A hosted agent: choose "Run it here" on the Connect pag
|
|
|
35
35
|
/** What a person on native Windows can do instead. */
|
|
36
36
|
export const windowsSandboxMessage = (adapterName) => {
|
|
37
37
|
const label = ADAPTER_LABELS[adapterName] || adapterName;
|
|
38
|
-
// WSL
|
|
39
|
-
//
|
|
38
|
+
// WSL runs the Linux sandbox, which carries every adapter, Claude Code
|
|
39
|
+
// included (F96). Claude Code also has the MCP command, which needs none.
|
|
40
40
|
return [
|
|
41
41
|
`Commonly runs a connected ${label} inside a sandbox, and has no sandbox for Windows yet,`,
|
|
42
42
|
'so by default this agent cannot listen for @mentions from native Windows. You can:',
|
|
43
|
+
` - WSL 2 (Ubuntu): install Node and ${label} inside WSL, run \`sudo apt install bubblewrap\`,`
|
|
44
|
+
+ ' then run these same commands there.',
|
|
43
45
|
// D245: the user's own choice, never a default (sandbox/windows-unsandboxed.js).
|
|
44
46
|
...(adapterName === 'claude'
|
|
45
47
|
? [` - Run it without an OS sandbox: add ${UNSANDBOXED_FLAG}`
|
|
@@ -48,28 +50,11 @@ export const windowsSandboxMessage = (adapterName) => {
|
|
|
48
50
|
: []),
|
|
49
51
|
...(adapterName === 'claude'
|
|
50
52
|
? [' - Use Commonly from your own Claude Code sessions: the Connect page shows a PowerShell `claude mcp add` command.']
|
|
51
|
-
: [
|
|
52
|
-
+ ' then run these same commands there.']),
|
|
53
|
+
: []),
|
|
53
54
|
HOSTED_LINE,
|
|
54
55
|
].join('\n');
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
-
* Claude Code on Linux (WSL included) for any seat but a workspace one. Only
|
|
59
|
-
* the workspace jail (#2784) carries what Claude needs: its install, a HOME
|
|
60
|
-
* of its own and a copy of the login. The public jail binds the system binary
|
|
61
|
-
* folders only, so a confined spawn could not even start Claude (Ubuntu run,
|
|
62
|
-
* 2026-10-16), and giving it the login would hand strangers a jail that holds
|
|
63
|
-
* it. A workspace seat needs every pod it answers in to be invite-only.
|
|
64
|
-
*/
|
|
65
|
-
export const linuxClaudeMessage = () => [
|
|
66
|
-
'Commonly runs a connected Claude Code inside a sandbox. On Linux (WSL included) it does that only',
|
|
67
|
-
'for an agent whose pods are all invite-only, and this one is not, so it cannot listen for @mentions here. Instead:',
|
|
68
|
-
' - From an invite-only pod: run the commands its Connect page shows, from your project\'s folder.',
|
|
69
|
-
' - Use Commonly from your own Claude Code sessions: the Connect page shows a `claude mcp add` command.',
|
|
70
|
-
HOSTED_LINE,
|
|
71
|
-
].join('\n');
|
|
72
|
-
|
|
73
58
|
/** Why this host cannot confine the seat, or null when it can (or the seat declares no sandbox). */
|
|
74
59
|
export const hostSandboxRefusal = ({
|
|
75
60
|
environment,
|
|
@@ -92,11 +77,6 @@ export const hostSandboxRefusal = ({
|
|
|
92
77
|
if (platform === 'win32') {
|
|
93
78
|
return hostUnconfined === true && adapterName === 'claude' ? null : windowsSandboxMessage(adapterName);
|
|
94
79
|
}
|
|
95
|
-
// A workspace claude seat falls through to the bubblewrap probe below; the
|
|
96
|
-
// probe failing, for any reason, is a refusal like any other adapter's.
|
|
97
|
-
if (mode === 'bwrap' && adapterName === 'claude' && sandbox.trust !== 'workspace') {
|
|
98
|
-
return linuxClaudeMessage();
|
|
99
|
-
}
|
|
100
80
|
if (mode === 'bwrap') {
|
|
101
81
|
const bwrap = detectBwrap();
|
|
102
82
|
return bwrap.available ? null : `This seat runs in a bubblewrap sandbox, which is not available here. ${bwrap.error}`;
|
|
@@ -107,4 +87,4 @@ export const hostSandboxRefusal = ({
|
|
|
107
87
|
: `This seat runs in a macOS Seatbelt sandbox (sandbox.mode=${mode}), which is not available here. ${seatbelt.error}`;
|
|
108
88
|
};
|
|
109
89
|
|
|
110
|
-
export default { hostSandboxRefusal,
|
|
90
|
+
export default { hostSandboxRefusal, windowsSandboxMessage };
|