@commonlyai/cli 0.1.234 → 0.1.235

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.234",
3
+ "version": "0.1.235",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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 = detectBwrap();
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}.`
@@ -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
- await supervisor.tick();
658
- if (terminalEnded) return;
659
- await supervisor.heartbeat();
660
- if (terminalEnded) return;
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');
@@ -84,6 +84,7 @@ import {
84
84
  } from '../environment.js';
85
85
  import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
86
86
  import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from '../sandbox/mode.js';
87
+ import { UNSANDBOXED_FLAG } from '../sandbox/host-check.js';
87
88
  import {
88
89
  publicClaudeStateRoot,
89
90
  wrapArgvWithSeatbelt,
@@ -1154,6 +1155,16 @@ const prepareArgv = async (innerArgv, ctx) => {
1154
1155
  // unconfined seat with a public record, which is how a record can claim
1155
1156
  // confinement it never applies.
1156
1157
  if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
1158
+ // D245: on native Windows, name Windows and the user's own opt-in, never
1159
+ // macOS or bwrap advice (host-check passes an unrecognised mode through).
1160
+ if ((ctx._platform || process.platform) === 'win32') {
1161
+ throw new Error(
1162
+ `${sandboxTrust} Claude agents run inside a sandbox, and Commonly has no sandbox it can enforce on Windows `
1163
+ + `(got sandbox.mode=${JSON.stringify(sandboxMode)}). To run this agent without one, which is your choice, `
1164
+ + `run commonly agent run <name> ${UNSANDBOXED_FLAG} (or commonly daemon install ${UNSANDBOXED_FLAG} for the agents `
1165
+ + 'this computer\'s daemon runs), or choose "Run it here" on the Connect page for a hosted agent.',
1166
+ );
1167
+ }
1157
1168
  throw new Error(
1158
1169
  `${sandboxTrust} Claude agents require an enforced sandbox mode `
1159
1170
  + `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
@@ -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. Returns a stop function; does nothing outside the launcher.
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);
@@ -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
- const trust = environment?.sandbox?.trust;
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
  };