@ours.network/fleet 1.1.4 → 1.1.5
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/README.md +68 -0
- package/dist/briefing.js +4 -3
- package/dist/build-info.json +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +23 -2
- package/dist/doctor.js +21 -2
- package/dist/harness/acp-mcp.d.ts +14 -0
- package/dist/harness/acp-mcp.js +68 -0
- package/dist/harness/claude-code.js +1 -69
- package/dist/harness/hermes-compatibility.d.ts +24 -0
- package/dist/harness/hermes-compatibility.js +191 -0
- package/dist/harness/hermes-config.d.ts +12 -0
- package/dist/harness/hermes-config.js +367 -0
- package/dist/harness/hermes-permissions.d.ts +4 -0
- package/dist/harness/hermes-permissions.js +36 -0
- package/dist/harness/hermes-session.d.ts +24 -0
- package/dist/harness/hermes-session.js +85 -0
- package/dist/harness/hermes-startup.d.ts +3 -0
- package/dist/harness/hermes-startup.js +21 -0
- package/dist/harness/hermes.d.ts +5 -0
- package/dist/harness/hermes.js +62 -0
- package/dist/harness/registry.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/session/acp.d.ts +15 -0
- package/dist/session/acp.js +22 -7
- package/dist/spawn.d.ts +1 -0
- package/dist/spawn.js +1 -0
- package/examples/fleet/brains/hermes.yaml +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, parse, resolve } from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
5
|
+
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
6
|
+
import { acpMcpServersFor, validateMcpServers } from './acp-mcp.js';
|
|
7
|
+
import { translateHermesPermissions } from './hermes-permissions.js';
|
|
8
|
+
const mapping = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
const credentialPathName = (key) => /(?:^|_)CREDENTIALS(?:_PATH|_FILE)?$/i.test(key);
|
|
10
|
+
const reserved = (key) => /^(?:_?HERMES_|OURS_|COPILOT_|CODEX_HOME$|TERMINAL_|OPENAI_|ANTHROPIC_|AZURE_|AWS_|GOOGLE_|GEMINI_|GROQ_|OPENROUTER_|NOUS_|TOGETHER_|FIREWORKS_|DEEPSEEK_|XAI_|MISTRAL_|COHERE_|OLLAMA_|LM_STUDIO_|VLLM_)/i.test(key)
|
|
11
|
+
|| /(?:_API_KEY|_TOKEN|_SECRET|_PASSWORD|_BASE_URL|_KEY)$/i.test(key) || credentialPathName(key);
|
|
12
|
+
// Native providers may choose arbitrary credential variable names (key_env).
|
|
13
|
+
// An execution allowlist is therefore the inherited baseline, not a credential denylist.
|
|
14
|
+
const executionKey = (key) => /^(?:PATH|HOME|USER|LOGNAME|SHELL|LANG|LANGUAGE|LC_[A-Z_]+|TERM|COLORTERM|TMPDIR|TMP|TEMP|TZ|SystemRoot|SYSTEMROOT|WINDIR|COMSPEC|ComSpec|PATHEXT|USERPROFILE|HOMEDRIVE|HOMEPATH|APPDATA|LOCALAPPDATA|PROGRAMDATA|PROGRAMFILES|ProgramFiles|NUMBER_OF_PROCESSORS|OS|PROCESSOR_ARCHITECTURE)$/i.test(key);
|
|
15
|
+
export function validateHermesOptions(options) {
|
|
16
|
+
if (options == null)
|
|
17
|
+
return [];
|
|
18
|
+
if (!mapping(options))
|
|
19
|
+
return [{ path: 'harness_options', message: 'must be a map' }];
|
|
20
|
+
const errors = Object.keys(options).filter(key => key !== 'mcp_servers').map(key => ({ path: `harness_options.${key}`, message: 'unsupported Hermes option; allowed: mcp_servers (provider and credentials must be provisioned in the stopped native home)' }));
|
|
21
|
+
errors.push(...validateMcpServers(options.mcp_servers));
|
|
22
|
+
if (!errors.length && options.mcp_servers != null) {
|
|
23
|
+
const servers = options.mcp_servers;
|
|
24
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
25
|
+
const remote = server.type === 'http' || server.type === 'sse';
|
|
26
|
+
const allowed = remote ? ['type', 'url', 'headers'] : ['type', 'command', 'args', 'env'];
|
|
27
|
+
for (const key of Object.keys(server))
|
|
28
|
+
if (!allowed.includes(key))
|
|
29
|
+
errors.push({ path: `harness_options.mcp_servers.${name}.${key}`, message: 'unsupported field for this MCP transport' });
|
|
30
|
+
for (const key of Object.keys(server.env ?? {}))
|
|
31
|
+
if (/^(?:OURS_|_?HERMES_)/i.test(key))
|
|
32
|
+
errors.push({ path: `harness_options.mcp_servers.${name}.env.${key}`, message: 'reserved Fleet/Hermes environment variable' });
|
|
33
|
+
if (remote) {
|
|
34
|
+
try {
|
|
35
|
+
if (!['http:', 'https:'].includes(new URL(server.url).protocol))
|
|
36
|
+
throw new Error();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
errors.push({ path: `harness_options.mcp_servers.${name}.url`, message: 'must be an absolute HTTP(S) URL' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (servers.ours && !identicalOurs(servers.ours))
|
|
44
|
+
errors.push({ path: 'harness_options.mcp_servers.ours', message: 'reserved ours connector must be { command: ours-mcp, args: [proxy] } with no overrides' });
|
|
45
|
+
}
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
48
|
+
function identicalOurs(server) {
|
|
49
|
+
return Object.keys(server).every(key => ['type', 'command', 'args', 'env'].includes(key))
|
|
50
|
+
&& (server.type == null || server.type === 'stdio') && server.command === 'ours-mcp'
|
|
51
|
+
&& JSON.stringify(server.args) === '["proxy"]' && Object.keys(server.env ?? {}).length === 0;
|
|
52
|
+
}
|
|
53
|
+
export function validateHermesRole(role) {
|
|
54
|
+
const errors = validateHermesOptions(role.harness_options);
|
|
55
|
+
if (typeof role.model !== 'string' || !role.model.trim())
|
|
56
|
+
errors.push({ path: 'model', message: 'Hermes requires an explicit non-empty Brain model' });
|
|
57
|
+
for (const key of ['effort', 'model_chain'])
|
|
58
|
+
if (role[key] != null)
|
|
59
|
+
errors.push({ path: key, message: `Hermes does not support ${key}` });
|
|
60
|
+
if (role.session !== 'acp')
|
|
61
|
+
errors.push({ path: 'session', message: 'Hermes requires session: acp' });
|
|
62
|
+
if (role.monitor?.mode === 'native')
|
|
63
|
+
errors.push({ path: 'monitor.mode', message: 'Hermes requires Fleet-owned monitoring' });
|
|
64
|
+
if (role.monitor?.interrupt === 'after_tool')
|
|
65
|
+
errors.push({ path: 'monitor.interrupt', message: 'Hermes does not support after_tool' });
|
|
66
|
+
const permissions = translateHermesPermissions(role.permissions);
|
|
67
|
+
if (!permissions.supported)
|
|
68
|
+
errors.push({ path: 'permissions', message: permissions.reason });
|
|
69
|
+
for (const key of Object.keys(role.env ?? {}))
|
|
70
|
+
if (reserved(key))
|
|
71
|
+
errors.push({ path: `env.${key}`, message: 'reserved Hermes/Fleet or provider variable; provision provider credentials in the stopped native home' });
|
|
72
|
+
return errors;
|
|
73
|
+
}
|
|
74
|
+
function throwErrors(errors) {
|
|
75
|
+
if (errors.length)
|
|
76
|
+
throw new Error(errors.map(e => `${e.path}: ${e.message}`).join('; '));
|
|
77
|
+
}
|
|
78
|
+
// Match the supported native config expander's ${VAR} and ${env:VAR} shapes.
|
|
79
|
+
const hasNativeInterpolation = (value) => typeof value === 'string' && /\$\{[^}]+\}/.test(value);
|
|
80
|
+
// Native config.py's credential vocabulary, plus its model.api alias below.
|
|
81
|
+
const credentialFields = new Set(['api_key', 'apikey', 'key', 'token', 'access_token', 'refresh_token', 'id_token', 'secret', 'client_secret', 'password', 'passwd', 'auth', 'authorization', 'private_key', 'bearer', 'jwt']);
|
|
82
|
+
const credentialEnvName = (key) => credentialFields.has(key.toLowerCase()) || /(?:_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PASSWD|_KEY)$/i.test(key) || credentialPathName(key);
|
|
83
|
+
function configReferences(value) {
|
|
84
|
+
return [...value.matchAll(/\$\{([^}]+)\}/g)].flatMap(match => {
|
|
85
|
+
const inner = match[1].trim();
|
|
86
|
+
const name = inner.startsWith('env:') ? inner.slice(4).trim()
|
|
87
|
+
: /^[a-z][a-z0-9_-]*:/.test(inner) ? '' : inner;
|
|
88
|
+
return name ? [name] : [];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function nativeCredentialKeys(config) {
|
|
92
|
+
const keys = new Set();
|
|
93
|
+
// YAML aliases can reach the same object through ordinary and credential fields.
|
|
94
|
+
const seen = [new Set(), new Set()];
|
|
95
|
+
const visit = (value, credential = false) => {
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
if (credential)
|
|
98
|
+
for (const name of configReferences(value))
|
|
99
|
+
keys.add(name);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (value === null || typeof value !== 'object' || seen[Number(credential)].has(value))
|
|
103
|
+
return;
|
|
104
|
+
seen[Number(credential)].add(value);
|
|
105
|
+
for (const [key, child] of Object.entries(value)) {
|
|
106
|
+
if ((key === 'key_env' || key === 'api_key_env') && typeof child === 'string' && child.trim()) {
|
|
107
|
+
if (hasNativeInterpolation(child))
|
|
108
|
+
throw new Error('Hermes credential variable names do not support interpolation in a Fleet-managed home; provision literal key_env/api_key_env names with the role stopped');
|
|
109
|
+
keys.add(child.trim());
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
visit(child, credential || credentialFields.has(key.toLowerCase()) || key === 'extra_headers' || (value === config.model && key === 'api'));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
visit(config);
|
|
116
|
+
return keys;
|
|
117
|
+
}
|
|
118
|
+
function dotenvSources(content, sources) {
|
|
119
|
+
const lines = content.split(/\r\n?|\n/);
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const assignment = /^\s*(?:export\s+)?(?:'([^']+)'|([^\s=#]+))\s*=/.exec(lines[i]);
|
|
122
|
+
if (!assignment)
|
|
123
|
+
continue;
|
|
124
|
+
const key = assignment[1] ?? assignment[2];
|
|
125
|
+
let value = lines[i].slice(assignment[0].length).trimStart();
|
|
126
|
+
let ambiguous = false;
|
|
127
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
128
|
+
// Only discover references: do not expand values or resolve assignment precedence.
|
|
129
|
+
const quoted = value.startsWith('"') ? /^"((?:\\"|[^"])*)"/ : /^'((?:\\'|[^'])*)'/;
|
|
130
|
+
let match = quoted.exec(value);
|
|
131
|
+
let last = i;
|
|
132
|
+
let combined = value;
|
|
133
|
+
while (!match && last + 1 < lines.length) {
|
|
134
|
+
combined += '\n' + lines[++last];
|
|
135
|
+
match = quoted.exec(combined);
|
|
136
|
+
}
|
|
137
|
+
if (match) {
|
|
138
|
+
value = match[1];
|
|
139
|
+
ambiguous = last !== i;
|
|
140
|
+
i = last;
|
|
141
|
+
}
|
|
142
|
+
else
|
|
143
|
+
ambiguous = true;
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
value = value.replace(/\s+#.*$/, '').trimEnd();
|
|
147
|
+
// python-dotenv uses ${NAME} / ${NAME:-default}, not Hermes config's env: prefix.
|
|
148
|
+
const references = [...value.matchAll(/\$\{([^}:]*)(?::-[^}]*)?\}/g)].map(match => match[1]);
|
|
149
|
+
const source = sources.get(key) ?? { references: new Set(), ambiguous: false };
|
|
150
|
+
for (const name of references)
|
|
151
|
+
source.references.add(name);
|
|
152
|
+
source.ambiguous ||= references.length > 0 && (ambiguous || value.includes('\\'));
|
|
153
|
+
sources.set(key, source);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function validateNativeCredentialOverrides(role, config, home) {
|
|
157
|
+
const keys = nativeCredentialKeys(config);
|
|
158
|
+
const sources = new Map();
|
|
159
|
+
for (const name of ['.env', '.op.env']) {
|
|
160
|
+
const content = validateHomeDotenv(join(home, name));
|
|
161
|
+
if (content !== undefined)
|
|
162
|
+
dotenvSources(content, sources);
|
|
163
|
+
}
|
|
164
|
+
for (const key of sources.keys())
|
|
165
|
+
if (credentialEnvName(key))
|
|
166
|
+
keys.add(key);
|
|
167
|
+
// Set iteration visits newly added dependencies; cycles terminate without evaluating secrets.
|
|
168
|
+
for (const key of keys) {
|
|
169
|
+
const source = sources.get(key);
|
|
170
|
+
if (source?.ambiguous)
|
|
171
|
+
throw new Error('Hermes credential dotenv interpolation must use single-line assignments without escape encoding; provision it with the role stopped');
|
|
172
|
+
for (const dependency of source?.references ?? [])
|
|
173
|
+
keys.add(dependency);
|
|
174
|
+
}
|
|
175
|
+
for (const key of keys)
|
|
176
|
+
if (Object.hasOwn(role.env ?? {}, key))
|
|
177
|
+
throw new Error('role.env overrides a native credential variable; provision credentials in the stopped Hermes home');
|
|
178
|
+
return keys;
|
|
179
|
+
}
|
|
180
|
+
/** Complete environment: transport MUST use inheritEnvironment:false after this final merge. */
|
|
181
|
+
export function hermesChildEnvironment(role, home, trustedFleetEnv, inherited = process.env) {
|
|
182
|
+
for (const key of Object.keys(role.env ?? {}))
|
|
183
|
+
if (reserved(key))
|
|
184
|
+
throw new Error(`env.${key} is reserved; provision native credentials in the stopped Hermes home`);
|
|
185
|
+
const credentialKeys = validateNativeCredentialOverrides(role, readConfig(join(home, 'config.yaml')), home);
|
|
186
|
+
const env = {};
|
|
187
|
+
for (const [key, value] of Object.entries(inherited))
|
|
188
|
+
if (value !== undefined && executionKey(key) && !credentialKeys.has(key))
|
|
189
|
+
env[key] = value;
|
|
190
|
+
Object.assign(env, role.env);
|
|
191
|
+
for (const [key, value] of Object.entries(trustedFleetEnv))
|
|
192
|
+
if (key.startsWith('OURS_'))
|
|
193
|
+
env[key] = value;
|
|
194
|
+
env.HERMES_HOME = home;
|
|
195
|
+
env.HERMES_ACP_SKIP_CONFIGURED_MCP = '1';
|
|
196
|
+
return env;
|
|
197
|
+
}
|
|
198
|
+
export function hermesMcpServers(role, trustedFleetEnv = {}) {
|
|
199
|
+
throwErrors(validateHermesOptions(role.harness_options));
|
|
200
|
+
const options = (role.harness_options ?? {});
|
|
201
|
+
const oursEnv = Object.fromEntries(Object.entries(trustedFleetEnv).filter(([key]) => key.startsWith('OURS_')));
|
|
202
|
+
return acpMcpServersFor({ ours: { command: 'ours-mcp', args: ['proxy'], env: oursEnv }, ...Object.fromEntries(Object.entries(options.mcp_servers ?? {}).filter(([name]) => name !== 'ours')) });
|
|
203
|
+
}
|
|
204
|
+
function stat(path) {
|
|
205
|
+
try {
|
|
206
|
+
return lstatSync(path);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
if (e.code === 'ENOENT')
|
|
210
|
+
return undefined;
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function regular(path) {
|
|
215
|
+
const info = stat(path);
|
|
216
|
+
if (!info)
|
|
217
|
+
return false;
|
|
218
|
+
if (info.isSymbolicLink() || !info.isFile() || info.nlink !== 1)
|
|
219
|
+
throw new Error(`Hermes requires a regular, non-symlink, unshared file: ${path}`);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
function privateDirectory(path) {
|
|
223
|
+
const absolute = resolve(path);
|
|
224
|
+
let current = parse(absolute).root;
|
|
225
|
+
for (const component of absolute.slice(current.length).split('/').filter(Boolean)) {
|
|
226
|
+
current = join(current, component);
|
|
227
|
+
const info = stat(current);
|
|
228
|
+
if (info?.isSymbolicLink() || (info && !info.isDirectory()))
|
|
229
|
+
throw new Error(`Hermes home path must contain directories without symlinks: ${current}`);
|
|
230
|
+
if (!info)
|
|
231
|
+
mkdirSync(current, { mode: 0o700 });
|
|
232
|
+
}
|
|
233
|
+
chmodSync(absolute, 0o700);
|
|
234
|
+
}
|
|
235
|
+
function parseNativeFile(path) {
|
|
236
|
+
try {
|
|
237
|
+
return path.endsWith('.json') ? JSON.parse(readFileSync(path, 'utf8')) : YAML.parse(readFileSync(path, 'utf8'));
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
throw new Error(`Invalid Hermes configuration file; repair it with the role stopped: ${path}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function validateHomeDotenv(path) {
|
|
244
|
+
if (!regular(path))
|
|
245
|
+
return;
|
|
246
|
+
// Native dotenv accepts export and single-quoted keys. Read keys only; never
|
|
247
|
+
// return credential values or native parser diagnostics that may contain them.
|
|
248
|
+
// Native startup rewrites UTF-16 and strips NULs before parsing. Refuse those
|
|
249
|
+
// inputs rather than normalize credentials or validate a different assignment.
|
|
250
|
+
const bytes = readFileSync(path);
|
|
251
|
+
let content;
|
|
252
|
+
try {
|
|
253
|
+
if (bytes.includes(0))
|
|
254
|
+
throw new Error();
|
|
255
|
+
content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
256
|
+
// Python treats these controls as whitespace; JavaScript's key scanner does not.
|
|
257
|
+
if (/[\u001c-\u001f\u0085]/.test(content))
|
|
258
|
+
throw new Error();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
throw new Error(`Unsupported Hermes dotenv encoding; use UTF-8 without NUL or unsupported control characters with the role stopped: ${path}`);
|
|
262
|
+
}
|
|
263
|
+
for (const line of content.split(/\r\n?|\n/)) {
|
|
264
|
+
const match = /^\s*(?:export\s+)?(?:'([^']+)'|([^\s=#]+))\s*=/.exec(line);
|
|
265
|
+
if (!match)
|
|
266
|
+
continue;
|
|
267
|
+
const key = match[1] ?? match[2];
|
|
268
|
+
if (/^OURS_/i.test(key) || /^(?:_?HERMES_(?:HOME|PROFILE|CONFIG(?:_PATH)?|ENV(?:_PATH)?|SHARED_AUTH_DIR|MANAGED_DIR|YOLO_MODE|INTERACTIVE|EXEC_ASK|GATEWAY_SESSION|CRON_SESSION|SINGLE_QUERY_SESSION|SESSION_.*|ACP_AUTO_APPROVE|ACP_SKIP_CONFIGURED_MCP|MODEL|ENABLE_PROJECT_PLUGINS|OPTIONAL_MCPS|SAFE_MODE|IGNORE_USER_CONFIG))$/i.test(key)) {
|
|
269
|
+
throw new Error(`Hermes home dotenv contains a reserved Fleet/Hermes setting; remove it with the role stopped: ${path}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return content;
|
|
273
|
+
}
|
|
274
|
+
function readConfig(path) {
|
|
275
|
+
if (!regular(path))
|
|
276
|
+
return {};
|
|
277
|
+
const doc = parseNativeFile(path);
|
|
278
|
+
if (!mapping(doc))
|
|
279
|
+
throw new Error(`Hermes configuration must be a YAML mapping: ${path}`);
|
|
280
|
+
return doc;
|
|
281
|
+
}
|
|
282
|
+
function managedMapping(config, key) {
|
|
283
|
+
if (config[key] === undefined)
|
|
284
|
+
return {};
|
|
285
|
+
if (!mapping(config[key]))
|
|
286
|
+
throw new Error(`Hermes configuration ${key} must be a mapping`);
|
|
287
|
+
return config[key];
|
|
288
|
+
}
|
|
289
|
+
function validateHomeMcp(config, home) {
|
|
290
|
+
if (config.mcp_servers != null) {
|
|
291
|
+
if (!mapping(config.mcp_servers))
|
|
292
|
+
throw new Error('Hermes home mcp_servers must be a map');
|
|
293
|
+
for (const server of Object.values(config.mcp_servers)) {
|
|
294
|
+
if (!mapping(server) || server.enabled !== false)
|
|
295
|
+
throw new Error(`Disable home-configured MCP servers in ${join(home, 'config.yaml')}; declare extras through Fleet harness_options.mcp_servers`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const plugins = config.plugins;
|
|
299
|
+
if (plugins != null && !mapping(plugins))
|
|
300
|
+
throw new Error('Hermes home plugins must be a map');
|
|
301
|
+
if (mapping(plugins)) {
|
|
302
|
+
for (const gate of ['enabled', 'disabled']) {
|
|
303
|
+
const names = plugins[gate];
|
|
304
|
+
if (Array.isArray(names) && names.some(hasNativeInterpolation))
|
|
305
|
+
throw new Error('Hermes plugins.enabled/disabled do not support interpolation in a Fleet-managed home; provision literal plugin names with the role stopped');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const enabled = mapping(plugins) && Array.isArray(plugins.enabled) && plugins.enabled.every(x => typeof x === 'string') ? plugins.enabled : undefined;
|
|
309
|
+
const disabled = mapping(plugins) && Array.isArray(plugins.disabled) ? plugins.disabled : [];
|
|
310
|
+
if (enabled?.length === 0)
|
|
311
|
+
return;
|
|
312
|
+
const scan = (directory, prefix = '', depth = 0) => {
|
|
313
|
+
const info = stat(directory);
|
|
314
|
+
if (!info)
|
|
315
|
+
return;
|
|
316
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
317
|
+
throw new Error(`Hermes plugins directory must be a non-symlink directory: ${directory}`);
|
|
318
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
319
|
+
if (entry.isSymbolicLink())
|
|
320
|
+
throw new Error(`Hermes plugin paths must not be symlinks: ${join(directory, entry.name)}`);
|
|
321
|
+
if (!entry.isDirectory())
|
|
322
|
+
continue;
|
|
323
|
+
const root = join(directory, entry.name);
|
|
324
|
+
const key = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
325
|
+
const manifests = ['plugin.yaml', 'plugin.yml', 'plugin.json'];
|
|
326
|
+
const manifest = manifests.find(name => stat(join(root, name)));
|
|
327
|
+
if (!manifest) {
|
|
328
|
+
if (depth === 0)
|
|
329
|
+
scan(root, key, 1);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
regular(join(root, manifest));
|
|
333
|
+
const value = parseNativeFile(join(root, manifest));
|
|
334
|
+
const name = mapping(value) && typeof value.name === 'string' ? value.name : entry.name;
|
|
335
|
+
if (disabled.includes(key) || disabled.includes(name) || (enabled && !enabled.includes(key) && !enabled.includes(name)))
|
|
336
|
+
continue;
|
|
337
|
+
const mcpPath = join(root, 'mcp.json');
|
|
338
|
+
if (manifest === 'plugin.json' && regular(mcpPath)) {
|
|
339
|
+
const mcp = parseNativeFile(mcpPath);
|
|
340
|
+
if (!mapping(mcp) || !mapping(mcp.mcpServers) || Object.keys(mcp.mcpServers).length)
|
|
341
|
+
throw new Error(`Disable MCP-providing agent plugin ${key} in ${join(home, 'config.yaml')}; declare MCP extras through Fleet`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
scan(join(home, 'plugins'));
|
|
346
|
+
}
|
|
347
|
+
export async function prepareHermesConfig(role, dirs) {
|
|
348
|
+
throwErrors(validateHermesRole(role));
|
|
349
|
+
const home = harnessRuntimeDir(dirs.stateDir, 'hermes');
|
|
350
|
+
privateDirectory(home);
|
|
351
|
+
const file = join(home, 'config.yaml');
|
|
352
|
+
const lock = `${file}.lock`;
|
|
353
|
+
const lockStat = stat(lock);
|
|
354
|
+
if (lockStat?.isSymbolicLink() || (lockStat && !lockStat.isDirectory()))
|
|
355
|
+
throw new Error(`Hermes configuration lock must be a non-symlink directory: ${lock}`);
|
|
356
|
+
await withFileLock(lock, () => {
|
|
357
|
+
const config = readConfig(file);
|
|
358
|
+
const model = managedMapping(config, 'model');
|
|
359
|
+
const approvals = managedMapping(config, 'approvals');
|
|
360
|
+
validateNativeCredentialOverrides(role, config, home);
|
|
361
|
+
validateHomeMcp(config, home);
|
|
362
|
+
config.model = { ...model, default: role.model };
|
|
363
|
+
config.approvals = { ...approvals, mode: 'manual' };
|
|
364
|
+
replaceFileAtomically(file, YAML.stringify(config), 0o600);
|
|
365
|
+
});
|
|
366
|
+
return { env: hermesChildEnvironment(role, home, { OURS_BIND_IDENTITY: role.identity }) };
|
|
367
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CommonPermissions } from '../config.js';
|
|
2
|
+
import type { PermissionTranslation } from './types.js';
|
|
3
|
+
export declare function hermesPermissionMode(permissions: CommonPermissions): 'default' | 'accept_edits' | 'dont_ask';
|
|
4
|
+
export declare function translateHermesPermissions(permissions: CommonPermissions): PermissionTranslation;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function hermesPermissionMode(permissions) {
|
|
2
|
+
if (permissions.approval === 'deny')
|
|
3
|
+
throw new Error('Hermes does not support legacy approval: deny');
|
|
4
|
+
const modes = { ask: 'default', auto: 'accept_edits', allow: 'dont_ask' };
|
|
5
|
+
const mode = modes[permissions.approval];
|
|
6
|
+
if (!mode)
|
|
7
|
+
throw new Error('Hermes approval must be ask, auto or allow');
|
|
8
|
+
return mode;
|
|
9
|
+
}
|
|
10
|
+
export function translateHermesPermissions(permissions) {
|
|
11
|
+
if (permissions.filesystem === 'read-only')
|
|
12
|
+
return { supported: false, reason: 'Hermes does not support read-only filesystem mode, including with Fleet isolation' };
|
|
13
|
+
if (!['workspace', 'unrestricted'].includes(permissions.filesystem))
|
|
14
|
+
return { supported: false, reason: 'Hermes filesystem must be workspace or unrestricted' };
|
|
15
|
+
let mode;
|
|
16
|
+
try {
|
|
17
|
+
mode = hermesPermissionMode(permissions);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return { supported: false, reason: e.message };
|
|
21
|
+
}
|
|
22
|
+
const capabilities = ['read-state', 'messaging', 'monitor', 'status-commands'];
|
|
23
|
+
if (permissions.approval !== 'ask')
|
|
24
|
+
capabilities.push('write-state', 'workspace-edit');
|
|
25
|
+
return {
|
|
26
|
+
supported: true,
|
|
27
|
+
native: { permission_mode: mode, approvals_mode: 'manual' },
|
|
28
|
+
exact: false,
|
|
29
|
+
capabilities,
|
|
30
|
+
warnings: [
|
|
31
|
+
'Hermes modes mediate dangerous terminal commands and write_file/patch; browser, memory, skills, delegation and MCP side effects are not universally approval-mediated.',
|
|
32
|
+
'Unattended wait is bounded by Fleet’s 50-second permission timeout and the tested Hermes native 60-second dangerous-command timeout; protected-action floors remain active.',
|
|
33
|
+
...(permissions.filesystem === 'workspace' ? ['Workspace confinement is an approximation unless enforcing Fleet isolation is verified on this platform.'] : []),
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
2
|
+
import type { ResolvedRole } from '../config.js';
|
|
3
|
+
import type { SessionPrep } from './types.js';
|
|
4
|
+
import type { AgentSessionAdapter, AgentSessionStartOptions, BrainSelection } from './agent-session.js';
|
|
5
|
+
import type { AcpSessionTransport } from './acp-session-transport.js';
|
|
6
|
+
/** Native provider resolution and executable compatibility remain harness-owned. */
|
|
7
|
+
export interface HermesStartupChecks {
|
|
8
|
+
/** Independently resolved native provider identity, never copied from the ACP report. */
|
|
9
|
+
expectedProvider(role: ResolvedRole, prep: SessionPrep): string | Promise<string>;
|
|
10
|
+
validateArtifact(initialized: acp.InitializeResponse): void | Promise<void>;
|
|
11
|
+
/** Inspect the executable using the completed child environment before spawning it. */
|
|
12
|
+
preflight?(options: AgentSessionStartOptions, env: Record<string, string>): void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/** Fresh-only Hermes implementation of Fleet's existing live-session factory. */
|
|
15
|
+
export declare class HermesAgentSessionAdapter implements AgentSessionAdapter {
|
|
16
|
+
private readonly transport;
|
|
17
|
+
private readonly checks?;
|
|
18
|
+
constructor(transport?: AcpSessionTransport, checks?: HermesStartupChecks | undefined);
|
|
19
|
+
resolveBrain(brain: BrainSelection): ReturnType<AgentSessionAdapter['resolveBrain']>;
|
|
20
|
+
modelEnvironmentVariable(): string | undefined;
|
|
21
|
+
prepareLaunch(role: ResolvedRole, prep: SessionPrep): ReturnType<AgentSessionAdapter['prepareLaunch']>;
|
|
22
|
+
sessionConfigSelections(role: ResolvedRole): ReturnType<AgentSessionAdapter['sessionConfigSelections']>;
|
|
23
|
+
start(options: AgentSessionStartOptions): ReturnType<AgentSessionAdapter['start']>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { AcpSession } from '../session/acp.js';
|
|
2
|
+
import { hermesChildEnvironment, hermesMcpServers, validateHermesOptions, validateHermesRole } from './hermes-config.js';
|
|
3
|
+
import { hermesPermissionMode } from './hermes-permissions.js';
|
|
4
|
+
function requireValid(errors) {
|
|
5
|
+
if (errors.length)
|
|
6
|
+
throw new Error(errors.map(error => `${error.path}: ${error.message}`).join('; '));
|
|
7
|
+
}
|
|
8
|
+
function requireModel(model) {
|
|
9
|
+
if (typeof model !== 'string' || !model.trim())
|
|
10
|
+
throw new Error('Hermes requires an explicit non-empty Brain model');
|
|
11
|
+
return model.trim();
|
|
12
|
+
}
|
|
13
|
+
function preparedHome(prep) {
|
|
14
|
+
if (!prep.env.HERMES_HOME?.trim())
|
|
15
|
+
throw new Error('Hermes requires a prepared runtime home');
|
|
16
|
+
return prep.env.HERMES_HOME;
|
|
17
|
+
}
|
|
18
|
+
/** Fresh-only Hermes implementation of Fleet's existing live-session factory. */
|
|
19
|
+
export class HermesAgentSessionAdapter {
|
|
20
|
+
transport;
|
|
21
|
+
checks;
|
|
22
|
+
constructor(transport = AcpSession.start, checks) {
|
|
23
|
+
this.transport = transport;
|
|
24
|
+
this.checks = checks;
|
|
25
|
+
}
|
|
26
|
+
resolveBrain(brain) {
|
|
27
|
+
const model = requireModel(brain.model);
|
|
28
|
+
if (brain.effort != null)
|
|
29
|
+
throw new Error('Hermes does not support effort');
|
|
30
|
+
requireValid(validateHermesOptions(brain.harnessOptions));
|
|
31
|
+
return { model, ...(brain.harnessOptions ? { harnessOptions: brain.harnessOptions } : {}) };
|
|
32
|
+
}
|
|
33
|
+
modelEnvironmentVariable() { return undefined; }
|
|
34
|
+
prepareLaunch(role, prep) {
|
|
35
|
+
requireValid(validateHermesRole(role));
|
|
36
|
+
preparedHome(prep);
|
|
37
|
+
const command = role.session_options?.acp?.command;
|
|
38
|
+
const argv = Array.isArray(command) ? [...command]
|
|
39
|
+
: typeof command === 'string' ? ['sh', '-c', command] : ['hermes-acp'];
|
|
40
|
+
return { argv, env: { ...prep.env } };
|
|
41
|
+
}
|
|
42
|
+
sessionConfigSelections(role) {
|
|
43
|
+
requireValid(validateHermesRole(role));
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
async start(options) {
|
|
47
|
+
const { role, prep, launch } = options;
|
|
48
|
+
requireValid(validateHermesRole({ ...role, permissions: options.permissions }));
|
|
49
|
+
const model = requireModel(role.model);
|
|
50
|
+
if (!this.checks)
|
|
51
|
+
throw new Error('Hermes startup checks are required before launching a managed session');
|
|
52
|
+
// Reapply the complete environment boundary after runner routing/isolation
|
|
53
|
+
// composition; no second ambient merge may reintroduce provider secrets.
|
|
54
|
+
const env = hermesChildEnvironment(role, preparedHome(prep), launch.env, launch.env);
|
|
55
|
+
delete env.OURS_AUTOSTART;
|
|
56
|
+
await this.checks.preflight?.(options, env);
|
|
57
|
+
const nativeProvider = await this.checks.expectedProvider(role, prep);
|
|
58
|
+
if (typeof nativeProvider !== 'string' || !nativeProvider.trim())
|
|
59
|
+
throw new Error('Hermes requires an independently resolved native provider');
|
|
60
|
+
const provider = nativeProvider.trim().toLowerCase();
|
|
61
|
+
// model_catalog.encode_model_choice preserves model colons; its catalog
|
|
62
|
+
// constructor promotes Ollama to the named custom provider identity.
|
|
63
|
+
const expectedModelId = `${provider === 'ollama' ? 'custom:ollama' : provider}:${model}`;
|
|
64
|
+
const modeId = hermesPermissionMode(options.permissions);
|
|
65
|
+
return this.transport({
|
|
66
|
+
name: role.name, harness: 'hermes', argv: launch.argv, cwd: options.cwd, env,
|
|
67
|
+
inheritEnvironment: false, stateDir: options.stateDir, mode: 'fresh',
|
|
68
|
+
permissions: options.permissions, modeId, requireMode: true,
|
|
69
|
+
permissionMode: { fleetMode: options.permissionMode.fleetMode, nativeMode: modeId },
|
|
70
|
+
permissionTimeoutMs: 50_000,
|
|
71
|
+
mcpServers: hermesMcpServers(role, env),
|
|
72
|
+
scrubObsoleteOursAutostart: true,
|
|
73
|
+
validateStartupResponse: async (initialized, created) => {
|
|
74
|
+
await this.checks.validateArtifact(initialized);
|
|
75
|
+
if (typeof created.models?.currentModelId !== 'string'
|
|
76
|
+
|| created.models.currentModelId !== expectedModelId)
|
|
77
|
+
throw new Error('Hermes fresh-session model/provider report does not match the Brain model and provisioned provider');
|
|
78
|
+
},
|
|
79
|
+
...(role.monitor?.mode === 'fleet' && role.monitor.stall_recovery ? {
|
|
80
|
+
stallRecovery: { timeoutMs: role.monitor.stall_timeout_ms },
|
|
81
|
+
} : {}),
|
|
82
|
+
log: options.log,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
export { validateHermesInitialize as validateHermesHandshake } from './hermes-compatibility.js';
|
|
5
|
+
/** Compare a native provisioned identity, never infer it from the child's report. */
|
|
6
|
+
export function hermesConfiguredProvider(home) {
|
|
7
|
+
let provider;
|
|
8
|
+
try {
|
|
9
|
+
provider = YAML.parse(readFileSync(join(home, 'config.yaml'), 'utf8'))?.model?.provider;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error('Cannot read Hermes native provider; repair config.yaml in the stopped role home');
|
|
13
|
+
}
|
|
14
|
+
if (typeof provider !== 'string' || !provider.trim() || provider.trim().toLowerCase() === 'auto' || provider.includes('${'))
|
|
15
|
+
throw new Error('Hermes requires a literal native model.provider in the stopped role home so startup can detect provider fallback');
|
|
16
|
+
const canonical = provider.trim().toLowerCase();
|
|
17
|
+
// The tested runtime collapses named custom endpoints to custom, while its
|
|
18
|
+
// ACP model catalog reports Ollama under custom:ollama.
|
|
19
|
+
return canonical === 'ollama' || canonical === 'custom:ollama' ? 'custom:ollama'
|
|
20
|
+
: canonical.startsWith('custom:') ? 'custom' : canonical;
|
|
21
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
import type { AcpSessionTransport } from './acp-session-transport.js';
|
|
3
|
+
import type { HarnessAdapter } from './types.js';
|
|
4
|
+
export declare function makeHermesAdapter(exec?: Exec, transport?: AcpSessionTransport): HarnessAdapter;
|
|
5
|
+
export declare const hermesAdapter: HarnessAdapter;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { realExec } from '../exec.js';
|
|
2
|
+
import { HermesAgentSessionAdapter } from './hermes-session.js';
|
|
3
|
+
import { inspectHermesCompatibility } from './hermes-compatibility.js';
|
|
4
|
+
import { hermesConfiguredProvider, validateHermesHandshake } from './hermes-startup.js';
|
|
5
|
+
import { prepareHermesConfig, validateHermesOptions, validateHermesRole } from './hermes-config.js';
|
|
6
|
+
import { hermesPermissionMode, translateHermesPermissions } from './hermes-permissions.js';
|
|
7
|
+
import { registerAdapter } from './registry.js';
|
|
8
|
+
const wakeNote = 'Your mail wake-ups are delivered by the fleet supervisor as `[fleet-monitor]` lines. '
|
|
9
|
+
+ 'Call **get_messages**, handle the mail, and reply with send_message. '
|
|
10
|
+
+ 'Do NOT arm arm_monitor, foreground_monitor or a native Hermes monitor.';
|
|
11
|
+
export function makeHermesAdapter(exec = realExec, transport) {
|
|
12
|
+
return {
|
|
13
|
+
id: 'hermes',
|
|
14
|
+
agentSession: new HermesAgentSessionAdapter(transport, {
|
|
15
|
+
expectedProvider: (_role, prep) => hermesConfiguredProvider(prep.env.HERMES_HOME),
|
|
16
|
+
validateArtifact: validateHermesHandshake,
|
|
17
|
+
async preflight(options, env) {
|
|
18
|
+
const original = new HermesAgentSessionAdapter().prepareLaunch(options.role, options.prep);
|
|
19
|
+
const report = await inspectHermesCompatibility({ argv: original.argv, env, home: options.prep.env.HERMES_HOME }, exec);
|
|
20
|
+
options.log(`Hermes ${report.artifact.hermesVersion}, ACP ${report.artifact.acpVersion}; fresh conversation, MCP availability unverified until actual use`);
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
supportsResume: false,
|
|
24
|
+
async checkPrereqs() {
|
|
25
|
+
const result = await exec('hermes-acp', ['--help'], { timeout: 10_000 });
|
|
26
|
+
const ok = result.code === 0;
|
|
27
|
+
return { ok, checks: [{ name: 'hermes-acp', ok, detail: ok
|
|
28
|
+
? 'Hermes ACP executable found; launch still requires a tested compatible artifact, an explicit Brain model and provider/credentials provisioned in the exact stopped role home. Home/plugin MCP providers must be disabled.'
|
|
29
|
+
: 'hermes-acp unavailable; install the tested Hermes artifact and provision provider/credentials in the exact stopped role home.' }] };
|
|
30
|
+
},
|
|
31
|
+
validateOptions(options, role) {
|
|
32
|
+
return role ? validateHermesRole({ ...role, harness_options: options })
|
|
33
|
+
: validateHermesOptions(options);
|
|
34
|
+
},
|
|
35
|
+
prepareSession: prepareHermesConfig,
|
|
36
|
+
// The selected home is already beneath the role state directory, which
|
|
37
|
+
// Fleet mounts writable. No operator home or credential path is shared.
|
|
38
|
+
isolationPaths: () => ({ shared: [] }),
|
|
39
|
+
nativePermissionOverrides: () => ({}),
|
|
40
|
+
translatePermissions: translateHermesPermissions,
|
|
41
|
+
effectivePermissions: role => translateHermesPermissions(role.permissions),
|
|
42
|
+
effectivePermissionMode(role) {
|
|
43
|
+
const nativeMode = hermesPermissionMode(role.permissions);
|
|
44
|
+
return { fleetMode: role.permissions.approval, nativeMode };
|
|
45
|
+
},
|
|
46
|
+
vocabulary: {
|
|
47
|
+
bindTool: 'choose_identity', createTool: 'create_identity',
|
|
48
|
+
temporaryCreateTool: 'create_temporary_identity', setBioTool: 'set_bio',
|
|
49
|
+
setPersonaTool: 'set_persona', currentIdentityTool: 'current_identity',
|
|
50
|
+
sendTool: 'send_message', getMessagesTool: 'get_messages',
|
|
51
|
+
listHistoryTool: 'list_history', getHistoryItemTool: 'get_history_item',
|
|
52
|
+
monitorInstruction: () => wakeNote,
|
|
53
|
+
supervisedWakeNote: () => wakeNote,
|
|
54
|
+
launchNote: name => `You were launched as Fleet role ${name} in a fresh Hermes ACP conversation. Confirm you are running.`,
|
|
55
|
+
restartPrompt: (identity, worklog) => `This is a fresh Hermes conversation. Follow the full role briefing, bind identity "${identity}" without force, `
|
|
56
|
+
+ `and continue from ${worklog}. Native memory and skills persist; the previous conversation is not restored. ${wakeNote}`,
|
|
57
|
+
},
|
|
58
|
+
exitPolicy: { cleanExitIsFresh: true, fastFailSecs: 20 },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export const hermesAdapter = makeHermesAdapter();
|
|
62
|
+
registerAdapter(hermesAdapter);
|
package/dist/harness/registry.js
CHANGED
|
@@ -29,7 +29,7 @@ export function knownAdapters() {
|
|
|
29
29
|
* registry" is not the same question — this is the set doctor falls back to
|
|
30
30
|
* when a broken configuration names no harness at all.
|
|
31
31
|
*/
|
|
32
|
-
const PRODUCTION_ADAPTERS = ['claude-code', 'codex'];
|
|
32
|
+
const PRODUCTION_ADAPTERS = ['claude-code', 'codex', 'hermes'];
|
|
33
33
|
/** Production adapters actually registered in this process. */
|
|
34
34
|
export function productionAdapters() {
|
|
35
35
|
return PRODUCTION_ADAPTERS.filter(id => adapters.has(id));
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { OwnerChannel } from './owner-channel/channel.js';
|
|
|
9
9
|
export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
|
|
10
10
|
export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
|
|
11
11
|
export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
|
|
12
|
+
export { hermesAdapter, makeHermesAdapter } from './harness/hermes.js';
|
|
12
13
|
export { generateBriefing } from './briefing.js';
|
|
13
14
|
export { effectivePermissionMode } from './permissions.js';
|
|
14
15
|
export { pickBackend } from './supervisor/index.js';
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { OwnerChannel } from './owner-channel/channel.js';
|
|
|
6
6
|
export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
|
|
7
7
|
export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
|
|
8
8
|
export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
|
|
9
|
+
export { hermesAdapter, makeHermesAdapter } from './harness/hermes.js';
|
|
9
10
|
export { generateBriefing } from './briefing.js';
|
|
10
11
|
export { effectivePermissionMode } from './permissions.js';
|
|
11
12
|
export { pickBackend } from './supervisor/index.js';
|