@commonlyai/cli 0.1.190 → 0.1.192

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.190",
3
+ "version": "0.1.192",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -939,8 +939,16 @@ export const updateAgentConfiguration = async ({
939
939
  * two gates, because those gates compare `trust` directly.
940
940
  */
941
941
  export const resolveAttachSandbox = ({
942
- environment, adapterName, platform = process.platform,
942
+ environment, adapterName, adapter = null, platform = process.platform,
943
943
  } = {}) => {
944
+ // An adapter that enforces one confined shape of its own (dsh: Seatbelt with a
945
+ // restricted network and NO trust) says so here, and its check is the one that
946
+ // decides: the trust-keyed gates below cannot express that shape, so before
947
+ // this they refused the only environment dsh accepts (D106). The adapter's
948
+ // check throws on every other shape, so this admits nothing it would refuse.
949
+ if (typeof adapter?.confinesOwnSandbox === 'function' && adapter.confinesOwnSandbox(environment)) {
950
+ return { mode: environment.sandbox.mode, trust: environment.sandbox.trust };
951
+ }
944
952
  const sandbox = normalizeSandboxTrust(environment?.sandbox);
945
953
  const trust = sandbox?.trust;
946
954
  // `workspace` is claude-only for now (TASK-294). Refused here with the words
@@ -988,6 +996,7 @@ export const performAttach = async ({
988
996
  memoryInstance = 'default',
989
997
  log = () => {},
990
998
  adapterRegistry = { getAdapter, listAdapterNames },
999
+ seatbelt: probeSeatbelt = detectSeatbelt,
991
1000
  }) => {
992
1001
  const adapter = adapterRegistry.getAdapter(adapterName);
993
1002
  if (!adapter) {
@@ -1022,7 +1031,7 @@ export const performAttach = async ({
1022
1031
  }
1023
1032
  log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
1024
1033
 
1025
- const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName });
1034
+ const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter });
1026
1035
  if (sandboxMode === 'bwrap') {
1027
1036
  const bwrap = detectBwrap();
1028
1037
  if (!bwrap.available) {
@@ -1047,19 +1056,20 @@ export const performAttach = async ({
1047
1056
  + '(deny-by-default filesystem + network)',
1048
1057
  );
1049
1058
  } else {
1050
- const seatbelt = detectSeatbelt();
1051
- if (!seatbelt.available) {
1052
- throw new Error(
1053
- `public Claude sandbox.mode=${sandboxMode} requires macOS Seatbelt: `
1054
- + `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`,
1055
- );
1056
- }
1057
1059
  const adapterLabel = ({ opencode: 'OpenCode', qwen: 'Qwen', claude: 'Claude' })[adapterName]
1058
1060
  || adapterName;
1059
- log(
1060
- `sandbox: public ${sandboxMode} via macOS Seatbelt `
1061
- + `(deny-by-default host filesystem; ${adapterLabel}/MCP network retained)`,
1062
- );
1061
+ const seatbelt = probeSeatbelt();
1062
+ if (!seatbelt.available) {
1063
+ // dsh refuses bwrap, so it gets no Linux hint that leads to a second refusal.
1064
+ throw new Error(adapterName === 'dsh'
1065
+ ? `confined dsh sandbox.mode=${sandboxMode} requires macOS Seatbelt: ${seatbelt.error}.`
1066
+ : `public ${adapterLabel} sandbox.mode=${sandboxMode} requires macOS Seatbelt: `
1067
+ + `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`);
1068
+ }
1069
+ log(adapterName === 'dsh'
1070
+ ? `sandbox: confined dsh ${sandboxMode} via macOS Seatbelt (restricted network)`
1071
+ : `sandbox: public ${sandboxMode} via macOS Seatbelt `
1072
+ + `(deny-by-default host filesystem; ${adapterLabel}/MCP network retained)`);
1063
1073
  }
1064
1074
  } else if (sandboxMode !== 'none' && sandboxMode !== undefined) {
1065
1075
  throw new Error(
@@ -55,6 +55,43 @@ export const isConfinedDsh = (environment = {}) => {
55
55
 
56
56
  export const assertPrivateDsh = (environment = {}) => { isConfinedDsh(environment); };
57
57
 
58
+ /**
59
+ * The macOS developer tools a confined writer needs for git, granted read-only.
60
+ * /usr/bin/git is an xcrun shim: inside the sandbox it cannot read the tools it
61
+ * forwards to, so `git` fails with "No developer tools were found" and a writer
62
+ * cannot commit (D106). Returns null when no tools are installed; the seat then
63
+ * runs without git rather than failing.
64
+ *
65
+ * DEVELOPER_DIR and xcode-select are both settable by whoever starts the seat,
66
+ * so the resolved path is granted only when it is one of the two places Apple
67
+ * installs developer tools. Anything else (/, a home directory) returns null
68
+ * rather than widening what the sandbox can read.
69
+ */
70
+ const DEVELOPER_DIR_SHAPES = [
71
+ /^\/Library\/Developer\/CommandLineTools$/,
72
+ /^\/Applications\/[^/]+\.app\/Contents\/Developer$/,
73
+ ];
74
+
75
+ export const developerToolsDir = ({
76
+ env = process.env, run = spawnSync, exists = existsSync, realpath = realpathSync,
77
+ } = {}) => {
78
+ const declared = env?.DEVELOPER_DIR;
79
+ const dir = declared || String(run('/usr/bin/xcode-select', ['-p'], { encoding: 'utf8' })?.stdout || '').trim();
80
+ if (!dir || !isAbsolute(dir) || !exists(dir)) return null;
81
+ const resolved = realpath(dir);
82
+ return DEVELOPER_DIR_SHAPES.some((shape) => shape.test(resolved)) ? resolved : null;
83
+ };
84
+
85
+ /**
86
+ * The confined child's PATH with /usr/bin first, so bare `git` is the xcrun
87
+ * shim the developer-tools grant serves. Seat runners put /opt/homebrew/bin
88
+ * ahead of it, and Homebrew git fails inside the sandbox reading its own
89
+ * /opt/homebrew/etc/gitconfig. Other entries keep their order, so tools
90
+ * /usr/bin lacks (node, npm) still resolve as before.
91
+ */
92
+ export const systemToolsFirst = (pathValue) => ['/usr/bin', ...String(pathValue || '').split(delimiter)
93
+ .filter((dir) => dir && dir !== '/usr/bin')].join(delimiter);
94
+
58
95
  const onPath = (name, env) => {
59
96
  if (isAbsolute(name)) return existsSync(name) ? name : null;
60
97
  for (const dir of String(env?.PATH || '').split(delimiter)) {
@@ -296,6 +333,9 @@ export default {
296
333
  requiresModel: true,
297
334
  name: 'dsh', runtimeType: 'dsh', supportsProvider: true,
298
335
 
336
+ // The attach gate defers to this for dsh's one confined shape (D106).
337
+ confinesOwnSandbox: (environment) => isConfinedDsh(environment),
338
+
299
339
  async validateEnvironment(environment, workspacePath) {
300
340
  const validation = validateEnvironmentSpec(environment);
301
341
  if (!validation.ok) throw new Error(validation.errors.join('; '));
@@ -408,6 +448,11 @@ export default {
408
448
  for (const name of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) env[name] = egress.url;
409
449
  env.NO_PROXY = '127.0.0.1,localhost';
410
450
  env.no_proxy = env.NO_PROXY;
451
+ const developerDir = (ctx._developerToolsDir || developerToolsDir)({ env: sourceEnv });
452
+ if (developerDir) {
453
+ env.DEVELOPER_DIR = developerDir;
454
+ env.PATH = systemToolsFirst(env.PATH);
455
+ }
411
456
  [command, ...commandArgs] = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([process.execPath, entry, ...args], {
412
457
  workspacePath: ctx.cwd,
413
458
  workspaceAccess: 'write',
@@ -416,7 +461,7 @@ export default {
416
461
  mcpConfigDir: tempDir,
417
462
  network: 'loopback',
418
463
  loopbackNetworkPorts: [proxy.port, relay.port, egress.port],
419
- readOnlyDirectories: [packages, ...mcpDirectories],
464
+ readOnlyDirectories: [packages, ...mcpDirectories, ...(developerDir ? [developerDir] : [])],
420
465
  });
421
466
  }
422
467
  const credentials = [...spawnCredentials(ctx, []), bearer];
@@ -65,7 +65,7 @@ const ISOLATED_INSTANCE_PORT = 8766;
65
65
  const LOOPBACK_BRIDGE = fileURLToPath(new URL('../sandbox/opencode-loopback.mjs', import.meta.url));
66
66
  const TERMINATION_GRACE_MS = 5000;
67
67
  // Bump only with a passing real sandbox-exec smoke against the new version.
68
- const TESTED_OPENCODE_VERSIONS = Object.freeze(['1.18.35', '1.19.0']);
68
+ const TESTED_OPENCODE_VERSIONS = Object.freeze(['1.18.35', '1.19.0', '1.19.4']);
69
69
  const ALLOW_UNTESTED_OPENCODE_VERSION_ENV = 'COMMONLY_OPENCODE_ALLOW_UNTESTED_VERSION';
70
70
  const VERSION_CHECK_TIMEOUT_MS = 5000;
71
71
  const OPENCODE_CONFIG_GITIGNORE = [
@@ -1,4 +1,4 @@
1
- /** Qwen Code 0.25.1 wrapper. Public turns have fresh HOME, config and keys. */
1
+ /** Qwen Code 0.25.1 and 0.26.2 wrapper. Public turns have fresh HOME, config and keys. */
2
2
  import { requireSeatModel } from './seat-model.js';
3
3
  import { spawn as childSpawn, spawnSync } from 'child_process';
4
4
  import { lstatSync, realpathSync } from 'fs';
@@ -17,7 +17,7 @@ import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js
17
17
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
18
18
 
19
19
  // Add a version only with a passing real clean-box sandbox smoke that names it.
20
- const TESTED_QWEN_VERSIONS = Object.freeze(['0.25.1']);
20
+ const TESTED_QWEN_VERSIONS = Object.freeze(['0.25.1', '0.26.2']);
21
21
  const ALLOW_UNTESTED_QWEN_VERSION_ENV = 'COMMONLY_QWEN_ALLOW_UNTESTED_VERSION';
22
22
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
23
23
  const TERMINATION_GRACE_MS = 5000;