@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
|
|
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
|
|
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
|
|
28
|
-
throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter
|
|
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(
|
|
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', '--
|
|
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
|
|
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
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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(
|
package/src/lib/adapters/dsh.js
CHANGED
|
@@ -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 {
|
|
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
|