@ours.network/fleet 1.1.4 → 1.2.0-nightly.1

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.
Files changed (48) hide show
  1. package/README.md +137 -1
  2. package/dist/briefing.js +4 -3
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +13 -6
  6. package/dist/client-profile.d.ts +17 -0
  7. package/dist/client-profile.js +115 -0
  8. package/dist/creation.js +10 -7
  9. package/dist/daemon-recovery.d.ts +2 -0
  10. package/dist/daemon-recovery.js +53 -2
  11. package/dist/docs.d.ts +1 -1
  12. package/dist/docs.js +23 -2
  13. package/dist/doctor.js +62 -6
  14. package/dist/harness/acp-mcp.d.ts +14 -0
  15. package/dist/harness/acp-mcp.js +68 -0
  16. package/dist/harness/claude-code.js +1 -69
  17. package/dist/harness/codex.js +4 -0
  18. package/dist/harness/hermes-compatibility.d.ts +24 -0
  19. package/dist/harness/hermes-compatibility.js +191 -0
  20. package/dist/harness/hermes-config.d.ts +12 -0
  21. package/dist/harness/hermes-config.js +367 -0
  22. package/dist/harness/hermes-permissions.d.ts +4 -0
  23. package/dist/harness/hermes-permissions.js +36 -0
  24. package/dist/harness/hermes-session.d.ts +24 -0
  25. package/dist/harness/hermes-session.js +85 -0
  26. package/dist/harness/hermes-startup.d.ts +3 -0
  27. package/dist/harness/hermes-startup.js +21 -0
  28. package/dist/harness/hermes.d.ts +5 -0
  29. package/dist/harness/hermes.js +62 -0
  30. package/dist/harness/registry.js +1 -1
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/init-wizard.d.ts +11 -6
  34. package/dist/init-wizard.js +33 -0
  35. package/dist/monitor.d.ts +34 -3
  36. package/dist/monitor.js +174 -74
  37. package/dist/owner-channel/channel.js +4 -2
  38. package/dist/owner-channel/ours-client.d.ts +12 -5
  39. package/dist/owner-channel/ours-client.js +48 -12
  40. package/dist/runner.js +21 -7
  41. package/dist/session/acp.d.ts +15 -0
  42. package/dist/session/acp.js +22 -7
  43. package/dist/session/codex-app-server.js +31 -5
  44. package/dist/spawn.d.ts +1 -0
  45. package/dist/spawn.js +1 -0
  46. package/dist/supervisor/launchd.js +8 -1
  47. package/examples/fleet/brains/hermes.yaml +5 -0
  48. package/package.json +5 -4
@@ -167,6 +167,10 @@ function codexAgentLaunch(role, prep) {
167
167
  ...(options?.search ? ['--search'] : []),
168
168
  'app-server',
169
169
  ];
170
+ // Fleet delivers wakes through the native session itself. The ours-codex
171
+ // launcher owns a remote TUI and cannot serve this stdio transport.
172
+ if (launcher === 'auto' && role.monitor?.mode === 'fleet')
173
+ return { argv: ['codex', ...flags], env: prep.env };
170
174
  // Preserve the established `auto` launcher contract without resolving PATH
171
175
  // during synchronous launch preparation. The static shell fragment passes
172
176
  // every dynamic value as an argv element and `exec`s the selected process.
@@ -0,0 +1,24 @@
1
+ import { type Exec } from '../exec.js';
2
+ export declare const TESTED_HERMES_ARTIFACT: {
3
+ readonly commit: "d15ed4445207dda418b984e8bda0f68f48b8c6f3";
4
+ readonly hermesVersion: "0.21.1";
5
+ readonly acpVersion: "0.9.0";
6
+ readonly protocolVersion: 1;
7
+ };
8
+ export interface HermesCompatibilityReport {
9
+ artifact: typeof TESTED_HERMES_ARTIFACT & {
10
+ sourceRoot: string;
11
+ executable: string;
12
+ };
13
+ /** Scoped prerequisite only: ACP connection/tool availability and other launch checks remain separate. */
14
+ pluginMcp: 'absent-in-validated-sources';
15
+ }
16
+ export interface HermesCompatibilityRequest {
17
+ argv: string[];
18
+ env: Record<string, string>;
19
+ home: string;
20
+ }
21
+ /** Check original, unwrapped argv. This does not certify an isolation wrapper or claim MCP tool readiness. */
22
+ export declare function inspectHermesCompatibility(request: HermesCompatibilityRequest, exec?: Exec): Promise<HermesCompatibilityReport>;
23
+ /** Native identity check, separate from session model/provider validation and first actual MCP use. */
24
+ export declare function validateHermesInitialize(response: unknown): void;
@@ -0,0 +1,191 @@
1
+ import { accessSync, constants, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { realExec } from '../exec.js';
5
+ export const TESTED_HERMES_ARTIFACT = {
6
+ commit: 'd15ed4445207dda418b984e8bda0f68f48b8c6f3', hermesVersion: '0.21.1', acpVersion: '0.9.0', protocolVersion: 1,
7
+ };
8
+ const fail = (detail) => { throw new Error(`Hermes compatibility: ${detail}. Use the tested Hermes source/build and a supported hermes-acp launcher, or validate the new artifact before launch`); };
9
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
10
+ function stat(path) {
11
+ try {
12
+ return lstatSync(path);
13
+ }
14
+ catch (e) {
15
+ if (e.code === 'ENOENT')
16
+ return undefined;
17
+ return fail('cannot inspect a required artifact or plugin file');
18
+ }
19
+ }
20
+ function read(path) {
21
+ const info = stat(path);
22
+ if (!info)
23
+ return undefined;
24
+ if (!info.isFile() || info.isSymbolicLink())
25
+ return fail('artifact and plugin inputs must be regular non-symlink files');
26
+ try {
27
+ return new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path));
28
+ }
29
+ catch {
30
+ return fail('cannot read a required UTF-8 artifact or plugin file');
31
+ }
32
+ }
33
+ function document(path) {
34
+ const text = read(path);
35
+ if (text === undefined)
36
+ return {};
37
+ try {
38
+ const value = path.endsWith('.json') ? JSON.parse(text) : YAML.parse(text);
39
+ if (object(value))
40
+ return value;
41
+ }
42
+ catch { /* parser diagnostics may contain secrets */ }
43
+ return fail('invalid artifact or plugin configuration');
44
+ }
45
+ const CONSOLE_BODY = '# -*- coding: utf-8 -*-\nimport sys\nfrom acp_adapter.entry import main\nif __name__ == "__main__":\n if sys.argv[0].endswith("-script.pyw"):\n sys.argv[0] = sys.argv[0][:-11]\n elif sys.argv[0].endswith(".exe"):\n sys.argv[0] = sys.argv[0][:-4]\n sys.exit(main())\n';
46
+ function launcher(request) {
47
+ if (request.argv.length !== 1 || !request.argv[0])
48
+ return fail('only the original hermes-acp executable without shell commands or extra flags has been tested');
49
+ const command = request.argv[0];
50
+ const candidates = isAbsolute(command) ? [command] : command.includes('/') ? [resolve(command)] : (request.env.PATH ?? '').split(delimiter).filter(Boolean).map(path => join(path, command));
51
+ let executable;
52
+ for (const path of candidates) {
53
+ try {
54
+ accessSync(path, constants.X_OK);
55
+ executable = realpathSync(path);
56
+ break;
57
+ }
58
+ catch { /* next PATH entry */ }
59
+ }
60
+ if (!executable)
61
+ return fail('hermes-acp executable was not found');
62
+ const text = read(executable);
63
+ if (!text || text.length > 4096)
64
+ return fail('unrecognized hermes-acp launcher');
65
+ const sourceRoot = dirname(dirname(dirname(executable)));
66
+ const interpreter = join(sourceRoot, 'venv/bin/python3');
67
+ if (basename(executable) === 'hermes-acp' && text === `#!${interpreter}\n${CONSOLE_BODY}`)
68
+ return { executable, sourceRoot, interpreter };
69
+ const shim = /^#!\/usr\/bin\/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "([^"\n]+)\/venv\/bin\/python" "\1\/hermes" acp "\$@"\n$/.exec(text);
70
+ if (shim && isAbsolute(shim[1]))
71
+ return { executable, sourceRoot: shim[1], interpreter: join(shim[1], 'venv/bin/python') };
72
+ return fail('unrecognized hermes-acp launcher; a version string alone does not identify tested code');
73
+ }
74
+ // Standard-library metadata only. No Hermes config, dotenv, plugin module, or credential loader is imported.
75
+ const METADATA_PROBE = `import importlib.metadata as m, importlib.util as u, json
76
+ s=u.find_spec('acp_adapter')
77
+ e=m.entry_points()
78
+ e=e.select(group='hermes_agent.plugins') if hasattr(e,'select') else e.get('hermes_agent.plugins',[])
79
+ print(json.dumps({'hermesVersion':m.version('hermes-agent'),'acpVersion':m.version('agent-client-protocol'),'adapterOrigin':s.origin if s else None,'entryPoints':[{'name':x.name,'value':x.value} for x in e]}))`;
80
+ function collectPlugins(directory, source, skip = new Set(), prefix = '', depth = 0) {
81
+ const info = stat(directory);
82
+ if (!info)
83
+ return [];
84
+ if (!info.isDirectory() || info.isSymbolicLink())
85
+ return fail('plugin source must be a non-symlink directory');
86
+ const found = [];
87
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
88
+ if (depth === 0 && skip.has(entry.name))
89
+ continue;
90
+ if (entry.isSymbolicLink())
91
+ return fail('unverified symlink in plugin discovery');
92
+ if (!entry.isDirectory())
93
+ continue;
94
+ const path = join(directory, entry.name);
95
+ const filename = ['plugin.yaml', 'plugin.yml', 'plugin.json'].find(name => stat(join(path, name)));
96
+ if (!filename) {
97
+ if (depth === 0)
98
+ found.push(...collectPlugins(path, source, new Set(), entry.name, 1));
99
+ continue;
100
+ }
101
+ const data = document(join(path, filename));
102
+ const name = typeof data.name === 'string' && data.name ? data.name : entry.name;
103
+ found.push({ name, key: prefix ? `${prefix}/${entry.name}` : name, source, portable: filename === 'plugin.json', path });
104
+ }
105
+ return found;
106
+ }
107
+ function names(value, field) {
108
+ if (value === undefined)
109
+ return undefined;
110
+ if (!Array.isArray(value) || value.some(name => typeof name !== 'string'))
111
+ return fail(`plugins.${field} must contain literal names`);
112
+ if (value.some(name => /\$\{/.test(name)))
113
+ return fail(`plugins.${field} interpolation is unsupported; use literal names`);
114
+ return value;
115
+ }
116
+ function validatePluginSources(request, sourceRoot, metadata) {
117
+ for (const name of ['HERMES_BUNDLED_PLUGINS', 'HERMES_ENABLE_PROJECT_PLUGINS'])
118
+ if (request.env[name] !== undefined)
119
+ return fail('plugin discovery environment overrides are unsupported');
120
+ for (const filename of ['.env', '.op.env']) {
121
+ const content = read(join(request.home, filename));
122
+ if (content === undefined)
123
+ continue;
124
+ if (/[\0\u001c-\u001f\u0085]/.test(content))
125
+ return fail('unsupported plugin dotenv encoding');
126
+ for (const line of content.split(/\r\n?|\n/)) {
127
+ const match = /^\s*(?:export\s+)?(?:'([^']+)'|([^\s=#]+))\s*=/.exec(line);
128
+ if (match && ['HERMES_BUNDLED_PLUGINS', 'HERMES_ENABLE_PROJECT_PLUGINS'].includes(match[1] ?? match[2]))
129
+ return fail('plugin discovery redirection in selected-home dotenv is unsupported; remove it with the role stopped');
130
+ }
131
+ }
132
+ const config = document(join(request.home, 'config.yaml'));
133
+ if (config.plugins !== undefined && !object(config.plugins))
134
+ return fail('plugins configuration must be a map');
135
+ const plugins = object(config.plugins) ? config.plugins : {};
136
+ const enabled = names(plugins.enabled, 'enabled');
137
+ const disabled = names(plugins.disabled, 'disabled') ?? [];
138
+ const bundled = join(sourceRoot, 'plugins');
139
+ // Match native precedence: bundled, bundled/platforms, home, then entry points. Project plugins are refused above.
140
+ const all = [...collectPlugins(bundled, 'bundled', new Set(['memory', 'context_engine', 'platforms', 'model-providers'])), ...collectPlugins(join(bundled, 'platforms'), 'bundled'), ...collectPlugins(join(request.home, 'plugins'), 'home'), ...metadata.entryPoints.map(entry => ({ name: entry.name, key: entry.name, source: 'entrypoint', portable: false }))];
141
+ const winners = new Map(all.map(plugin => [plugin.key, plugin]));
142
+ for (const plugin of winners.values()) {
143
+ if ([plugin.key, plugin.name].some(name => disabled.includes(name)))
144
+ continue;
145
+ // Known bundled code includes native auto-loaded backends. It is part of the pinned clean source.
146
+ if (plugin.source !== 'bundled' && enabled && ![plugin.key, plugin.name].some(name => enabled.includes(name)))
147
+ continue;
148
+ if (!plugin.portable && plugin.source !== 'bundled')
149
+ return fail('enabled unreviewed home/entry-point plugin code cannot be verified MCP-free; disable that plugin or validate its artifact');
150
+ if (plugin.portable && plugin.path && stat(join(plugin.path, 'mcp.json'))) {
151
+ const mcp = document(join(plugin.path, 'mcp.json'));
152
+ if (!object(mcp.mcpServers) || Object.keys(mcp.mcpServers).length)
153
+ return fail('enabled plugin provides MCP; disable the plugin and declare extras through ACP mcpServers');
154
+ }
155
+ }
156
+ }
157
+ /** Check original, unwrapped argv. This does not certify an isolation wrapper or claim MCP tool readiness. */
158
+ export async function inspectHermesCompatibility(request, exec = realExec) {
159
+ const chain = launcher(request);
160
+ // Use ordinary supervisor execution state, not role overrides, for inspection subprocesses.
161
+ const env = { PATH: process.env.PATH, HOME: request.env.HOME, HERMES_HOME: request.home };
162
+ const opts = { env, timeout: 10_000 };
163
+ const [head, status] = await Promise.all([
164
+ exec('git', ['-C', chain.sourceRoot, 'rev-parse', 'HEAD'], opts),
165
+ exec('git', ['-C', chain.sourceRoot, 'status', '--porcelain', '--untracked-files=all'], opts),
166
+ ]);
167
+ if (head.code || status.code || head.stdout.trim() !== TESTED_HERMES_ARTIFACT.commit || status.stdout.trim())
168
+ return fail('source checkout is not the tested clean Git artifact');
169
+ const result = await exec(chain.interpreter, ['-I', '-B', '-c', METADATA_PROBE], opts);
170
+ if (result.code)
171
+ return fail('could not inspect the tested Python package metadata');
172
+ let metadata;
173
+ try {
174
+ const value = JSON.parse(result.stdout);
175
+ if (!object(value) || typeof value.adapterOrigin !== 'string' || !Array.isArray(value.entryPoints) || value.entryPoints.some(entry => !object(entry) || typeof entry.name !== 'string' || typeof entry.value !== 'string'))
176
+ return fail('invalid Python package metadata');
177
+ metadata = value;
178
+ }
179
+ catch {
180
+ return fail('invalid Python package metadata');
181
+ }
182
+ if (metadata.hermesVersion !== TESTED_HERMES_ARTIFACT.hermesVersion || metadata.acpVersion !== TESTED_HERMES_ARTIFACT.acpVersion || resolve(metadata.adapterOrigin) !== join(chain.sourceRoot, 'acp_adapter/__init__.py'))
183
+ return fail('Python packages or ACP source origin differ from the tested build');
184
+ validatePluginSources(request, chain.sourceRoot, metadata);
185
+ return { artifact: { ...TESTED_HERMES_ARTIFACT, sourceRoot: chain.sourceRoot, executable: chain.executable }, pluginMcp: 'absent-in-validated-sources' };
186
+ }
187
+ /** Native identity check, separate from session model/provider validation and first actual MCP use. */
188
+ export function validateHermesInitialize(response) {
189
+ if (!object(response) || response.protocolVersion !== TESTED_HERMES_ARTIFACT.protocolVersion || !object(response.agentInfo) || response.agentInfo.name !== 'hermes-agent' || response.agentInfo.version !== TESTED_HERMES_ARTIFACT.hermesVersion)
190
+ fail('ACP initialize identity/protocol does not match the tested Hermes artifact');
191
+ }
@@ -0,0 +1,12 @@
1
+ import type { ResolvedRole } from '../config.js';
2
+ import { type McpServerSpec } from './acp-mcp.js';
3
+ import type { AcpMcpServer, RoleDirs, SessionPrep, ValidationError } from './types.js';
4
+ export interface HermesOptions {
5
+ mcp_servers?: Record<string, McpServerSpec>;
6
+ }
7
+ export declare function validateHermesOptions(options: unknown): ValidationError[];
8
+ export declare function validateHermesRole(role: ResolvedRole): ValidationError[];
9
+ /** Complete environment: transport MUST use inheritEnvironment:false after this final merge. */
10
+ export declare function hermesChildEnvironment(role: ResolvedRole, home: string, trustedFleetEnv: Record<string, string>, inherited?: NodeJS.ProcessEnv): Record<string, string>;
11
+ export declare function hermesMcpServers(role: ResolvedRole, trustedFleetEnv?: Record<string, string>): AcpMcpServer[];
12
+ export declare function prepareHermesConfig(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
@@ -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
+ }