@commonlyai/cli 0.1.189 → 0.1.191

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.
@@ -1,7 +1,7 @@
1
- /** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */
1
+ /** Hermes 0.22 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */
2
2
  import { spawn as childSpawn, spawnSync } from 'child_process';
3
- import { createHash } from 'crypto';
4
- import { mkdir } from 'fs/promises';
3
+ import { createHash, randomBytes } from 'crypto';
4
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'fs/promises';
5
5
  import { homedir } from 'os';
6
6
  import { join } from 'path';
7
7
  import { StringDecoder } from 'string_decoder';
@@ -9,11 +9,64 @@ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
9
9
  import { requireSeatModel } from '../seat-model.js';
10
10
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
11
11
 
12
- export const TESTED_HERMES_VERSION = '0.22.2';
12
+ export const SUPPORTED_HERMES_VERSION_RANGE = '>=0.22.5 <0.23.0';
13
+ const supportsHermesVersion = (version) => {
14
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version || '');
15
+ if (!match) return false;
16
+ return Number(match[1]) === 0 && Number(match[2]) === 22 && Number(match[3]) >= 5;
17
+ };
13
18
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
14
19
  const VERSION_TIMEOUT_MS = 15000;
15
20
  const MAX_STDERR_CHARS = 4096;
16
21
  const MAX_LINE_CHARS = 1024 * 1024;
22
+ // Keys the wrapper owns in the profile's .env. Every other line is the user's.
23
+ const PROFILE_ENV_KEYS = ['OPENROUTER_API_KEY', 'OPENROUTER_BASE_URL'];
24
+ const PLAIN_ENV_VALUE = /^[A-Za-z0-9_\-.:/+=@%~]+$/;
25
+
26
+ const definedKey = (line) => {
27
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
28
+ return match ? match[1] : null;
29
+ };
30
+
31
+ // From 0.22.5 the detached gateway serves a profiles/<hash> home with a child
32
+ // env scrubbed of credentials and overlaid with that profile's own .env
33
+ // (tools/environments/local.py, served_profile_child_env), so a key in the
34
+ // spawn env never reaches the model. Hermes documents <profile>/.env as where
35
+ // a profile's API keys live (website/docs/user-guide/profiles.md). The file
36
+ // stays after the turn: the gateway outlives the spawn and rereads it.
37
+ export const syncProfileEnv = async (home, env) => {
38
+ const owned = PROFILE_ENV_KEYS.filter((key) => env[key]);
39
+ for (const key of owned) {
40
+ if (!PLAIN_ENV_VALUE.test(env[key])) {
41
+ throw new Error(`${key} contains characters a Hermes .env line cannot carry unquoted; refusing to write it`);
42
+ }
43
+ }
44
+ const path = join(home, '.env');
45
+ let current = '';
46
+ try {
47
+ current = await readFile(path, 'utf8');
48
+ } catch (error) {
49
+ if (error?.code !== 'ENOENT') throw new Error(`Cannot read the Hermes profile .env (${error?.code || 'read failed'})`);
50
+ }
51
+ const kept = current.split(/\r?\n/).filter((line, index, lines) => (
52
+ !owned.includes(definedKey(line)) && !(index === lines.length - 1 && line === '')
53
+ ));
54
+ const next = `${[...kept, ...owned.map((key) => `${key}=${env[key]}`)].join('\n')}\n`;
55
+ await chmod(home, 0o700);
56
+ if (next === current) {
57
+ await chmod(path, 0o600);
58
+ return;
59
+ }
60
+ const temp = join(home, `.env.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
61
+ try {
62
+ await writeFile(temp, next, { mode: 0o600, flag: 'wx' });
63
+ await chmod(temp, 0o600);
64
+ await rename(temp, path);
65
+ } catch (error) {
66
+ await rm(temp, { force: true });
67
+ throw new Error(`Cannot write the Hermes profile .env (${error?.code || 'write failed'})`);
68
+ }
69
+ };
17
70
 
18
71
  const readVersion = (env, spawnSyncImpl = spawnSync) => {
19
72
  let probe;
@@ -24,8 +77,8 @@ const readVersion = (env, spawnSyncImpl = spawnSync) => {
24
77
  if (probe.error?.code === 'ENOENT') return null;
25
78
  if (probe.error || probe.status !== 0) continue;
26
79
  const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1];
27
- if (version !== TESTED_HERMES_VERSION) {
28
- throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter requires ${TESTED_HERMES_VERSION}`);
80
+ if (!supportsHermesVersion(version)) {
81
+ throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter supports ${SUPPORTED_HERMES_VERSION_RANGE}. Run hermes update if yours is older than 0.22.5; releases 0.23 and later are not supported yet`);
29
82
  }
30
83
  return version;
31
84
  }
@@ -182,13 +235,18 @@ export const createHermesAdapter = ({
182
235
  await mkdir(home, { recursive: true, mode: 0o700 });
183
236
  const env = { ...(ctx.env || process.env), HERMES_HOME: home };
184
237
  if (!readVersion(env, ctx._spawnSyncImpl)) {
185
- throw new Error('Hermes Agent 0.22.2 is not on PATH');
238
+ throw new Error(`Hermes Agent ${SUPPORTED_HERMES_VERSION_RANGE} is not on PATH; install for your OS: https://hermes-agent.nousresearch.com/docs/getting-started/installation`);
186
239
  }
187
240
  if (!env.OPENROUTER_API_KEY) {
188
241
  throw new Error('Supply the operator OPENROUTER_API_KEY through Secret Manager before running Hermes');
189
242
  }
243
+ await syncProfileEnv(home, env);
244
+ const cwd = ctx.cwd || process.cwd();
245
+ // 0.22.5's gateway rejects --no-restore-cwd; --in pins the workspace on
246
+ // both fresh and resumed turns. 0.22.2-0.22.4 are refused above, so no
247
+ // version-keyed flag is needed.
190
248
  const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json',
191
- '--provider', 'openrouter', '--source', 'tool', '--no-restore-cwd'];
249
+ '--provider', 'openrouter', '--source', 'tool', '--in', cwd];
192
250
  if (seatModel) args.push('--model', seatModel);
193
251
  if (ctx.sessionId) args.push('--resume', ctx.sessionId);
194
252
  const fullPrompt = memoryPreamble(prompt, ctx.memoryLongTerm, {
@@ -198,8 +256,8 @@ export const createHermesAdapter = ({
198
256
  const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
199
257
  ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
200
258
  return runHermes({
201
- args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs,
202
- spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }),
259
+ args, prompt: fullPrompt, cwd, env, timeoutMs,
260
+ spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--in', '--resume'] }),
203
261
  credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']),
204
262
  });
205
263
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.189",
3
+ "version": "0.1.191",
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];
@@ -5,7 +5,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js';
5
5
  import { effectiveSandboxTrust } from '../environment.js';
6
6
  import { seatModelRequired } from './seat-model.js';
7
7
 
8
- export { TESTED_HERMES_VERSION, readHermesJsonl } from '@commonlyai/seats/adapters/hermes';
8
+ export { SUPPORTED_HERMES_VERSION_RANGE, readHermesJsonl, syncProfileEnv } from '@commonlyai/seats/adapters/hermes';
9
9
 
10
10
  // Commonly's policy for the generic Hermes adapter: its memory preamble, its
11
11
  // legacy sandbox-trust names, the fleet seat-model switch, and the profile