@ours.network/fleet 0.18.0-nightly.5 → 0.18.0

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 (65) hide show
  1. package/README.md +101 -25
  2. package/dist/application/fleet-query-service.js +12 -0
  3. package/dist/application/role-creation-service.js +3 -1
  4. package/dist/application/types.d.ts +11 -0
  5. package/dist/briefing.js +9 -2
  6. package/dist/build-info.json +7 -6
  7. package/dist/capabilities.d.ts +3 -1
  8. package/dist/capabilities.js +3 -0
  9. package/dist/cli.js +83 -7
  10. package/dist/config.d.ts +11 -3
  11. package/dist/config.js +40 -15
  12. package/dist/creation.d.ts +14 -15
  13. package/dist/creation.js +19 -13
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +113 -27
  16. package/dist/doctor.d.ts +1 -5
  17. package/dist/doctor.js +11 -18
  18. package/dist/fleet-proxy.d.ts +5 -0
  19. package/dist/harness/acp-agent.js +11 -6
  20. package/dist/harness/claude-code.js +204 -11
  21. package/dist/harness/codex.d.ts +4 -1
  22. package/dist/harness/codex.js +74 -12
  23. package/dist/harness/types.d.ts +54 -4
  24. package/dist/harness-plugins.d.ts +48 -0
  25. package/dist/harness-plugins.js +309 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +30 -1
  29. package/dist/loops/manager.js +69 -6
  30. package/dist/loops/state.d.ts +18 -0
  31. package/dist/loops/state.js +4 -0
  32. package/dist/model-env.d.ts +71 -0
  33. package/dist/model-env.js +106 -0
  34. package/dist/monitor.js +1 -1
  35. package/dist/ops.js +1 -1
  36. package/dist/owner-channel/attachments.d.ts +2 -25
  37. package/dist/owner-channel/attachments.js +5 -61
  38. package/dist/owner-channel/channel.d.ts +28 -17
  39. package/dist/owner-channel/channel.js +249 -158
  40. package/dist/owner-channel/mcp.d.ts +24 -0
  41. package/dist/owner-channel/mcp.js +145 -0
  42. package/dist/owner-channel/notices.d.ts +7 -0
  43. package/dist/owner-channel/notices.js +9 -0
  44. package/dist/resolved-plan.js +1 -0
  45. package/dist/runner.d.ts +48 -0
  46. package/dist/runner.js +237 -85
  47. package/dist/session/acp.d.ts +104 -0
  48. package/dist/session/acp.js +213 -10
  49. package/dist/session/activity.d.ts +31 -0
  50. package/dist/session/activity.js +48 -0
  51. package/dist/session/conversation-normalizer.d.ts +6 -0
  52. package/dist/session/conversation-normalizer.js +153 -10
  53. package/dist/session/conversation-types.d.ts +23 -4
  54. package/dist/session/types.d.ts +35 -0
  55. package/dist/spawn.js +29 -17
  56. package/dist/supervisor/systemd.js +2 -29
  57. package/dist/watchdog/briefing.js +7 -0
  58. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  59. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  60. package/dist/web-app/index.html +1 -1
  61. package/dist/worklog.d.ts +7 -1
  62. package/dist/worklog.js +191 -39
  63. package/package.json +1 -3
  64. package/dist/owner-channel/ours-client.d.ts +0 -141
  65. package/dist/owner-channel/ours-client.js +0 -225
package/dist/doctor.d.ts CHANGED
@@ -1,10 +1,7 @@
1
- import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
2
1
  import { type Exec } from './exec.js';
3
2
  import type { YamlMode } from './config-yaml.js';
4
3
  import { type FetchLike } from './monitor.js';
5
4
  import type { PrereqReport } from './harness/types.js';
6
- type DoctorDaemonClient = Pick<OursClient, 'version'>;
7
- type AttachDoctorDaemon = (options: AttachOursClientOptions) => Promise<DoctorDaemonClient>;
8
5
  /** Host-level + per-harness prerequisite report with actionable messages. */
9
6
  export declare function doctor(opts?: {
10
7
  harness?: string;
@@ -15,5 +12,4 @@ export declare function doctor(opts?: {
15
12
  path?: string;
16
13
  argv1?: string;
17
14
  };
18
- }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike, attachDaemon?: AttachDoctorDaemon): Promise<PrereqReport>;
19
- export {};
15
+ }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike): Promise<PrereqReport>;
package/dist/doctor.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { userInfo } from 'node:os';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { attachOursClient, } from '@ours.network/sdk/client';
4
3
  import { realExec } from './exec.js';
5
4
  import { isolationContextFor, loadConfig } from './config.js';
6
5
  import { getAdapter, productionAdapters } from './harness/registry.js';
@@ -77,7 +76,7 @@ function installChecks(scan) {
77
76
  ];
78
77
  }
79
78
  /** Host-level + per-harness prerequisite report with actionable messages. */
80
- export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i), attachDaemon = attachOursClient) {
79
+ export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
81
80
  const checks = [];
82
81
  // First: which artifact is producing this report. Everything below is only as
83
82
  // trustworthy as the answer, and a second install with the same semver and
@@ -113,22 +112,16 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
113
112
  detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
114
113
  });
115
114
  }
116
- try {
117
- const client = await attachDaemon({
118
- env: process.env,
119
- leaseToken: `ours-fleet-doctor-${process.pid}`,
120
- clientPid: process.pid,
121
- });
122
- const info = await client.version();
123
- if (info.name !== 'ours' || typeof info.version !== 'string')
124
- throw new Error('the selected endpoint did not return a valid ours daemon identity');
125
- checks.push({ name: 'ours daemon', ok: true, detail: `running (${info.version})` });
126
- }
127
- catch (error) {
115
+ const mcp = await exec('ours-mcp', ['--version']);
116
+ checks.push({
117
+ name: 'ours-mcp', ok: mcp.code === 0,
118
+ detail: mcp.code === 0 ? mcp.stdout.trim() : 'not found — npm i -g @ours.network/mcp',
119
+ });
120
+ if (mcp.code === 0) {
121
+ const st = await exec('ours-mcp', ['status']);
128
122
  checks.push({
129
- name: 'ours daemon', ok: false,
130
- detail: `not reachable through the SDK — start it with: ours daemon start `
131
- + `[${error?.message ?? String(error)}]`,
123
+ name: 'ours-mcp daemon', ok: st.code === 0,
124
+ detail: st.code === 0 ? 'running' : 'not running — start it with: ours-mcp start',
132
125
  });
133
126
  }
134
127
  if (platform === 'linux') {
@@ -295,7 +288,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
295
288
  }
296
289
  catch (e) {
297
290
  detail = `unreachable on :${endpoint.port} — monitored roles run degraded until it is up ` +
298
- `(start it: ours daemon start) [${e?.message ?? e}]`;
291
+ `(start it: ours-mcp start) [${e?.message ?? e}]`;
299
292
  }
300
293
  checks.push({ name: checkName, ok, detail });
301
294
  }
@@ -12,6 +12,11 @@ export interface ManagedFleetSpawnResult {
12
12
  session: 'tmux' | 'acp';
13
13
  model?: string;
14
14
  monitor: Pick<MonitorConfig, 'mode' | 'interrupt'>;
15
+ /** Adapter-resolved portable policy and exact native runtime mode. */
16
+ permissionMode?: {
17
+ fleetMode: 'ask' | 'auto' | 'allow';
18
+ nativeMode: string;
19
+ };
15
20
  inherited: string[];
16
21
  creationActionId: string;
17
22
  }
@@ -1,18 +1,23 @@
1
1
  import { createRequire } from 'node:module';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { dirname, resolve } from 'node:path';
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
4
  const require = createRequire(import.meta.url);
5
5
  export function resolveBundledAcpAgent(packageName, binName, fallbackCommand) {
6
6
  try {
7
7
  const manifestPath = require.resolve(`${packageName}/package.json`);
8
8
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
9
- const relative = typeof manifest.bin === 'string'
9
+ const declaredEntrypoint = typeof manifest.bin === 'string'
10
10
  ? manifest.bin
11
11
  : manifest.bin?.[binName];
12
- if (!relative)
12
+ if (!declaredEntrypoint)
13
13
  return { argv: [fallbackCommand], bundled: false };
14
- const entrypoint = resolve(dirname(manifestPath), relative);
15
- if (!existsSync(entrypoint))
14
+ const packageRoot = realpathSync(dirname(manifestPath));
15
+ const entrypoint = realpathSync(resolve(packageRoot, declaredEntrypoint));
16
+ const entrypointFromRoot = relative(packageRoot, entrypoint);
17
+ if (entrypointFromRoot === '..'
18
+ || entrypointFromRoot.startsWith(`..${sep}`)
19
+ || isAbsolute(entrypointFromRoot)
20
+ || !statSync(entrypoint).isFile())
16
21
  return { argv: [fallbackCommand], bundled: false };
17
22
  return {
18
23
  argv: [process.execPath, entrypoint], bundled: true, manifestPath,
@@ -6,8 +6,99 @@ import { registerAdapter } from './registry.js';
6
6
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
8
  import { bundledAcpAgent } from './acp-agent.js';
9
- const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort'];
9
+ import { restoreLockedHarnessMarketplace } from '../harness-plugins.js';
10
+ const OPTION_KEYS = [
11
+ 'plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort',
12
+ 'mcp_servers', 'mcp_servers_only',
13
+ ];
10
14
  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
+ /** A role that names its own ACP command runs a process fleet did not choose. */
18
+ const customAcpCommand = (role) => role.session === 'acp' && role.session_options?.acp?.command != null;
19
+ /**
20
+ * Does this server set include the ours connector?
21
+ *
22
+ * Load-bearing, and the reason it is a check rather than a doc line:
23
+ * `mcp_servers_only` maps to `--strict-mcp-config`, which ignores EVERY other MCP
24
+ * configuration — project `.mcp.json`, user settings, and **plugins**. On a
25
+ * normal install the ours connector arrives as a plugin
26
+ * (`~/.claude/plugins/.../plugin.json` declares `ours`), so a role that turns
27
+ * strict mode on without re-declaring it loses `send_message` and `get_messages`
28
+ * and cannot report that it has: a mute agent looks exactly like a quiet one.
29
+ *
30
+ * Matched on the command line rather than the server's NAME, because the name is
31
+ * the operator's to choose and would make this trivially satisfiable by writing
32
+ * `ours:` above the wrong command.
33
+ */
34
+ 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 [];
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
+ }
11
102
  /** Claude Code's accepted --permission-mode values. */
12
103
  const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
13
104
  /**
@@ -127,11 +218,8 @@ export async function pretrust(dir, deps = {}) {
127
218
  * it must be the Monitor TOOL, not a background Bash task (which never wakes the
128
219
  * agent on output → an armed-looking but deaf monitor).
129
220
  */
130
- const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
131
- const watchCommand = (id) => 'ours api watch-notifications --input '
132
- + shellQuote(JSON.stringify({ identity: id, since: 'tip' })) + ' --json';
133
221
  const armMonitor = (id) => 'arm a **persistent Monitor** (the Monitor TOOL — NOT a background Bash command; a ' +
134
- `background Bash task never wakes you on output) running \`${watchCommand(id)}\` ` +
222
+ `background Bash task never wakes you on output) running \`ours-mcp watch "${id}"\` ` +
135
223
  'so inbound ours mail wakes you';
136
224
  export function makeClaudeCodeAdapter(exec = realExec) {
137
225
  return {
@@ -149,7 +237,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
149
237
  }],
150
238
  };
151
239
  },
152
- validateOptions(opts) {
240
+ validateOptions(opts, role) {
153
241
  if (opts == null)
154
242
  return [];
155
243
  if (typeof opts !== 'object' || Array.isArray(opts))
@@ -157,12 +245,50 @@ export function makeClaudeCodeAdapter(exec = realExec) {
157
245
  const errors = Object.keys(opts)
158
246
  .filter(k => !OPTION_KEYS.includes(k))
159
247
  .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
160
- const effort = opts.effort;
248
+ const o = opts;
249
+ const effort = o.effort;
161
250
  if (effort != null && !EFFORT_LEVELS.includes(effort))
162
251
  errors.push({ path: 'harness_options.effort', message: `must be one of: ${EFFORT_LEVELS.join(', ')}` });
252
+ if (o.mcp_servers_only != null && typeof o.mcp_servers_only !== 'boolean')
253
+ errors.push({ path: 'harness_options.mcp_servers_only', message: 'must be a boolean' });
254
+ errors.push(...validateMcpServers(o.mcp_servers));
255
+ if (o.mcp_servers_only === true && !o.mcp_servers)
256
+ errors.push({
257
+ path: 'harness_options.mcp_servers_only',
258
+ message: 'requires harness_options.mcp_servers; on its own it would leave the role with no MCP servers at all',
259
+ });
260
+ // The muteness gate. Only when the declared set is otherwise well-formed —
261
+ // a shape error already told the operator to look here.
262
+ if (o.mcp_servers_only === true && o.mcp_servers && errors.length === 0
263
+ && !declaresOursConnector(o.mcp_servers))
264
+ errors.push({
265
+ path: 'harness_options.mcp_servers',
266
+ message: 'mcp_servers_only ignores every other MCP configuration, INCLUDING plugins — and the ours '
267
+ + 'connector is normally a plugin, so this role would have no send_message or get_messages and no way '
268
+ + 'to report that. Declare it explicitly, e.g. ours: { command: ours-mcp, args: [proxy] }',
269
+ });
270
+ // Session-aware refusals. Both options reach an ACP session through the
271
+ // bundled agent's `_meta` vocabulary, so a role that launches a DIFFERENT
272
+ // ACP agent cannot be promised either one. Refuse rather than send it and
273
+ // hope: silently dropping the config is the defect being fixed here.
274
+ if (role && customAcpCommand(role)) {
275
+ for (const key of ['plugins', 'mcp_servers', 'mcp_servers_only']) {
276
+ if (o[key] == null)
277
+ continue;
278
+ errors.push({
279
+ path: `harness_options.${key}`,
280
+ message: 'cannot be honoured with session_options.acp.command: it is delivered through the bundled '
281
+ + 'Claude ACP agent\'s _meta vocabulary, which another agent has no reason to read. Drop the '
282
+ + 'custom ACP command, or drop this option',
283
+ });
284
+ }
285
+ }
163
286
  return errors;
164
287
  },
165
288
  async prepareSession(role, dirs) {
289
+ // Re-materialize only from the persisted exact lock. Ordinary launches
290
+ // never resolve npm tags or run an installer.
291
+ restoreLockedHarnessMarketplace('claude-code', role.harnessPluginChannel);
166
292
  // Pre-trust stays a HOST-side step: inside the sandbox ~/.claude.json is
167
293
  // read-only, and it is the fleet's job to trust the role's dirs, not the
168
294
  // agent's (5.1, 6.1).
@@ -178,6 +304,15 @@ export function makeClaudeCodeAdapter(exec = realExec) {
178
304
  CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(autocompactPct(role)),
179
305
  MEMPALACE_HOOKS_AUTO_SAVE: 'false',
180
306
  MEMPALACE_MIDSESSION_AUTOSAVE: o.mem_palace_midsession_autosave ? 'true' : 'false',
307
+ // The role's identity, for the ours connector to bind at startup instead of
308
+ // the briefing telling the MODEL to call choose_identity. Both launches
309
+ // return `prep.env`, so this one line covers tmux and ACP alike.
310
+ //
311
+ // The bind the connector performs is PLAIN and fail-closed: it can never
312
+ // evict a live session, and a role whose identity does not exist yet simply
313
+ // boots unbound and falls through to the briefing's create-if-missing step.
314
+ // Nothing here may ever grow a force flag.
315
+ OURS_BIND_IDENTITY: role.identity,
181
316
  };
182
317
  if (!memPalace)
183
318
  env.MEMPALACE_DISABLED = 'true';
@@ -188,12 +323,29 @@ export function makeClaudeCodeAdapter(exec = realExec) {
188
323
  if (role.isolation)
189
324
  mkdirSync(harnessRuntimeDir(dirs.stateDir, 'claude'), { recursive: true });
190
325
  const argv = [];
326
+ let settingsOverlay;
191
327
  if (Object.keys(enabledPlugins).length) {
192
- const overlay = join(dirs.stateDir, '.settings-overlay.json');
193
- writeFileSync(overlay, JSON.stringify({ enabledPlugins }, null, 2));
194
- argv.push('--settings', overlay);
328
+ settingsOverlay = join(dirs.stateDir, '.settings-overlay.json');
329
+ writeFileSync(settingsOverlay, JSON.stringify({ enabledPlugins }, null, 2));
330
+ argv.push('--settings', settingsOverlay);
195
331
  }
196
- return { argv, env };
332
+ // `harness_options.mcp_servers` the tmux delivery. `--mcp-config` ADDS the
333
+ // file's servers; `--strict-mcp-config` is what makes the set exclusive, and
334
+ // it is opt-in per role because it drops everything else the user has,
335
+ // plugins included (see `declaresOursConnector`).
336
+ let mcpConfigFile;
337
+ if (o.mcp_servers) {
338
+ mcpConfigFile = join(dirs.stateDir, '.mcp-config.json');
339
+ writeFileSync(mcpConfigFile, JSON.stringify({ mcpServers: o.mcp_servers }, null, 2), { mode: 0o600 });
340
+ argv.push('--mcp-config', mcpConfigFile);
341
+ if (o.mcp_servers_only === true)
342
+ argv.push('--strict-mcp-config');
343
+ }
344
+ return {
345
+ argv, env,
346
+ ...(settingsOverlay ? { settingsOverlay } : {}),
347
+ ...(mcpConfigFile ? { mcpConfigFile } : {}),
348
+ };
197
349
  },
198
350
  buildLaunch(role, mode, s, prep) {
199
351
  const stateDir = roleStateDir(role);
@@ -223,6 +375,46 @@ export function makeClaudeCodeAdapter(exec = realExec) {
223
375
  acpPermissionModeId(role) {
224
376
  return permissionMode(role);
225
377
  },
378
+ /**
379
+ * Deliver, over ACP, the two things the tmux launch delivers as flags.
380
+ *
381
+ * `buildAcpLaunch` builds its own argv and cannot carry `prep.argv`: the
382
+ * process it launches is the ACP agent, not `claude`, and it takes none of
383
+ * claude's flags. That is why `harness_options.plugins` did nothing at all on
384
+ * an ACP role — the overlay was written and then dropped, and the mem-palace
385
+ * toggle rode `prep.env` and survived, so the failure was silent AND
386
+ * selective.
387
+ *
388
+ * `_meta.claudeCode.options` is the bundled agent's own passthrough into the
389
+ * Claude Agent SDK (@agentclientprotocol/claude-agent-acp, acp-agent.js:4092
390
+ * → the `options` object at :4144). `settings` takes the same overlay path
391
+ * `--settings` takes; `strictMcpConfig` is the SDK's spelling of
392
+ * `--strict-mcp-config`. Both are spread BEFORE the fields the agent forces,
393
+ * so neither is overwritten.
394
+ *
395
+ * ⚠ RETURNS NOTHING FOR A ROLE THAT NAMES ITS OWN ACP COMMAND. That process
396
+ * is not the bundled agent and has no reason to read this vocabulary; sending
397
+ * it anyway would be the silent drop again, one level down. `validateOptions`
398
+ * refuses those roles instead.
399
+ */
400
+ acpSessionMeta(role, prep) {
401
+ if (customAcpCommand(role))
402
+ return undefined;
403
+ const options = {};
404
+ if (prep.settingsOverlay)
405
+ options.settings = prep.settingsOverlay;
406
+ if (role.harness_options?.mcp_servers_only === true)
407
+ options.strictMcpConfig = true;
408
+ return Object.keys(options).length ? { claudeCode: { options } } : undefined;
409
+ },
410
+ /**
411
+ * The declared servers, in ACP's array shape. Sent on `session/new` and on
412
+ * resume/load, because the SDK builds its server set once per session and a
413
+ * resumed session that dropped them would quietly lose its tools.
414
+ */
415
+ acpMcpServers(role) {
416
+ return acpMcpServersFor(role.harness_options?.mcp_servers);
417
+ },
226
418
  isolationPaths(_role, _dirs) {
227
419
  const claudeHome = join(home(), '.claude');
228
420
  return {
@@ -285,6 +477,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
285
477
  currentIdentityTool: 'current_identity',
286
478
  sendTool: 'send_message',
287
479
  getMessagesTool: 'get_messages',
480
+ watchCommand: id => `ours-mcp watch "${id}"`,
288
481
  monitorInstruction: id => {
289
482
  const m = armMonitor(id);
290
483
  return `${m.charAt(0).toUpperCase()}${m.slice(1)}.`;
@@ -1,10 +1,13 @@
1
1
  import { type Exec } from '../exec.js';
2
- import type { HarnessAdapter, UnattendedCapability } from './types.js';
2
+ import type { AcpLaunch, HarnessAdapter, UnattendedCapability } from './types.js';
3
+ import { type AcpAgentResolution } from './acp-agent.js';
3
4
  /**
4
5
  * What an unattended role can actually do under Codex's native settings.
5
6
  * `on-request` and `untrusted` stop to ask, and with no console attached that
6
7
  * request is refused rather than answered — so the role can only read.
7
8
  */
8
9
  export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
10
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
11
+ export declare function codexAcpLaunchForResolution(resolution: AcpAgentResolution): Pick<AcpLaunch, 'argv' | 'permissionMetadataSource'>;
9
12
  export declare function makeCodexAdapter(exec?: Exec): HarnessAdapter;
10
13
  export declare const codexAdapter: HarnessAdapter;
@@ -5,7 +5,8 @@ import { agentDir, home } from '../paths.js';
5
5
  import { realExec } from '../exec.js';
6
6
  import { registerAdapter } from './registry.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
- import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
8
+ import { resolveBundledAcpAgent, } from './acp-agent.js';
9
+ import { restoreLockedHarnessMarketplace } from '../harness-plugins.js';
9
10
  const OPTION_KEYS = [
10
11
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
11
12
  'monitor',
@@ -51,9 +52,7 @@ function sandboxMode(role) {
51
52
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
52
53
  return s;
53
54
  }
54
- /** codex-acp exposes the same sandbox postures as named ACP agent modes. */
55
- function acpAgentMode(role) {
56
- const sandbox = sandboxMode(role);
55
+ function modeForSandbox(sandbox) {
57
56
  if (sandbox === 'read-only')
58
57
  return 'read-only';
59
58
  if (sandbox === 'workspace-write')
@@ -62,6 +61,26 @@ function acpAgentMode(role) {
62
61
  return 'agent-full-access';
63
62
  return undefined;
64
63
  }
64
+ /**
65
+ * Resolve the coupled Codex ACP mode.
66
+ *
67
+ * The portable approval contract owns the default mode selection: `allow`
68
+ * means the adapter's fully non-interactive yolo preset and `auto` means its
69
+ * ordinary agent preset. This intentionally means that Codex ACP cannot retain
70
+ * an independent neutral filesystem posture for those two modes. An explicit
71
+ * native sandbox remains authoritative and selects its corresponding preset.
72
+ */
73
+ function acpAgentMode(role) {
74
+ const explicitSandbox = role.harness_options?.sandbox;
75
+ if (explicitSandbox != null)
76
+ return modeForSandbox(sandboxMode(role));
77
+ if (role.permissions?.approval === 'allow')
78
+ return 'agent-full-access';
79
+ if (role.permissions?.approval === 'auto')
80
+ return 'agent';
81
+ const sandbox = sandboxMode(role);
82
+ return modeForSandbox(sandbox);
83
+ }
65
84
  function acpModePermissions(mode) {
66
85
  if (mode === 'read-only')
67
86
  return { approval: 'on-request', sandbox: 'read-only' };
@@ -69,6 +88,10 @@ function acpModePermissions(mode) {
69
88
  return { approval: 'never', sandbox: 'danger-full-access' };
70
89
  return { approval: 'on-request', sandbox: 'workspace-write' };
71
90
  }
91
+ /** The sandbox Codex will actually receive from the selected coupled ACP mode. */
92
+ function acpRuntimeSandbox(role) {
93
+ return acpModePermissions(acpAgentMode(role)).sandbox;
94
+ }
72
95
  function fleetModeForApproval(nativeMode) {
73
96
  if (nativeMode === 'never')
74
97
  return 'allow';
@@ -102,6 +125,18 @@ function launcherMode(role) {
102
125
  function bundledCodexAcp() {
103
126
  return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
104
127
  }
128
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
129
+ export function codexAcpLaunchForResolution(resolution) {
130
+ const permissionMetadataSource = resolution.bundled
131
+ && resolution.version === BUNDLED_CODEX_ACP_VERSION
132
+ && resolution.manifestPath !== undefined
133
+ ? 'codex-acp'
134
+ : undefined;
135
+ return {
136
+ argv: [...resolution.argv],
137
+ ...(permissionMetadataSource ? { permissionMetadataSource } : {}),
138
+ };
139
+ }
105
140
  function canOverrideBundledAcpApproval() {
106
141
  const resolution = bundledCodexAcp();
107
142
  return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
@@ -147,7 +182,7 @@ function codexAcpEnvironment(role, dirs) {
147
182
  return {
148
183
  CODEX_PATH: command,
149
184
  [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
150
- [CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
185
+ [CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
151
186
  [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
152
187
  ...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
153
188
  };
@@ -275,6 +310,9 @@ export function makeCodexAdapter(exec = realExec) {
275
310
  return errs;
276
311
  },
277
312
  async prepareSession(role, dirs) {
313
+ // Re-materialize only from the persisted exact lock. Ordinary launches
314
+ // never resolve npm tags or run an installer.
315
+ restoreLockedHarnessMarketplace('codex', role.harnessPluginChannel);
278
316
  // Per-role harness runtime home (5.1); harmless for un-isolated roles.
279
317
  // Only a role that declares `isolation:` gets a sandbox, and only a
280
318
  // sandbox needs this directory to exist before entry.
@@ -285,7 +323,16 @@ export function makeCodexAdapter(exec = realExec) {
285
323
  if (requested === 'ours-codex' && !hasOursCodex)
286
324
  throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
287
325
  const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
288
- return { argv: [], env: codexAcpEnvironment(role, dirs), command };
326
+ return {
327
+ argv: [],
328
+ // OURS_BIND_IDENTITY is the connector's startup bind seed — see the note in
329
+ // claude-code.ts's prepareSession. It belongs on EVERY harness that runs a
330
+ // role with an ours identity, not just claude-code: a seed that works on one
331
+ // harness and silently does nothing on the other is the same class of defect
332
+ // as a config key that only works on one session type.
333
+ env: { OURS_BIND_IDENTITY: role.identity, ...codexAcpEnvironment(role, dirs) },
334
+ command,
335
+ };
289
336
  },
290
337
  buildLaunch(role, mode, _s, prep) {
291
338
  const stateDir = roleStateDir(role);
@@ -299,17 +346,30 @@ export function makeCodexAdapter(exec = realExec) {
299
346
  },
300
347
  buildAcpLaunch(role, prep) {
301
348
  const configured = role.session_options?.acp?.command;
349
+ // Resolve once: both argv and permission-metadata provenance must describe
350
+ // the same artifact. A bare PATH fallback is launchable for compatibility,
351
+ // but is never authenticated for protected-MCP auto-approval.
352
+ const resolved = configured == null
353
+ ? codexAcpLaunchForResolution(bundledCodexAcp())
354
+ : undefined;
302
355
  const argv = Array.isArray(configured)
303
356
  ? [...configured]
304
357
  : typeof configured === 'string'
305
358
  ? ['sh', '-c', configured]
306
- : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
359
+ : resolved.argv;
307
360
  const initialMode = acpAgentMode(role);
308
361
  return {
309
362
  argv,
310
363
  env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
364
+ ...(resolved?.permissionMetadataSource
365
+ ? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
311
366
  };
312
367
  },
368
+ // INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
369
+ // keeps resumed/loaded sessions and live status on the identical mode.
370
+ acpPermissionModeId(role) {
371
+ return acpAgentMode(role);
372
+ },
313
373
  isolationPaths(role, _dirs) {
314
374
  const codexHome = join(home(), '.codex');
315
375
  const profile = role.harness_options?.profile;
@@ -362,7 +422,9 @@ export function makeCodexAdapter(exec = realExec) {
362
422
  const mode = acpAgentMode(role) ?? 'agent';
363
423
  const configured = role.session_options?.acp?.command;
364
424
  const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
365
- const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
425
+ const actual = overrideAvailable
426
+ ? { approval, sandbox: acpRuntimeSandbox(role) }
427
+ : acpModePermissions(mode);
366
428
  const exact = actual.approval === approval && actual.sandbox === sandbox;
367
429
  return {
368
430
  ...translated,
@@ -372,10 +434,9 @@ export function makeCodexAdapter(exec = realExec) {
372
434
  ? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
373
435
  + `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
374
436
  + `sandbox=${actual.sandbox}`
375
- : `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
376
- + `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
377
- + `app-server override is unavailable; this does not exactly represent `
378
- + `approval=${approval} sandbox=${sandbox}`],
437
+ : `Codex ACP mode '${mode}' couples approval and filesystem as `
438
+ + `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
439
+ + `represent approval=${approval} sandbox=${sandbox}`],
379
440
  capabilities: codexCapabilities(actual.approval, actual.sandbox),
380
441
  };
381
442
  }
@@ -411,6 +472,7 @@ export function makeCodexAdapter(exec = realExec) {
411
472
  currentIdentityTool: 'current_identity',
412
473
  sendTool: 'send_message',
413
474
  getMessagesTool: 'get_messages',
475
+ watchCommand: id => `ours-mcp watch "${id}"`,
414
476
  monitorInstruction: (id, configuredRole) => {
415
477
  const consented = configuredRole?.harness_options?.monitor === true;
416
478
  const consent = consented