@commonlyai/cli 0.1.233 → 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.233",
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';
@@ -134,6 +134,7 @@ import {
134
134
  peerHoldsFrame,
135
135
  resolveCascadeSettings,
136
136
  } from '../lib/enforcement.js';
137
+ import { buildReplyReceipt } from '../lib/reply-receipt.js';
137
138
 
138
139
  const isPodFocusDiagnostic = (error) => (
139
140
  typeof error?.code === 'string'
@@ -1175,6 +1176,8 @@ export const performAttach = async ({
1175
1176
  log = () => {},
1176
1177
  adapterRegistry = { getAdapter, listAdapterNames },
1177
1178
  seatbelt: probeSeatbelt = detectSeatbelt,
1179
+ bwrap: probeBwrap = detectBwrap,
1180
+ platform = process.platform,
1178
1181
  }) => {
1179
1182
  const adapter = adapterRegistry.getAdapter(adapterName);
1180
1183
  if (!adapter) {
@@ -1209,10 +1212,37 @@ export const performAttach = async ({
1209
1212
  }
1210
1213
  log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
1211
1214
 
1212
- 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)));
1213
1242
  if (sandboxMode === 'bwrap') {
1214
- const bwrap = detectBwrap();
1243
+ const bwrap = probeBwrap();
1215
1244
  if (!bwrap.available) {
1245
+ if (platform === 'win32') throw windowsRefusal();
1216
1246
  throw new Error(`sandbox.mode=bwrap requested but unavailable: ${bwrap.error}`);
1217
1247
  }
1218
1248
  if (environment.sandbox?.network?.policy === 'restricted') {
@@ -1238,6 +1268,7 @@ export const performAttach = async ({
1238
1268
  || adapterName;
1239
1269
  const seatbelt = probeSeatbelt();
1240
1270
  if (!seatbelt.available) {
1271
+ if (platform === 'win32') throw windowsRefusal();
1241
1272
  // dsh refuses bwrap, so it gets no Linux hint that leads to a second refusal.
1242
1273
  throw new Error(adapterName === 'dsh'
1243
1274
  ? `confined dsh sandbox.mode=${sandboxMode} requires macOS Seatbelt: ${seatbelt.error}.`
@@ -2296,6 +2327,8 @@ export const performRun = ({
2296
2327
  ? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) }
2297
2328
  : null;
2298
2329
  let spawnCompleted = false;
2330
+ // D245 item 4: how long the runtime ran, for the reply receipt.
2331
+ let spawnDurationMs;
2299
2332
  let observedSpawn = false;
2300
2333
  let commitAttribution = null;
2301
2334
  try {
@@ -2364,7 +2397,9 @@ export const performRun = ({
2364
2397
  };
2365
2398
  // Supervised or plain, a failure is thrown exactly as the adapter's own
2366
2399
  // would be, so the run loop's classification and retry apply unchanged.
2400
+ const spawnStartedAt = Date.now();
2367
2401
  result = await turn.run(adapter, spawnPrompt, spawnCtx);
2402
+ spawnDurationMs = Date.now() - spawnStartedAt;
2368
2403
  spawnCompleted = true;
2369
2404
  } finally {
2370
2405
  try {
@@ -2551,6 +2586,12 @@ export const performRun = ({
2551
2586
  maxChunks: maxChatChunks,
2552
2587
  uploadName: `${agentName}-reply-${event._id}.md`,
2553
2588
  log: (line) => log(`[${event.type}] ${line}`),
2589
+ // D245 item 4: the wrapper's own account of this turn.
2590
+ receipt: buildReplyReceipt({
2591
+ adapterName: adapter.name,
2592
+ stats: result.stats,
2593
+ durationMs: spawnDurationMs,
2594
+ }),
2554
2595
  });
2555
2596
  } catch (deliveryError) {
2556
2597
  // TASK-535: every rung threw, so the reply reached nobody. The throw
@@ -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');
@@ -53,6 +53,7 @@ import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/t
53
53
  */
54
54
 
55
55
  import { requireSeatModel, seatModelRequired } from './seat-model.js';
56
+ import { claudeRunStats } from '../reply-receipt.js';
56
57
  import { spawn as childSpawn, spawnSync } from 'child_process';
57
58
  import { createHash, randomUUID } from 'crypto';
58
59
  import { accessSync, constants, realpathSync } from 'fs';
@@ -83,6 +84,7 @@ import {
83
84
  } from '../environment.js';
84
85
  import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
85
86
  import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from '../sandbox/mode.js';
87
+ import { UNSANDBOXED_FLAG } from '../sandbox/host-check.js';
86
88
  import {
87
89
  publicClaudeStateRoot,
88
90
  wrapArgvWithSeatbelt,
@@ -1153,6 +1155,16 @@ const prepareArgv = async (innerArgv, ctx) => {
1153
1155
  // unconfined seat with a public record, which is how a record can claim
1154
1156
  // confinement it never applies.
1155
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
+ }
1156
1168
  throw new Error(
1157
1169
  `${sandboxTrust} Claude agents require an enforced sandbox mode `
1158
1170
  + `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
@@ -1521,6 +1533,8 @@ export default {
1521
1533
  text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1522
1534
  newSessionId: sessionId,
1523
1535
  contextTokens,
1536
+ // D245 item 4: what the run said about itself, for the reply receipt.
1537
+ stats: claudeRunStats(stdout),
1524
1538
  };
1525
1539
  } catch (err) {
1526
1540
  // A public seat's refusal is not a session-store failure, whatever its
@@ -1591,6 +1605,7 @@ export default {
1591
1605
  text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
1592
1606
  newSessionId: freshId,
1593
1607
  contextTokens,
1608
+ stats: claudeRunStats(stdout),
1594
1609
  };
1595
1610
  }
1596
1611
  throw err;
@@ -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);
@@ -668,6 +668,9 @@ export const deliverChatReply = async ({
668
668
  attachThreshold = 800,
669
669
  uploadName = 'reply.md',
670
670
  log = () => {},
671
+ // D245 item 4: how this reply was made (lib/reply-receipt.js). It rides
672
+ // the first message that actually reaches the pod and no later one.
673
+ receipt = null,
671
674
  }) => {
672
675
  const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
673
676
  const chunks = splitForChat(text, { limit });
@@ -697,7 +700,14 @@ export const deliverChatReply = async ({
697
700
  // override per delivery; a post that still fails throws to the next rung as
698
701
  // before. Nothing here loops.
699
702
  let freshnessOverride = false;
700
- const postMessage = async (body) => {
703
+ let receiptDelivered = !receipt;
704
+ const postOnce = async (rawBody) => {
705
+ const body = receiptDelivered ? rawBody : { ...rawBody, receipt };
706
+ const response = await postGuarded(body);
707
+ if (response?.refused !== true && response?.skipped !== true) receiptDelivered = true;
708
+ return response;
709
+ };
710
+ const postGuarded = async (body) => {
701
711
  if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
702
712
  try {
703
713
  return await client.post(messagesPath, body);
@@ -774,7 +784,7 @@ export const deliverChatReply = async ({
774
784
  // the gate (found by the fleet's implementation audit).
775
785
  const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
776
786
  if (chunks.length <= 1 && !hasIndivisibleOversize) {
777
- const response = await postMessage({ content: chunks[0] ?? text });
787
+ const response = await postOnce({ content: chunks[0] ?? text });
778
788
  if (undelivered(response)) return nonDelivery(response, 0, 1);
779
789
  return { mode: 'single', messages: 1 };
780
790
  }
@@ -782,7 +792,7 @@ export const deliverChatReply = async ({
782
792
  let messages = 0;
783
793
  for (const chunk of chunks) {
784
794
  // eslint-disable-next-line no-await-in-loop
785
- const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
795
+ const response = await postOnce({ content: chunk }); // in order, so the reply reads top-down
786
796
  if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
787
797
  messages += 1;
788
798
  }
@@ -802,7 +812,7 @@ export const deliverChatReply = async ({
802
812
  // fail at the root-id step before any continuation has posted.
803
813
  let posted = 0;
804
814
  try {
805
- const rootRes = await postMessage({ content: chunks[0] });
815
+ const rootRes = await postOnce({ content: chunks[0] });
806
816
  if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
807
817
  posted = 1;
808
818
  // The runtime route answers `res.json(result)` with the created row on
@@ -814,7 +824,7 @@ export const deliverChatReply = async ({
814
824
  if (!rootId) throw new Error('no message id in post response — cannot root the thread');
815
825
  for (const chunk of chunks.slice(1)) {
816
826
  // eslint-disable-next-line no-await-in-loop
817
- const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
827
+ const response = await postOnce({ content: chunk, threadRootId: String(rootId) });
818
828
  if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
819
829
  posted += 1;
820
830
  }
@@ -829,7 +839,7 @@ export const deliverChatReply = async ({
829
839
  log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
830
840
  for (const chunk of chunks.slice(posted)) {
831
841
  // eslint-disable-next-line no-await-in-loop
832
- const response = await postMessage({ content: chunk });
842
+ const response = await postOnce({ content: chunk });
833
843
  if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
834
844
  posted += 1;
835
845
  }
@@ -855,7 +865,7 @@ export const deliverChatReply = async ({
855
865
  const lead = chunks[0] && chunks[0].length <= limit
856
866
  ? chunks[0]
857
867
  : '(reply too large for chat — attached in full)';
858
- const response = await postMessage({ content: `${lead}\n\n${directive}` });
868
+ const response = await postOnce({ content: `${lead}\n\n${directive}` });
859
869
  if (undelivered(response)) return nonDelivery(response, 0, 1);
860
870
  return { mode: 'attach', messages: 1 };
861
871
  } catch (err) {
@@ -863,7 +873,7 @@ export const deliverChatReply = async ({
863
873
  let messages = 0;
864
874
  for (const chunk of chunks) {
865
875
  // eslint-disable-next-line no-await-in-loop
866
- const response = await postMessage({ content: chunk });
876
+ const response = await postOnce({ content: chunk });
867
877
  if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
868
878
  messages += 1;
869
879
  }
@@ -0,0 +1,53 @@
1
+ // D245 item 4: the receipt the wrapper posts with a reply, so a BYO reply
2
+ // carries "how this reply was made" the way a hosted reply has its run trace.
3
+ // It is the wrapper's own account and the server stores it as self-reported
4
+ // (backend/services/byoReceipt.ts), which also bounds and checks every field.
5
+ // This side keeps to what it actually observed: an adapter that reports no
6
+ // stats sends only its name and the measured duration.
7
+
8
+ const isName = (value) => typeof value === 'string' && value.length > 0;
9
+ const isCount = (value) => Number.isInteger(value) && value >= 0;
10
+
11
+ const parseLine = (raw) => {
12
+ try { return JSON.parse(raw); } catch { return null; }
13
+ };
14
+
15
+ /**
16
+ * What a Claude Code stream-json run says about itself: the model from the
17
+ * init event, the turn count from the result event, and each tool it called,
18
+ * once, in first-use order.
19
+ */
20
+ export const claudeRunStats = (stdout) => {
21
+ const events = String(stdout || '').split('\n').map(parseLine).filter(Boolean);
22
+ const stats = {};
23
+ const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
24
+ if (isName(init?.model)) stats.model = init.model;
25
+ const result = [...events].reverse().find((event) => event.type === 'result');
26
+ if (isCount(result?.num_turns)) stats.turns = result.num_turns;
27
+ const tools = [];
28
+ for (const event of events) {
29
+ if (event.type !== 'assistant') continue;
30
+ for (const block of event.message?.content || []) {
31
+ if (block?.type === 'tool_use' && isName(block.name) && !tools.includes(block.name)) tools.push(block.name);
32
+ }
33
+ }
34
+ if (tools.length) stats.tools = tools;
35
+ return stats;
36
+ };
37
+
38
+ /** The receipt for one reply, or null when there is no adapter to name. */
39
+ export const buildReplyReceipt = ({ adapterName, stats = {}, durationMs } = {}) => {
40
+ if (!isName(adapterName)) return null;
41
+ const receipt = { adapter: adapterName };
42
+ // A cloud-hosted model can be named by an ARN or a resource path that
43
+ // carries the account or project it runs in; that is not this reply's to
44
+ // publish, so such a model id is left out.
45
+ if (isName(stats?.model) && !/^arn:|(^|\/)projects\//i.test(stats.model)) receipt.model = stats.model;
46
+ if (isCount(stats?.turns)) receipt.turns = stats.turns;
47
+ if (Array.isArray(stats?.tools)) {
48
+ const tools = stats.tools.filter(isName);
49
+ if (tools.length) receipt.tools = tools;
50
+ }
51
+ if (Number.isFinite(durationMs) && durationMs >= 0) receipt.durationMs = Math.round(durationMs);
52
+ return receipt;
53
+ };
@@ -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
  };