@commonlyai/cli 0.1.234 → 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.
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +7 -2
- package/package.json +1 -1
- package/src/commands/agent.js +43 -6
- package/src/commands/daemon.js +48 -19
- package/src/lib/adapters/claude.js +225 -33
- package/src/lib/daemon-service-windows.js +8 -1
- package/src/lib/sandbox/bwrap.js +4 -3
- package/src/lib/sandbox/host-check.js +6 -26
- package/src/lib/sandbox/windows-unsandboxed.js +4 -1
|
@@ -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
|
@@ -89,7 +89,7 @@ import { createClaimedWorkGate } from '../lib/claimed-work-gate.js';
|
|
|
89
89
|
import { prepareCommitAttribution } from '../lib/commit-attribution.js';
|
|
90
90
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
91
91
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
92
|
-
import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
|
|
92
|
+
import { UNSANDBOXED_FLAG, hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
|
|
93
93
|
import {
|
|
94
94
|
UNSANDBOXED_NOTICE, hostUnconfinedForRun, runOptedInUnsandboxed, unsandboxedFlagRefusal,
|
|
95
95
|
} from '../lib/sandbox/windows-unsandboxed.js';
|
|
@@ -1176,6 +1176,8 @@ export const performAttach = async ({
|
|
|
1176
1176
|
log = () => {},
|
|
1177
1177
|
adapterRegistry = { getAdapter, listAdapterNames },
|
|
1178
1178
|
seatbelt: probeSeatbelt = detectSeatbelt,
|
|
1179
|
+
bwrap: probeBwrap = detectBwrap,
|
|
1180
|
+
platform = process.platform,
|
|
1179
1181
|
}) => {
|
|
1180
1182
|
const adapter = adapterRegistry.getAdapter(adapterName);
|
|
1181
1183
|
if (!adapter) {
|
|
@@ -1210,10 +1212,37 @@ export const performAttach = async ({
|
|
|
1210
1212
|
}
|
|
1211
1213
|
log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
|
|
1212
1214
|
|
|
1213
|
-
const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter });
|
|
1215
|
+
const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter, platform });
|
|
1216
|
+
// D245: native Windows has neither sandbox, so its refusal names Windows
|
|
1217
|
+
// and the user's own opt-in, never Seatbelt or bwrap advice. Attach still
|
|
1218
|
+
// refuses there, as before; only the words differ.
|
|
1219
|
+
const windowsRefusal = () => new Error(adapterName === 'dsh'
|
|
1220
|
+
? `confined dsh sandbox.mode=${sandboxMode} needs a sandbox, and Commonly has no sandbox for Windows yet, `
|
|
1221
|
+
+ 'so dsh cannot run confined on native Windows. Choose "Run it here" on the Connect page for a hosted agent.'
|
|
1222
|
+
: `This environment file asks for sandbox.mode=${sandboxMode}, which Commonly cannot enforce on Windows, `
|
|
1223
|
+
+ 'so attach stops here.\n'
|
|
1224
|
+
// This attach made no agent, so Claude's options name the path that works on
|
|
1225
|
+
// Windows: the Connect page's commands, which set the two env vars and run with
|
|
1226
|
+
// the flag, keeping the public trust and policy (#3407 review B1). Other
|
|
1227
|
+
// adapters keep the shared Windows text.
|
|
1228
|
+
+ (adapterName === 'claude' ? [
|
|
1229
|
+
'Instead:',
|
|
1230
|
+
' - Run it without an OS sandbox, which is your choice: use the commands on the Connect page.'
|
|
1231
|
+
+ ' They set COMMONLY_API_URL and COMMONLY_AGENT_TOKEN, then run'
|
|
1232
|
+
+ ` commonly agent run <name> --adapter claude ${UNSANDBOXED_FLAG}.`
|
|
1233
|
+
+ " It keeps Commonly's restricted tool settings and gets no shell, but nothing at the OS level"
|
|
1234
|
+
+ ' confines it, and it gets no connector grants.'
|
|
1235
|
+
// agent run prefers a saved record over the page's env vars, so an
|
|
1236
|
+
// older record of that name would run with its own settings (#3407).
|
|
1237
|
+
+ ' If this computer already has an agent saved under that name, the run uses that one and its'
|
|
1238
|
+
+ ' settings instead: remove it first with commonly agent detach <name> --force.',
|
|
1239
|
+
' - Use Commonly from your own Claude Code sessions: the Connect page shows a PowerShell `claude mcp add` command.',
|
|
1240
|
+
' - A hosted agent: choose "Run it here" on the Connect page. Nothing runs on this machine.',
|
|
1241
|
+
].join('\n') : windowsSandboxMessage(adapterName)));
|
|
1214
1242
|
if (sandboxMode === 'bwrap') {
|
|
1215
|
-
const bwrap =
|
|
1243
|
+
const bwrap = probeBwrap();
|
|
1216
1244
|
if (!bwrap.available) {
|
|
1245
|
+
if (platform === 'win32') throw windowsRefusal();
|
|
1217
1246
|
throw new Error(`sandbox.mode=bwrap requested but unavailable: ${bwrap.error}`);
|
|
1218
1247
|
}
|
|
1219
1248
|
if (environment.sandbox?.network?.policy === 'restricted') {
|
|
@@ -1239,6 +1268,7 @@ export const performAttach = async ({
|
|
|
1239
1268
|
|| adapterName;
|
|
1240
1269
|
const seatbelt = probeSeatbelt();
|
|
1241
1270
|
if (!seatbelt.available) {
|
|
1271
|
+
if (platform === 'win32') throw windowsRefusal();
|
|
1242
1272
|
// dsh refuses bwrap, so it gets no Linux hint that leads to a second refusal.
|
|
1243
1273
|
throw new Error(adapterName === 'dsh'
|
|
1244
1274
|
? `confined dsh sandbox.mode=${sandboxMode} requires macOS Seatbelt: ${seatbelt.error}.`
|
|
@@ -1595,6 +1625,8 @@ export const performRun = ({
|
|
|
1595
1625
|
setTimeoutImpl = setTimeout,
|
|
1596
1626
|
setIntervalImpl = setInterval,
|
|
1597
1627
|
clearIntervalImpl = clearInterval,
|
|
1628
|
+
// The host's bubblewrap probe, for a turn placed public; tests pin it.
|
|
1629
|
+
detectBwrapImpl,
|
|
1598
1630
|
setDeadlineTimeoutImpl = setTimeout,
|
|
1599
1631
|
clearDeadlineTimeoutImpl = clearTimeout,
|
|
1600
1632
|
turnTimeoutMs,
|
|
@@ -2109,14 +2141,19 @@ export const performRun = ({
|
|
|
2109
2141
|
const placement = await placeTurn();
|
|
2110
2142
|
// A workspace turn placed public (its pod turned public, or its pods could
|
|
2111
2143
|
// not be read) is confined as a public seat, which this host may not be
|
|
2112
|
-
// able to do
|
|
2113
|
-
//
|
|
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
|
|
2114
2146
|
// never runs and nothing is posted. A public pod is a settled answer, so a
|
|
2115
2147
|
// human's message is handed to another listener (claim-outcome.js). A
|
|
2116
2148
|
// failed read is not: it throws, so the event stays unacked and comes back
|
|
2117
2149
|
// through the spawn-retry backoff instead of being refused for good.
|
|
2118
2150
|
if (placement.reason) {
|
|
2119
|
-
const turnRefusal = hostSandboxRefusal({
|
|
2151
|
+
const turnRefusal = hostSandboxRefusal({
|
|
2152
|
+
environment: placement.environment,
|
|
2153
|
+
adapterName: adapter.name,
|
|
2154
|
+
hostUnconfined,
|
|
2155
|
+
...(detectBwrapImpl ? { detectBwrap: detectBwrapImpl } : {}),
|
|
2156
|
+
});
|
|
2120
2157
|
if (turnRefusal && placement.readError) {
|
|
2121
2158
|
throw Object.assign(
|
|
2122
2159
|
new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
|
package/src/commands/daemon.js
CHANGED
|
@@ -300,6 +300,34 @@ export const resolveAdapterForRuntime = async (runtime, registry = { getAdapter
|
|
|
300
300
|
return null;
|
|
301
301
|
};
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The daemon's first poll and heartbeat, with the Windows launcher watch armed
|
|
305
|
+
* BEFORE them (D245). Arming first puts the EPIPE guard on stdout and stderr
|
|
306
|
+
* before any supervision log line, and a launcher that is already gone stops
|
|
307
|
+
* the daemon here instead of after a tick logged into a dead pipe. Returns
|
|
308
|
+
* false when the daemon should not go on to its timers.
|
|
309
|
+
*/
|
|
310
|
+
export const firstSupervisionPass = async ({
|
|
311
|
+
supervisor,
|
|
312
|
+
onLauncherGone,
|
|
313
|
+
isEnded = () => false,
|
|
314
|
+
armLauncherWatch = watchWindowsLauncher,
|
|
315
|
+
}) => {
|
|
316
|
+
let launcherGone = false;
|
|
317
|
+
armLauncherWatch({
|
|
318
|
+
onGone: (pid) => {
|
|
319
|
+
launcherGone = true;
|
|
320
|
+
onLauncherGone(pid);
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
const stopped = () => launcherGone || isEnded();
|
|
324
|
+
if (stopped()) return false;
|
|
325
|
+
await supervisor.tick();
|
|
326
|
+
if (stopped()) return false;
|
|
327
|
+
await supervisor.heartbeat();
|
|
328
|
+
return !stopped();
|
|
329
|
+
};
|
|
330
|
+
|
|
303
331
|
export const registerDaemon = (program) => {
|
|
304
332
|
const daemon = program.command('daemon').description('Manage the local Commonly daemon');
|
|
305
333
|
|
|
@@ -654,10 +682,26 @@ Examples:
|
|
|
654
682
|
});
|
|
655
683
|
|
|
656
684
|
stampLog(`daemon supervising for ${record.machineName} — poll ${opts.poll}ms, heartbeat ${opts.heartbeat}ms (ctrl+c to stop)`);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
685
|
+
const shutdown = () => {
|
|
686
|
+
stampLog('daemon stopping — terminating supervised agents');
|
|
687
|
+
clearInterval(pollTimer);
|
|
688
|
+
clearInterval(heartbeatTimer);
|
|
689
|
+
supervisor.stop();
|
|
690
|
+
process.exit(0);
|
|
691
|
+
};
|
|
692
|
+
// D245: under the Windows launcher, the launcher's exit is the stop
|
|
693
|
+
// signal. Armed before the first tick, so the EPIPE guard covers its
|
|
694
|
+
// log lines and a launcher already gone stops the daemon at once.
|
|
695
|
+
const started = await firstSupervisionPass({
|
|
696
|
+
supervisor,
|
|
697
|
+
onLauncherGone: (pid) => {
|
|
698
|
+
// stdout and stderr already swallow EPIPE (armed with the watch).
|
|
699
|
+
try { stampLog(`launcher ${pid} has exited`); } catch { /* stdout is gone */ }
|
|
700
|
+
shutdown();
|
|
701
|
+
},
|
|
702
|
+
isEnded: () => terminalEnded,
|
|
703
|
+
});
|
|
704
|
+
if (!started) return;
|
|
661
705
|
const runtimeFailure = (error) => {
|
|
662
706
|
if (terminalEnded) return;
|
|
663
707
|
terminalEnded = true;
|
|
@@ -669,23 +713,8 @@ Examples:
|
|
|
669
713
|
};
|
|
670
714
|
pollTimer = setInterval(() => { void supervisor.tick().catch(runtimeFailure); }, Number(opts.poll) || DEFAULT_POLL_MS);
|
|
671
715
|
heartbeatTimer = setInterval(() => { void supervisor.heartbeat().catch(runtimeFailure); }, Number(opts.heartbeat) || DEFAULT_HEARTBEAT_MS);
|
|
672
|
-
const shutdown = () => {
|
|
673
|
-
stampLog('daemon stopping — terminating supervised agents');
|
|
674
|
-
clearInterval(pollTimer);
|
|
675
|
-
clearInterval(heartbeatTimer);
|
|
676
|
-
supervisor.stop();
|
|
677
|
-
process.exit(0);
|
|
678
|
-
};
|
|
679
716
|
process.on('SIGINT', shutdown);
|
|
680
717
|
process.on('SIGTERM', shutdown);
|
|
681
|
-
// D245: under the Windows launcher, the launcher's exit is the stop signal.
|
|
682
|
-
watchWindowsLauncher({
|
|
683
|
-
onGone: (pid) => {
|
|
684
|
-
// stdout and stderr already swallow EPIPE (armed with the watch).
|
|
685
|
-
try { stampLog(`launcher ${pid} has exited`); } catch { /* stdout is gone */ }
|
|
686
|
-
shutdown();
|
|
687
|
-
},
|
|
688
|
-
});
|
|
689
718
|
} catch (error) {
|
|
690
719
|
console.error(`Daemon run failed: ${error.message}`);
|
|
691
720
|
const badConfig = !record || error.message.startsWith('Daemon credential file');
|
|
@@ -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,
|
|
@@ -84,6 +86,7 @@ import {
|
|
|
84
86
|
} from '../environment.js';
|
|
85
87
|
import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
|
|
86
88
|
import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from '../sandbox/mode.js';
|
|
89
|
+
import { UNSANDBOXED_FLAG } from '../sandbox/host-check.js';
|
|
87
90
|
import {
|
|
88
91
|
publicClaudeStateRoot,
|
|
89
92
|
wrapArgvWithSeatbelt,
|
|
@@ -97,11 +100,12 @@ import {
|
|
|
97
100
|
import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
98
101
|
import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
|
|
99
102
|
import { skillsAsText } from '../skill-prompt.js';
|
|
100
|
-
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
103
|
+
import { adapterFailure, redactKnownCredentials, spawnCredentials } from '../upstream-refusal.js';
|
|
101
104
|
import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
|
|
102
105
|
import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
|
|
103
106
|
import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
|
|
104
107
|
import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
|
|
108
|
+
import { startProviderProxy } from '../provider-proxy.js';
|
|
105
109
|
|
|
106
110
|
// See codex.js for the rationale on bumping the default + env override.
|
|
107
111
|
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
@@ -324,14 +328,71 @@ const replacePrivateFile = async (dir, name, content) => {
|
|
|
324
328
|
return target;
|
|
325
329
|
};
|
|
326
330
|
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
335
396
|
const claudeLoginSource = (env) => join(
|
|
336
397
|
env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
|
|
337
398
|
'.credentials.json',
|
|
@@ -339,7 +400,39 @@ const claudeLoginSource = (env) => join(
|
|
|
339
400
|
|
|
340
401
|
// Opened once, without following a symlink, and checked on the open handle,
|
|
341
402
|
// so the file read is the file checked (a FIFO cannot hold the open either).
|
|
342
|
-
|
|
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 } = {}) => {
|
|
343
436
|
const source = claudeLoginSource(env);
|
|
344
437
|
const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
|
|
345
438
|
let handle;
|
|
@@ -355,20 +448,33 @@ const carryClaudeLogin = async (env, configDir) => {
|
|
|
355
448
|
}
|
|
356
449
|
try {
|
|
357
450
|
if (!(await handle.stat()).isFile()) throw refuse();
|
|
358
|
-
|
|
451
|
+
const text = await handle.readFile('utf8');
|
|
452
|
+
return replacePrivateFile(configDir, '.credentials.json', accessOnly
|
|
453
|
+
? accessOnlyLogin(text, { source, mustOutlastMs, apiKeyHint })
|
|
454
|
+
: text);
|
|
359
455
|
} finally {
|
|
360
456
|
await handle.close();
|
|
361
457
|
}
|
|
362
458
|
};
|
|
363
459
|
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// and the
|
|
367
|
-
//
|
|
368
|
-
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;
|
|
369
466
|
const identity = ctx.agentName || ctx.cwd || 'anonymous';
|
|
370
467
|
const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
|
|
371
|
-
|
|
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);
|
|
372
478
|
const tmpPath = join(statePath, 'tmp');
|
|
373
479
|
const configDir = join(statePath, '.claude');
|
|
374
480
|
await mkdir(statePath, { recursive: true, mode: 0o700 });
|
|
@@ -385,7 +491,15 @@ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
|
|
|
385
491
|
const configPath = await replacePrivateFile(
|
|
386
492
|
statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
|
|
387
493
|
);
|
|
388
|
-
|
|
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
|
+
: {});
|
|
389
503
|
return { statePath, tmpPath, configPath, loginPath };
|
|
390
504
|
};
|
|
391
505
|
|
|
@@ -639,12 +753,14 @@ const publicWitnessError = ({ code, tool, error }) => {
|
|
|
639
753
|
};
|
|
640
754
|
|
|
641
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.
|
|
642
758
|
const turnReply = (stdout, { credentials, publicMcp }) => {
|
|
643
759
|
if (publicMcp) {
|
|
644
760
|
const failure = claudePublicMcpWitness(stdout, publicMcp);
|
|
645
|
-
if (failure) throw publicWitnessError(failure);
|
|
761
|
+
if (failure) throw publicWitnessError({ ...failure, error: redactKnownCredentials(String(failure.error), credentials) });
|
|
646
762
|
}
|
|
647
|
-
return claudeResultText(stdout, { credentials });
|
|
763
|
+
return redactKnownCredentials(claudeResultText(stdout, { credentials }), credentials);
|
|
648
764
|
};
|
|
649
765
|
|
|
650
766
|
// The failure tail for a non-zero exit. Stream events are mostly system init
|
|
@@ -1062,7 +1178,7 @@ const resolveClaudeInstall = (env) => {
|
|
|
1062
1178
|
|
|
1063
1179
|
// A jail that cannot carry claude would fail inside at every spawn.
|
|
1064
1180
|
const claudeInstallRefusal = (claudeBin) => new Error(
|
|
1065
|
-
'
|
|
1181
|
+
'Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
|
|
1066
1182
|
+ `\`${claudeBin}\` could not be resolved`,
|
|
1067
1183
|
);
|
|
1068
1184
|
|
|
@@ -1154,6 +1270,16 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
1154
1270
|
// unconfined seat with a public record, which is how a record can claim
|
|
1155
1271
|
// confinement it never applies.
|
|
1156
1272
|
if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
|
|
1273
|
+
// D245: on native Windows, name Windows and the user's own opt-in, never
|
|
1274
|
+
// macOS or bwrap advice (host-check passes an unrecognised mode through).
|
|
1275
|
+
if ((ctx._platform || process.platform) === 'win32') {
|
|
1276
|
+
throw new Error(
|
|
1277
|
+
`${sandboxTrust} Claude agents run inside a sandbox, and Commonly has no sandbox it can enforce on Windows `
|
|
1278
|
+
+ `(got sandbox.mode=${JSON.stringify(sandboxMode)}). To run this agent without one, which is your choice, `
|
|
1279
|
+
+ `run commonly agent run <name> ${UNSANDBOXED_FLAG} (or commonly daemon install ${UNSANDBOXED_FLAG} for the agents `
|
|
1280
|
+
+ 'this computer\'s daemon runs), or choose "Run it here" on the Connect page for a hosted agent.',
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1157
1283
|
throw new Error(
|
|
1158
1284
|
`${sandboxTrust} Claude agents require an enforced sandbox mode `
|
|
1159
1285
|
+ `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
|
|
@@ -1220,8 +1346,8 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
1220
1346
|
if (!bwrap.available) {
|
|
1221
1347
|
throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
|
|
1222
1348
|
}
|
|
1223
|
-
if (
|
|
1224
|
-
throw new Error(
|
|
1349
|
+
if (!ctx.publicClaudeState) {
|
|
1350
|
+
throw new Error(`${sandboxTrust} Claude state was not prepared before argv construction`);
|
|
1225
1351
|
}
|
|
1226
1352
|
innerArgv = [
|
|
1227
1353
|
...innerArgv,
|
|
@@ -1233,7 +1359,7 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
1233
1359
|
} else if (allowedPatterns.length > 0) {
|
|
1234
1360
|
innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
|
|
1235
1361
|
}
|
|
1236
|
-
if (workspaceBwrapSandbox) {
|
|
1362
|
+
if (workspaceBwrapSandbox || publicBwrapSandbox) {
|
|
1237
1363
|
// Seatbelt denies writes under .git by path, present or not. A bwrap mount
|
|
1238
1364
|
// needs the path to exist, so a writable project without a .git could get
|
|
1239
1365
|
// one from the child, hooks included, that the person's next git command
|
|
@@ -1421,6 +1547,10 @@ export default {
|
|
|
1421
1547
|
|
|
1422
1548
|
let mcpConfig = null;
|
|
1423
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;
|
|
1424
1554
|
let publicSkillArgs = [];
|
|
1425
1555
|
// D245: no jail to prepare for on a policy-only Windows host.
|
|
1426
1556
|
const jailed = !policyOnlyHost(ctx);
|
|
@@ -1430,6 +1560,18 @@ export default {
|
|
|
1430
1560
|
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
1431
1561
|
const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
|
|
1432
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);
|
|
1433
1575
|
try {
|
|
1434
1576
|
// Trust is normalized at entry (legacy `internal` reads as public). The
|
|
1435
1577
|
// MCP policy follows trust alone, whatever confines the seat: a public
|
|
@@ -1444,7 +1586,50 @@ export default {
|
|
|
1444
1586
|
if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
|
|
1445
1587
|
const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
|
|
1446
1588
|
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1447
|
-
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
|
+
}
|
|
1448
1633
|
}
|
|
1449
1634
|
if (publicTrust) {
|
|
1450
1635
|
const prompt = await publicSkillPromptArgs(mountedSkills);
|
|
@@ -1469,14 +1654,14 @@ export default {
|
|
|
1469
1654
|
});
|
|
1470
1655
|
}
|
|
1471
1656
|
if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
|
|
1472
|
-
const claudeEnv = buildClaudeEnv(
|
|
1657
|
+
const claudeEnv = withProvider(buildClaudeEnv(
|
|
1473
1658
|
ctx.env,
|
|
1474
1659
|
publicClaudeState,
|
|
1475
1660
|
mcpConfig?.expansionEnv,
|
|
1476
1661
|
mcpConfig?.credential?.path || null,
|
|
1477
1662
|
publicTrust,
|
|
1478
1663
|
ctx._platform || process.platform,
|
|
1479
|
-
);
|
|
1664
|
+
));
|
|
1480
1665
|
if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
|
|
1481
1666
|
Object.assign(claudeEnv, ctx.commitAttributionEnv);
|
|
1482
1667
|
}
|
|
@@ -1500,7 +1685,7 @@ export default {
|
|
|
1500
1685
|
// check. Snapshot before the spawn: claude is subscription-authenticated
|
|
1501
1686
|
// and its provider key is not an env var here, so the seat credential is
|
|
1502
1687
|
// the value a tail can echo back.
|
|
1503
|
-
credentials:
|
|
1688
|
+
credentials: turnCredentials(spawnCtx),
|
|
1504
1689
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
|
|
1505
1690
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1506
1691
|
transcriptPath: claudeTranscriptPath({
|
|
@@ -1519,7 +1704,7 @@ export default {
|
|
|
1519
1704
|
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
1520
1705
|
});
|
|
1521
1706
|
return {
|
|
1522
|
-
text: turnReply(stdout, { credentials:
|
|
1707
|
+
text: turnReply(stdout, { credentials: turnCredentials(spawnCtx), publicMcp: witnessedMcp }),
|
|
1523
1708
|
newSessionId: sessionId,
|
|
1524
1709
|
contextTokens,
|
|
1525
1710
|
// D245 item 4: what the run said about itself, for the reply receipt.
|
|
@@ -1553,14 +1738,14 @@ export default {
|
|
|
1553
1738
|
mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
|
|
1554
1739
|
publicClaudeState,
|
|
1555
1740
|
publicSkillArgs,
|
|
1556
|
-
claudeEnv: buildClaudeEnv(
|
|
1741
|
+
claudeEnv: withProvider(buildClaudeEnv(
|
|
1557
1742
|
ctx.env,
|
|
1558
1743
|
publicClaudeState,
|
|
1559
1744
|
mcpConfig?.expansionEnv,
|
|
1560
1745
|
mcpConfig?.credential?.path || null,
|
|
1561
1746
|
publicTrust,
|
|
1562
1747
|
ctx._platform || process.platform,
|
|
1563
|
-
),
|
|
1748
|
+
)),
|
|
1564
1749
|
};
|
|
1565
1750
|
if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
|
|
1566
1751
|
Object.assign(retryCtx.claudeEnv, ctx.commitAttributionEnv);
|
|
@@ -1572,7 +1757,7 @@ export default {
|
|
|
1572
1757
|
cwd: ctx.cwd,
|
|
1573
1758
|
env: retry.env,
|
|
1574
1759
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
1575
|
-
credentials:
|
|
1760
|
+
credentials: turnCredentials(retryCtx),
|
|
1576
1761
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
1577
1762
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1578
1763
|
transcriptPath: claudeTranscriptPath({
|
|
@@ -1591,7 +1776,7 @@ export default {
|
|
|
1591
1776
|
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
1592
1777
|
});
|
|
1593
1778
|
return {
|
|
1594
|
-
text: turnReply(stdout, { credentials:
|
|
1779
|
+
text: turnReply(stdout, { credentials: turnCredentials(retryCtx), publicMcp: witnessedMcp }),
|
|
1595
1780
|
newSessionId: freshId,
|
|
1596
1781
|
contextTokens,
|
|
1597
1782
|
stats: claudeRunStats(stdout),
|
|
@@ -1602,6 +1787,13 @@ export default {
|
|
|
1602
1787
|
if (mcpConfig?.dir) {
|
|
1603
1788
|
try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1604
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
|
+
}
|
|
1605
1797
|
}
|
|
1606
1798
|
},
|
|
1607
1799
|
};
|
|
@@ -201,7 +201,8 @@ const pidAlive = (pid) => {
|
|
|
201
201
|
* keep running, unsupervised and invisible to the task (seen on windows-latest,
|
|
202
202
|
* 11-04: a stopped task left its daemon checking in). A daemon started by the
|
|
203
203
|
* launcher therefore polls the launcher's pid and calls `onGone` once it has
|
|
204
|
-
* exited
|
|
204
|
+
* exited, at once if it is already gone when the watch is armed. Returns a
|
|
205
|
+
* stop function; does nothing outside the launcher.
|
|
205
206
|
*/
|
|
206
207
|
export const watchWindowsLauncher = ({
|
|
207
208
|
env = process.env,
|
|
@@ -221,6 +222,12 @@ export const watchWindowsLauncher = ({
|
|
|
221
222
|
// swallow errors from the moment the watch is armed, not only after the
|
|
222
223
|
// launcher's exit is noticed up to two seconds later.
|
|
223
224
|
for (const stream of streams) stream?.on?.('error', () => {});
|
|
225
|
+
// Already gone when armed (the daemon is armed before its first tick): say
|
|
226
|
+
// so now, rather than after a poll and a heartbeat logged into a dead pipe.
|
|
227
|
+
if (!isAlive(pid)) {
|
|
228
|
+
onGone(pid);
|
|
229
|
+
return () => {};
|
|
230
|
+
}
|
|
224
231
|
const timer = setIntervalFn(() => {
|
|
225
232
|
if (isAlive(pid)) return;
|
|
226
233
|
clearIntervalFn(timer);
|
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 };
|
|
@@ -41,6 +41,7 @@ import { homedir } from 'os';
|
|
|
41
41
|
import { join } from 'path';
|
|
42
42
|
|
|
43
43
|
import { writeServiceFile } from '../daemon-service.js';
|
|
44
|
+
import { effectiveSandboxTrust } from '../environment.js';
|
|
44
45
|
import { UNSANDBOXED_FLAG } from './host-check.js';
|
|
45
46
|
|
|
46
47
|
export { UNSANDBOXED_FLAG };
|
|
@@ -100,7 +101,9 @@ export const POLICY_ONLY_ADAPTERS = new Set(['claude']);
|
|
|
100
101
|
export const hostUnconfinedForRun = ({
|
|
101
102
|
optedIn, adapterName, environment, platform = process.platform,
|
|
102
103
|
}) => {
|
|
103
|
-
|
|
104
|
+
// The effective trust, as host-check.js and the claude adapter read it: a
|
|
105
|
+
// legacy `internal` record means `public`, so it gets the same opt-in.
|
|
106
|
+
const trust = effectiveSandboxTrust(environment?.sandbox?.trust);
|
|
104
107
|
return platform === 'win32' && optedIn === true && POLICY_ONLY_ADAPTERS.has(adapterName)
|
|
105
108
|
&& (trust === 'public' || trust === 'workspace');
|
|
106
109
|
};
|