@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.
@@ -1,3 +1,4 @@
1
+ import { acpMcpServersFor, validateMcpServers } from './acp-mcp.js';
1
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { home } from '../paths.js';
@@ -12,8 +13,6 @@ const OPTION_KEYS = [
12
13
  'mcp_servers', 'mcp_servers_only',
13
14
  ];
14
15
  const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
15
- /** `.mcp.json` server types. Absent means stdio, as the file format has it. */
16
- const MCP_SERVER_TYPES = ['stdio', 'http', 'sse'];
17
16
  /** A role that names its own ACP command runs a process fleet did not choose. */
18
17
  const customAcpCommand = (role) => role.session === 'acp' && role.session_options?.acp?.command != null;
19
18
  /**
@@ -32,73 +31,6 @@ const customAcpCommand = (role) => role.session === 'acp' && role.session_option
32
31
  * `ours:` above the wrong command.
33
32
  */
34
33
  const declaresOursConnector = (servers) => Object.values(servers).some(s => [s.command ?? '', ...(s.args ?? [])].some(part => /(^|[/\\])ours-mcp($|\s)|@ours\.network[/\\]mcp/.test(part)));
35
- /** Shape-check `harness_options.mcp_servers` against `.mcp.json`'s own rules. */
36
- function validateMcpServers(servers) {
37
- if (servers == null)
38
- return [];
39
- const at = (k = '') => ({ path: `harness_options.mcp_servers${k}` });
40
- if (typeof servers !== 'object' || Array.isArray(servers))
41
- return [{ ...at(), message: 'must be a map of server name to server definition' }];
42
- const entries = Object.entries(servers);
43
- if (!entries.length)
44
- return [{ ...at(), message: 'must declare at least one server, or be omitted' }];
45
- const errors = [];
46
- for (const [name, raw] of entries) {
47
- const p = `.${name}`;
48
- if (!/^[A-Za-z0-9_-]+$/.test(name)) {
49
- errors.push({ ...at(p), message: 'server name must be [A-Za-z0-9_-]' });
50
- continue;
51
- }
52
- if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
53
- errors.push({ ...at(p), message: 'must be a map' });
54
- continue;
55
- }
56
- const s = raw;
57
- if (s.type != null && !MCP_SERVER_TYPES.includes(s.type))
58
- errors.push({ ...at(`${p}.type`), message: `must be one of: ${MCP_SERVER_TYPES.join(', ')}` });
59
- const remote = s.type === 'http' || s.type === 'sse';
60
- if (remote) {
61
- if (typeof s.url !== 'string' || !s.url.trim())
62
- errors.push({ ...at(`${p}.url`), message: `must be a non-empty URL for a ${s.type} server` });
63
- if (s.command != null)
64
- errors.push({ ...at(`${p}.command`), message: `must not be set for a ${s.type} server` });
65
- }
66
- else {
67
- if (typeof s.command !== 'string' || !s.command.trim())
68
- errors.push({ ...at(`${p}.command`), message: 'must be a non-empty command for a stdio server' });
69
- if (s.args != null && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string')))
70
- errors.push({ ...at(`${p}.args`), message: 'must be an array of strings' });
71
- if (s.url != null)
72
- errors.push({ ...at(`${p}.url`), message: 'must not be set for a stdio server' });
73
- }
74
- for (const key of ['env', 'headers']) {
75
- const v = s[key];
76
- if (v == null)
77
- continue;
78
- if (typeof v !== 'object' || Array.isArray(v)
79
- || Object.values(v).some(x => typeof x !== 'string'))
80
- errors.push({ ...at(`${p}.${key}`), message: 'must be a map of string to string' });
81
- }
82
- }
83
- return errors;
84
- }
85
- /** `harness_options.mcp_servers` in ACP's `session/new` array shape. */
86
- function acpMcpServersFor(servers) {
87
- if (!servers)
88
- return undefined;
89
- // `env` and `headers` are REQUIRED arrays in the protocol, so they are always
90
- // sent — empty when the role declared none.
91
- const pairs = (r) => Object.entries(r ?? {}).map(([name, value]) => ({ name, value }));
92
- return Object.entries(servers).map(([name, s]) => {
93
- if (s.type === 'http' || s.type === 'sse')
94
- return { name, type: s.type, url: s.url, headers: pairs(s.headers) };
95
- // Stdio carries NO `type` field: ACP's stdio variant is the one without it,
96
- // and the bundled agent keys on exactly that (claude-agent-acp
97
- // acp-agent.js:4058, `!("type" in server)`), so sending `type: 'stdio'`
98
- // would drop the server on the floor.
99
- return { name, command: s.command, args: s.args ?? [], env: pairs(s.env) };
100
- });
101
- }
102
34
  /** Claude Code's accepted --permission-mode values. */
103
35
  const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
104
36
  /**
@@ -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>;