@ours.network/fleet 0.19.0-nightly.9 → 1.0.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 (114) hide show
  1. package/README.md +66 -11
  2. package/dist/application/fleet-query-service.d.ts +1 -1
  3. package/dist/application/fleet-query-service.js +12 -0
  4. package/dist/application/role-creation-service.js +3 -1
  5. package/dist/application/types.d.ts +11 -0
  6. package/dist/briefing.d.ts +1 -1
  7. package/dist/briefing.js +103 -10
  8. package/dist/build-info.json +5 -5
  9. package/dist/cli.js +41 -11
  10. package/dist/config.d.ts +21 -7
  11. package/dist/config.js +32 -18
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/docs.d.ts +2 -2
  14. package/dist/docs.js +117 -24
  15. package/dist/doctor.js +10 -10
  16. package/dist/duration.js +1 -1
  17. package/dist/fleet-proxy.d.ts +5 -0
  18. package/dist/harness/acp-agent.js +11 -6
  19. package/dist/harness/claude-code.js +201 -8
  20. package/dist/harness/codex.d.ts +4 -1
  21. package/dist/harness/codex.js +72 -13
  22. package/dist/harness/types.d.ts +57 -6
  23. package/dist/isolation/bubblewrap.d.ts +4 -4
  24. package/dist/isolation/bubblewrap.js +4 -4
  25. package/dist/isolation/policy.d.ts +1 -1
  26. package/dist/isolation/policy.js +2 -2
  27. package/dist/isolation/registry.d.ts +3 -3
  28. package/dist/isolation/registry.js +5 -5
  29. package/dist/isolation/resources.d.ts +1 -1
  30. package/dist/isolation/resources.js +1 -1
  31. package/dist/isolation/types.d.ts +3 -3
  32. package/dist/loops/manager.d.ts +30 -1
  33. package/dist/loops/manager.js +69 -6
  34. package/dist/loops/state.d.ts +18 -0
  35. package/dist/loops/state.js +4 -0
  36. package/dist/model-env.d.ts +71 -0
  37. package/dist/model-env.js +106 -0
  38. package/dist/monitor.d.ts +2 -2
  39. package/dist/monitor.js +4 -4
  40. package/dist/ops.d.ts +2 -2
  41. package/dist/ops.js +9 -9
  42. package/dist/owner-channel/channel.d.ts +17 -0
  43. package/dist/owner-channel/channel.js +104 -23
  44. package/dist/owner-channel/commands.d.ts +9 -0
  45. package/dist/owner-channel/commands.js +226 -99
  46. package/dist/owner-channel/notices.d.ts +7 -0
  47. package/dist/owner-channel/notices.js +9 -0
  48. package/dist/rooms-tasks/cli.js +731 -219
  49. package/dist/rooms-tasks/close.d.ts +35 -0
  50. package/dist/rooms-tasks/close.js +190 -0
  51. package/dist/rooms-tasks/cowork-adapter.d.ts +24 -0
  52. package/dist/rooms-tasks/cowork-adapter.js +120 -0
  53. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  54. package/dist/rooms-tasks/external-worker.js +40 -0
  55. package/dist/rooms-tasks/index.d.ts +2 -0
  56. package/dist/rooms-tasks/index.js +2 -0
  57. package/dist/rooms-tasks/markdown.d.ts +49 -0
  58. package/dist/rooms-tasks/markdown.js +206 -0
  59. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  60. package/dist/rooms-tasks/member-startup.js +151 -0
  61. package/dist/rooms-tasks/provision.d.ts +8 -0
  62. package/dist/rooms-tasks/provision.js +418 -97
  63. package/dist/rooms-tasks/room-state.d.ts +13 -1
  64. package/dist/rooms-tasks/room-state.js +149 -1
  65. package/dist/rooms-tasks/task-state.d.ts +13 -2
  66. package/dist/rooms-tasks/task-state.js +131 -3
  67. package/dist/rooms-tasks/terminal.d.ts +23 -0
  68. package/dist/rooms-tasks/terminal.js +56 -0
  69. package/dist/rooms-tasks/types.d.ts +108 -2
  70. package/dist/runner.d.ts +48 -0
  71. package/dist/runner.js +252 -100
  72. package/dist/session/acp.d.ts +106 -2
  73. package/dist/session/acp.js +217 -14
  74. package/dist/session/activity.d.ts +31 -0
  75. package/dist/session/activity.js +48 -0
  76. package/dist/session/control.d.ts +1 -1
  77. package/dist/session/conversation-normalizer.d.ts +7 -1
  78. package/dist/session/conversation-normalizer.js +154 -11
  79. package/dist/session/conversation-store.js +1 -1
  80. package/dist/session/conversation-types.d.ts +25 -6
  81. package/dist/session/types.d.ts +35 -0
  82. package/dist/spawn.d.ts +4 -2
  83. package/dist/spawn.js +39 -26
  84. package/dist/supervisor/launchd.d.ts +2 -2
  85. package/dist/supervisor/launchd.js +4 -4
  86. package/dist/supervisor/systemd.js +4 -4
  87. package/dist/supervisor/types.d.ts +1 -1
  88. package/dist/temp-lifecycle.d.ts +14 -2
  89. package/dist/temp-lifecycle.js +90 -0
  90. package/dist/watchdog/alerts.d.ts +2 -2
  91. package/dist/watchdog/alerts.js +2 -2
  92. package/dist/watchdog/briefing.d.ts +5 -5
  93. package/dist/watchdog/briefing.js +13 -6
  94. package/dist/watchdog/config.js +1 -1
  95. package/dist/watchdog/query.d.ts +2 -2
  96. package/dist/watchdog/query.js +2 -2
  97. package/dist/watchdog/report.js +2 -2
  98. package/dist/watchdog/run.d.ts +2 -2
  99. package/dist/watchdog/run.js +6 -6
  100. package/dist/watchdog/scheduler.d.ts +7 -7
  101. package/dist/watchdog/scheduler.js +10 -10
  102. package/dist/watchdog/service.d.ts +4 -4
  103. package/dist/watchdog/service.js +5 -6
  104. package/dist/watchdog/store.d.ts +4 -4
  105. package/dist/watchdog/store.js +4 -4
  106. package/dist/web/runtime.js +1 -1
  107. package/dist/web/server.js +1 -1
  108. package/dist/web/topology-promote.js +1 -1
  109. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  110. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  111. package/dist/web-app/index.html +1 -1
  112. package/dist/worklog.d.ts +7 -1
  113. package/dist/worklog.js +191 -39
  114. package/package.json +1 -1
package/dist/duration.js CHANGED
@@ -1,4 +1,4 @@
1
- /** Parse `30s | 10m | 2h` duration strings to milliseconds (spec §2). */
1
+ /** Parse `30s | 10m | 2h` duration strings to milliseconds. */
2
2
  const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
3
3
  const RE = /^(\d+)([smhd])$/;
4
4
  export function parseDuration(text, opts = {}) {
@@ -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,98 @@ 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
+ const OPTION_KEYS = [
10
+ 'plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort',
11
+ 'mcp_servers', 'mcp_servers_only',
12
+ ];
10
13
  const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
14
+ /** `.mcp.json` server types. Absent means stdio, as the file format has it. */
15
+ const MCP_SERVER_TYPES = ['stdio', 'http', 'sse'];
16
+ /** A role that names its own ACP command runs a process fleet did not choose. */
17
+ const customAcpCommand = (role) => role.session === 'acp' && role.session_options?.acp?.command != null;
18
+ /**
19
+ * Does this server set include the ours connector?
20
+ *
21
+ * Load-bearing, and the reason it is a check rather than a doc line:
22
+ * `mcp_servers_only` maps to `--strict-mcp-config`, which ignores EVERY other MCP
23
+ * configuration — project `.mcp.json`, user settings, and **plugins**. On a
24
+ * normal install the ours connector arrives as a plugin
25
+ * (`~/.claude/plugins/.../plugin.json` declares `ours`), so a role that turns
26
+ * strict mode on without re-declaring it loses `send_message` and `get_messages`
27
+ * and cannot report that it has: a mute agent looks exactly like a quiet one.
28
+ *
29
+ * Matched on the command line rather than the server's NAME, because the name is
30
+ * the operator's to choose and would make this trivially satisfiable by writing
31
+ * `ours:` above the wrong command.
32
+ */
33
+ const declaresOursConnector = (servers) => Object.values(servers).some(s => [s.command ?? '', ...(s.args ?? [])].some(part => /(^|[/\\])ours-mcp($|\s)|@ours\.network[/\\]mcp/.test(part)));
34
+ /** Shape-check `harness_options.mcp_servers` against `.mcp.json`'s own rules. */
35
+ function validateMcpServers(servers) {
36
+ if (servers == null)
37
+ return [];
38
+ const at = (k = '') => ({ path: `harness_options.mcp_servers${k}` });
39
+ if (typeof servers !== 'object' || Array.isArray(servers))
40
+ return [{ ...at(), message: 'must be a map of server name to server definition' }];
41
+ const entries = Object.entries(servers);
42
+ if (!entries.length)
43
+ return [{ ...at(), message: 'must declare at least one server, or be omitted' }];
44
+ const errors = [];
45
+ for (const [name, raw] of entries) {
46
+ const p = `.${name}`;
47
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
48
+ errors.push({ ...at(p), message: 'server name must be [A-Za-z0-9_-]' });
49
+ continue;
50
+ }
51
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
52
+ errors.push({ ...at(p), message: 'must be a map' });
53
+ continue;
54
+ }
55
+ const s = raw;
56
+ if (s.type != null && !MCP_SERVER_TYPES.includes(s.type))
57
+ errors.push({ ...at(`${p}.type`), message: `must be one of: ${MCP_SERVER_TYPES.join(', ')}` });
58
+ const remote = s.type === 'http' || s.type === 'sse';
59
+ if (remote) {
60
+ if (typeof s.url !== 'string' || !s.url.trim())
61
+ errors.push({ ...at(`${p}.url`), message: `must be a non-empty URL for a ${s.type} server` });
62
+ if (s.command != null)
63
+ errors.push({ ...at(`${p}.command`), message: `must not be set for a ${s.type} server` });
64
+ }
65
+ else {
66
+ if (typeof s.command !== 'string' || !s.command.trim())
67
+ errors.push({ ...at(`${p}.command`), message: 'must be a non-empty command for a stdio server' });
68
+ if (s.args != null && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string')))
69
+ errors.push({ ...at(`${p}.args`), message: 'must be an array of strings' });
70
+ if (s.url != null)
71
+ errors.push({ ...at(`${p}.url`), message: 'must not be set for a stdio server' });
72
+ }
73
+ for (const key of ['env', 'headers']) {
74
+ const v = s[key];
75
+ if (v == null)
76
+ continue;
77
+ if (typeof v !== 'object' || Array.isArray(v)
78
+ || Object.values(v).some(x => typeof x !== 'string'))
79
+ errors.push({ ...at(`${p}.${key}`), message: 'must be a map of string to string' });
80
+ }
81
+ }
82
+ return errors;
83
+ }
84
+ /** `harness_options.mcp_servers` in ACP's `session/new` array shape. */
85
+ function acpMcpServersFor(servers) {
86
+ if (!servers)
87
+ return [];
88
+ // `env` and `headers` are REQUIRED arrays in the protocol, so they are always
89
+ // sent — empty when the role declared none.
90
+ const pairs = (r) => Object.entries(r ?? {}).map(([name, value]) => ({ name, value }));
91
+ return Object.entries(servers).map(([name, s]) => {
92
+ if (s.type === 'http' || s.type === 'sse')
93
+ return { name, type: s.type, url: s.url, headers: pairs(s.headers) };
94
+ // Stdio carries NO `type` field: ACP's stdio variant is the one without it,
95
+ // and the bundled agent keys on exactly that (claude-agent-acp
96
+ // acp-agent.js:4058, `!("type" in server)`), so sending `type: 'stdio'`
97
+ // would drop the server on the floor.
98
+ return { name, command: s.command, args: s.args ?? [], env: pairs(s.env) };
99
+ });
100
+ }
11
101
  /** Claude Code's accepted --permission-mode values. */
12
102
  const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
13
103
  /**
@@ -149,7 +239,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
149
239
  }],
150
240
  };
151
241
  },
152
- validateOptions(opts) {
242
+ validateOptions(opts, role) {
153
243
  if (opts == null)
154
244
  return [];
155
245
  if (typeof opts !== 'object' || Array.isArray(opts))
@@ -157,9 +247,44 @@ export function makeClaudeCodeAdapter(exec = realExec) {
157
247
  const errors = Object.keys(opts)
158
248
  .filter(k => !OPTION_KEYS.includes(k))
159
249
  .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
160
- const effort = opts.effort;
250
+ const o = opts;
251
+ const effort = o.effort;
161
252
  if (effort != null && !EFFORT_LEVELS.includes(effort))
162
253
  errors.push({ path: 'harness_options.effort', message: `must be one of: ${EFFORT_LEVELS.join(', ')}` });
254
+ if (o.mcp_servers_only != null && typeof o.mcp_servers_only !== 'boolean')
255
+ errors.push({ path: 'harness_options.mcp_servers_only', message: 'must be a boolean' });
256
+ errors.push(...validateMcpServers(o.mcp_servers));
257
+ if (o.mcp_servers_only === true && !o.mcp_servers)
258
+ errors.push({
259
+ path: 'harness_options.mcp_servers_only',
260
+ message: 'requires harness_options.mcp_servers; on its own it would leave the role with no MCP servers at all',
261
+ });
262
+ // The muteness gate. Only when the declared set is otherwise well-formed —
263
+ // a shape error already told the operator to look here.
264
+ if (o.mcp_servers_only === true && o.mcp_servers && errors.length === 0
265
+ && !declaresOursConnector(o.mcp_servers))
266
+ errors.push({
267
+ path: 'harness_options.mcp_servers',
268
+ message: 'mcp_servers_only ignores every other MCP configuration, INCLUDING plugins — and the ours '
269
+ + 'connector is normally a plugin, so this role would have no send_message or get_messages and no way '
270
+ + 'to report that. Declare it explicitly, e.g. ours: { command: ours-mcp, args: [proxy] }',
271
+ });
272
+ // Session-aware refusals. Both options reach an ACP session through the
273
+ // bundled agent's `_meta` vocabulary, so a role that launches a DIFFERENT
274
+ // ACP agent cannot be promised either one. Refuse rather than send it and
275
+ // hope: silently dropping the config is the defect being fixed here.
276
+ if (role && customAcpCommand(role)) {
277
+ for (const key of ['plugins', 'mcp_servers', 'mcp_servers_only']) {
278
+ if (o[key] == null)
279
+ continue;
280
+ errors.push({
281
+ path: `harness_options.${key}`,
282
+ message: 'cannot be honoured with session_options.acp.command: it is delivered through the bundled '
283
+ + 'Claude ACP agent\'s _meta vocabulary, which another agent has no reason to read. Drop the '
284
+ + 'custom ACP command, or drop this option',
285
+ });
286
+ }
287
+ }
163
288
  return errors;
164
289
  },
165
290
  async prepareSession(role, dirs) {
@@ -178,22 +303,48 @@ export function makeClaudeCodeAdapter(exec = realExec) {
178
303
  CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(autocompactPct(role)),
179
304
  MEMPALACE_HOOKS_AUTO_SAVE: 'false',
180
305
  MEMPALACE_MIDSESSION_AUTOSAVE: o.mem_palace_midsession_autosave ? 'true' : 'false',
306
+ // The role's identity, for the ours connector to bind at startup instead of
307
+ // the briefing telling the MODEL to call choose_identity. Both launches
308
+ // return `prep.env`, so this one line covers tmux and ACP alike.
309
+ //
310
+ // The bind the connector performs is PLAIN and fail-closed: it can never
311
+ // evict a live session, and a role whose identity does not exist yet simply
312
+ // boots unbound and falls through to the briefing's create-if-missing step.
313
+ // Nothing here may ever grow a force flag.
314
+ OURS_BIND_IDENTITY: role.identity,
181
315
  };
182
316
  if (!memPalace)
183
317
  env.MEMPALACE_DISABLED = 'true';
184
- // Per-role harness runtime home (5.1). Created before sandbox entry so the
318
+ // Per-role harness runtime home. Created before sandbox entry so the
185
319
  // bind has something to mount; harmless for un-isolated roles.
186
320
  // Only a role that declares `isolation:` gets a sandbox, and only a
187
321
  // sandbox needs this directory to exist before entry.
188
322
  if (role.isolation)
189
323
  mkdirSync(harnessRuntimeDir(dirs.stateDir, 'claude'), { recursive: true });
190
324
  const argv = [];
325
+ let settingsOverlay;
191
326
  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);
327
+ settingsOverlay = join(dirs.stateDir, '.settings-overlay.json');
328
+ writeFileSync(settingsOverlay, JSON.stringify({ enabledPlugins }, null, 2));
329
+ argv.push('--settings', settingsOverlay);
195
330
  }
196
- return { argv, env };
331
+ // `harness_options.mcp_servers` the tmux delivery. `--mcp-config` ADDS the
332
+ // file's servers; `--strict-mcp-config` is what makes the set exclusive, and
333
+ // it is opt-in per role because it drops everything else the user has,
334
+ // plugins included (see `declaresOursConnector`).
335
+ let mcpConfigFile;
336
+ if (o.mcp_servers) {
337
+ mcpConfigFile = join(dirs.stateDir, '.mcp-config.json');
338
+ writeFileSync(mcpConfigFile, JSON.stringify({ mcpServers: o.mcp_servers }, null, 2), { mode: 0o600 });
339
+ argv.push('--mcp-config', mcpConfigFile);
340
+ if (o.mcp_servers_only === true)
341
+ argv.push('--strict-mcp-config');
342
+ }
343
+ return {
344
+ argv, env,
345
+ ...(settingsOverlay ? { settingsOverlay } : {}),
346
+ ...(mcpConfigFile ? { mcpConfigFile } : {}),
347
+ };
197
348
  },
198
349
  buildLaunch(role, mode, s, prep) {
199
350
  const stateDir = roleStateDir(role);
@@ -223,6 +374,46 @@ export function makeClaudeCodeAdapter(exec = realExec) {
223
374
  acpPermissionModeId(role) {
224
375
  return permissionMode(role);
225
376
  },
377
+ /**
378
+ * Deliver, over ACP, the two things the tmux launch delivers as flags.
379
+ *
380
+ * `buildAcpLaunch` builds its own argv and cannot carry `prep.argv`: the
381
+ * process it launches is the ACP agent, not `claude`, and it takes none of
382
+ * claude's flags. That is why `harness_options.plugins` did nothing at all on
383
+ * an ACP role — the overlay was written and then dropped, and the mem-palace
384
+ * toggle rode `prep.env` and survived, so the failure was silent AND
385
+ * selective.
386
+ *
387
+ * `_meta.claudeCode.options` is the bundled agent's own passthrough into the
388
+ * Claude Agent SDK (@agentclientprotocol/claude-agent-acp, acp-agent.js:4092
389
+ * → the `options` object at :4144). `settings` takes the same overlay path
390
+ * `--settings` takes; `strictMcpConfig` is the SDK's spelling of
391
+ * `--strict-mcp-config`. Both are spread BEFORE the fields the agent forces,
392
+ * so neither is overwritten.
393
+ *
394
+ * ⚠ RETURNS NOTHING FOR A ROLE THAT NAMES ITS OWN ACP COMMAND. That process
395
+ * is not the bundled agent and has no reason to read this vocabulary; sending
396
+ * it anyway would be the silent drop again, one level down. `validateOptions`
397
+ * refuses those roles instead.
398
+ */
399
+ acpSessionMeta(role, prep) {
400
+ if (customAcpCommand(role))
401
+ return undefined;
402
+ const options = {};
403
+ if (prep.settingsOverlay)
404
+ options.settings = prep.settingsOverlay;
405
+ if (role.harness_options?.mcp_servers_only === true)
406
+ options.strictMcpConfig = true;
407
+ return Object.keys(options).length ? { claudeCode: { options } } : undefined;
408
+ },
409
+ /**
410
+ * The declared servers, in ACP's array shape. Sent on `session/new` and on
411
+ * resume/load, because the SDK builds its server set once per session and a
412
+ * resumed session that dropped them would quietly lose its tools.
413
+ */
414
+ acpMcpServers(role) {
415
+ return acpMcpServersFor(role.harness_options?.mcp_servers);
416
+ },
226
417
  isolationPaths(_role, _dirs) {
227
418
  const claudeHome = join(home(), '.claude');
228
419
  return {
@@ -285,6 +476,8 @@ export function makeClaudeCodeAdapter(exec = realExec) {
285
476
  currentIdentityTool: 'current_identity',
286
477
  sendTool: 'send_message',
287
478
  getMessagesTool: 'get_messages',
479
+ listHistoryTool: 'list_history',
480
+ getHistoryItemTool: 'get_history_item',
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,7 @@ 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
9
  const OPTION_KEYS = [
10
10
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
11
11
  'monitor',
@@ -51,9 +51,7 @@ function sandboxMode(role) {
51
51
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
52
52
  return s;
53
53
  }
54
- /** codex-acp exposes the same sandbox postures as named ACP agent modes. */
55
- function acpAgentMode(role) {
56
- const sandbox = sandboxMode(role);
54
+ function modeForSandbox(sandbox) {
57
55
  if (sandbox === 'read-only')
58
56
  return 'read-only';
59
57
  if (sandbox === 'workspace-write')
@@ -62,6 +60,26 @@ function acpAgentMode(role) {
62
60
  return 'agent-full-access';
63
61
  return undefined;
64
62
  }
63
+ /**
64
+ * Resolve the coupled Codex ACP mode.
65
+ *
66
+ * The portable approval contract owns the default mode selection: `allow`
67
+ * means the adapter's fully non-interactive yolo preset and `auto` means its
68
+ * ordinary agent preset. This intentionally means that Codex ACP cannot retain
69
+ * an independent neutral filesystem posture for those two modes. An explicit
70
+ * native sandbox remains authoritative and selects its corresponding preset.
71
+ */
72
+ function acpAgentMode(role) {
73
+ const explicitSandbox = role.harness_options?.sandbox;
74
+ if (explicitSandbox != null)
75
+ return modeForSandbox(sandboxMode(role));
76
+ if (role.permissions?.approval === 'allow')
77
+ return 'agent-full-access';
78
+ if (role.permissions?.approval === 'auto')
79
+ return 'agent';
80
+ const sandbox = sandboxMode(role);
81
+ return modeForSandbox(sandbox);
82
+ }
65
83
  function acpModePermissions(mode) {
66
84
  if (mode === 'read-only')
67
85
  return { approval: 'on-request', sandbox: 'read-only' };
@@ -69,6 +87,10 @@ function acpModePermissions(mode) {
69
87
  return { approval: 'never', sandbox: 'danger-full-access' };
70
88
  return { approval: 'on-request', sandbox: 'workspace-write' };
71
89
  }
90
+ /** The sandbox Codex will actually receive from the selected coupled ACP mode. */
91
+ function acpRuntimeSandbox(role) {
92
+ return acpModePermissions(acpAgentMode(role)).sandbox;
93
+ }
72
94
  function fleetModeForApproval(nativeMode) {
73
95
  if (nativeMode === 'never')
74
96
  return 'allow';
@@ -102,6 +124,18 @@ function launcherMode(role) {
102
124
  function bundledCodexAcp() {
103
125
  return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
104
126
  }
127
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
128
+ export function codexAcpLaunchForResolution(resolution) {
129
+ const permissionMetadataSource = resolution.bundled
130
+ && resolution.version === BUNDLED_CODEX_ACP_VERSION
131
+ && resolution.manifestPath !== undefined
132
+ ? 'codex-acp'
133
+ : undefined;
134
+ return {
135
+ argv: [...resolution.argv],
136
+ ...(permissionMetadataSource ? { permissionMetadataSource } : {}),
137
+ };
138
+ }
105
139
  function canOverrideBundledAcpApproval() {
106
140
  const resolution = bundledCodexAcp();
107
141
  return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
@@ -147,7 +181,7 @@ function codexAcpEnvironment(role, dirs) {
147
181
  return {
148
182
  CODEX_PATH: command,
149
183
  [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
150
- [CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
184
+ [CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
151
185
  [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
152
186
  ...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
153
187
  };
@@ -275,7 +309,7 @@ export function makeCodexAdapter(exec = realExec) {
275
309
  return errs;
276
310
  },
277
311
  async prepareSession(role, dirs) {
278
- // Per-role harness runtime home (5.1); harmless for un-isolated roles.
312
+ // Per-role harness runtime home; harmless for un-isolated roles.
279
313
  // Only a role that declares `isolation:` gets a sandbox, and only a
280
314
  // sandbox needs this directory to exist before entry.
281
315
  if (role.isolation)
@@ -285,7 +319,16 @@ export function makeCodexAdapter(exec = realExec) {
285
319
  if (requested === 'ours-codex' && !hasOursCodex)
286
320
  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
321
  const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
288
- return { argv: [], env: codexAcpEnvironment(role, dirs), command };
322
+ return {
323
+ argv: [],
324
+ // OURS_BIND_IDENTITY is the connector's startup bind seed — see the note in
325
+ // claude-code.ts's prepareSession. It belongs on EVERY harness that runs a
326
+ // role with an ours identity, not just claude-code: a seed that works on one
327
+ // harness and silently does nothing on the other is the same class of defect
328
+ // as a config key that only works on one session type.
329
+ env: { OURS_BIND_IDENTITY: role.identity, ...codexAcpEnvironment(role, dirs) },
330
+ command,
331
+ };
289
332
  },
290
333
  buildLaunch(role, mode, _s, prep) {
291
334
  const stateDir = roleStateDir(role);
@@ -299,17 +342,30 @@ export function makeCodexAdapter(exec = realExec) {
299
342
  },
300
343
  buildAcpLaunch(role, prep) {
301
344
  const configured = role.session_options?.acp?.command;
345
+ // Resolve once: both argv and permission-metadata provenance must describe
346
+ // the same artifact. A bare PATH fallback is launchable for compatibility,
347
+ // but is never authenticated for protected-MCP auto-approval.
348
+ const resolved = configured == null
349
+ ? codexAcpLaunchForResolution(bundledCodexAcp())
350
+ : undefined;
302
351
  const argv = Array.isArray(configured)
303
352
  ? [...configured]
304
353
  : typeof configured === 'string'
305
354
  ? ['sh', '-c', configured]
306
- : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
355
+ : resolved.argv;
307
356
  const initialMode = acpAgentMode(role);
308
357
  return {
309
358
  argv,
310
359
  env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
360
+ ...(resolved?.permissionMetadataSource
361
+ ? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
311
362
  };
312
363
  },
364
+ // INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
365
+ // keeps resumed/loaded sessions and live status on the identical mode.
366
+ acpPermissionModeId(role) {
367
+ return acpAgentMode(role);
368
+ },
313
369
  isolationPaths(role, _dirs) {
314
370
  const codexHome = join(home(), '.codex');
315
371
  const profile = role.harness_options?.profile;
@@ -362,7 +418,9 @@ export function makeCodexAdapter(exec = realExec) {
362
418
  const mode = acpAgentMode(role) ?? 'agent';
363
419
  const configured = role.session_options?.acp?.command;
364
420
  const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
365
- const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
421
+ const actual = overrideAvailable
422
+ ? { approval, sandbox: acpRuntimeSandbox(role) }
423
+ : acpModePermissions(mode);
366
424
  const exact = actual.approval === approval && actual.sandbox === sandbox;
367
425
  return {
368
426
  ...translated,
@@ -372,10 +430,9 @@ export function makeCodexAdapter(exec = realExec) {
372
430
  ? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
373
431
  + `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
374
432
  + `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}`],
433
+ : `Codex ACP mode '${mode}' couples approval and filesystem as `
434
+ + `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
435
+ + `represent approval=${approval} sandbox=${sandbox}`],
379
436
  capabilities: codexCapabilities(actual.approval, actual.sandbox),
380
437
  };
381
438
  }
@@ -411,6 +468,8 @@ export function makeCodexAdapter(exec = realExec) {
411
468
  currentIdentityTool: 'current_identity',
412
469
  sendTool: 'send_message',
413
470
  getMessagesTool: 'get_messages',
471
+ listHistoryTool: 'list_history',
472
+ getHistoryItemTool: 'get_history_item',
414
473
  monitorInstruction: (id, configuredRole) => {
415
474
  const consented = configuredRole?.harness_options?.monitor === true;
416
475
  const consent = consented
@@ -1,3 +1,4 @@
1
+ import type { McpServer } from '@agentclientprotocol/sdk';
1
2
  import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
2
3
  export interface PrereqCheck {
3
4
  name: string;
@@ -21,14 +22,39 @@ export interface SessionPrep {
21
22
  env: Record<string, string>;
22
23
  /** Optional launcher selected after runtime prerequisite probing. */
23
24
  command?: string;
25
+ /**
26
+ * The settings overlay prepareSession wrote, if it wrote one.
27
+ *
28
+ * The tmux launch delivers this as `--settings <path>` in `argv`; an ACP agent
29
+ * takes no flags, so it needs the PATH rather than the flag. Recorded here so
30
+ * the two deliveries read one value instead of each re-deriving the filename.
31
+ */
32
+ settingsOverlay?: string;
33
+ /**
34
+ * The MCP config file prepareSession wrote for `harness_options.mcp_servers`,
35
+ * if the role declared any. Same reason as `settingsOverlay`: the tmux launch
36
+ * passes the file, the ACP launch has to send the servers themselves.
37
+ */
38
+ mcpConfigFile?: string;
24
39
  }
40
+ /**
41
+ * One MCP server as ACP's `session/new` declares it.
42
+ *
43
+ * ⚠ THE PROTOCOL'S OWN TYPE, DELIBERATELY NOT A LOCAL RESTATEMENT. `mcpServers`
44
+ * goes onto the wire unchanged, so a hand-written near-copy would compile while
45
+ * being subtly wrong — `env` and `headers` are REQUIRED arrays, and the stdio
46
+ * variant is the one with no `type` field at all. Aliasing it also keeps
47
+ * `session/new`'s response type inferable, which a structural stand-in silently
48
+ * broke (every field of the result degraded to `unknown`).
49
+ */
50
+ export type AcpMcpServer = McpServer;
25
51
  export interface Launch {
26
52
  argv: string[];
27
53
  env: Record<string, string>;
28
54
  }
29
- export interface AcpLaunch {
30
- argv: string[];
31
- env: Record<string, string>;
55
+ export interface AcpLaunch extends Launch {
56
+ /** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
57
+ permissionMetadataSource?: 'codex-acp';
32
58
  }
33
59
  /**
34
60
  * The result of expressing neutral `permissions:` in a harness's own terms.
@@ -67,6 +93,8 @@ export interface BriefingVocab {
67
93
  currentIdentityTool: string;
68
94
  sendTool: string;
69
95
  getMessagesTool: string;
96
+ listHistoryTool: string;
97
+ getHistoryItemTool: string;
70
98
  monitorInstruction(identity: string, role?: ResolvedRole): string;
71
99
  /** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
72
100
  supervisedWakeNote(identity: string, role?: ResolvedRole): string;
@@ -74,7 +102,7 @@ export interface BriefingVocab {
74
102
  restartPrompt(identity: string, worklogPath: string, role?: ResolvedRole): string;
75
103
  }
76
104
  /**
77
- * How a harness's host state splits for sandboxing (5.1). `home` is the
105
+ * How a harness's host state splits for sandboxing. `home` is the
78
106
  * directory the CLI treats as its own and whose RUNTIME state must be per-role;
79
107
  * `shared` are the credential, instruction and configuration paths that stay
80
108
  * shared and become read-only inside the sandbox.
@@ -95,7 +123,13 @@ export interface HarnessAdapter {
95
123
  id: string;
96
124
  supportsResume: boolean;
97
125
  checkPrereqs(): Promise<PrereqReport>;
98
- validateOptions(opts: unknown): ValidationError[];
126
+ /**
127
+ * `role` is the SESSION-AWARE half: some harness options can only be honoured
128
+ * on some session types, and an option that is silently dropped is worse than
129
+ * one that is refused. Optional so an adapter that has nothing session-specific
130
+ * to say keeps its one-argument implementation.
131
+ */
132
+ validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
99
133
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
100
134
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
101
135
  buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
@@ -105,6 +139,23 @@ export interface HarnessAdapter {
105
139
  * agent's default. Omit for a harness whose ACP agent has no modes.
106
140
  */
107
141
  acpPermissionModeId?(role: ResolvedRole): string | undefined;
142
+ /**
143
+ * The MCP servers this role declares, for the `mcpServers` array of ACP's
144
+ * `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
145
+ * configuration alone, which is what fleet has always sent.
146
+ */
147
+ acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
148
+ /**
149
+ * Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
150
+ * a flag reaches an ACP agent that accepts no flags.
151
+ *
152
+ * ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
153
+ * so what an adapter puts here is only honoured by the agent it was written
154
+ * for. An adapter must therefore return nothing for an ACP command it did not
155
+ * choose, and the options that depend on it must be refused at validation for
156
+ * such a role rather than sent and silently ignored.
157
+ */
158
+ acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
108
159
  /** Effective portable policy and harness-native approval mode after native overrides win. */
109
160
  effectivePermissionMode?(role: ResolvedRole): {
110
161
  fleetMode: FleetPermissionMode;
@@ -127,7 +178,7 @@ export interface HarnessAdapter {
127
178
  nativePermissionOverrides(options: unknown): Record<string, unknown>;
128
179
  /**
129
180
  * Host paths this harness needs inside a sandbox, split into a per-role
130
- * writable home and shared read-only credentials/config (5.1). Omit for a
181
+ * writable home and shared read-only credentials/config. Omit for a
131
182
  * harness with no host state of its own.
132
183
  */
133
184
  isolationPaths?(role: ResolvedRole, dirs: RoleDirs): HarnessIsolationPaths;
@@ -1,10 +1,10 @@
1
1
  import { type Exec } from '../exec.js';
2
2
  import type { IsolationBackend, NetworkMode } from './types.js';
3
3
  /**
4
- * Phase 2 network policy: only `deny` unshares the network namespace. `broker`
5
- * keeps the host network so ours messaging (a loopback TCP daemon on this host —
6
- * see the Phase-0 spike) keeps working; hardening `broker` to `--unshare-net` +
7
- * a loopback forwarder is Phase 4. `allow`/`allowlist` also keep host net.
4
+ * Only `deny` unshares the network namespace. `broker`
5
+ * keeps the host network so ours messaging through a loopback TCP daemon keeps
6
+ * working; hardening `broker` to `--unshare-net` plus
7
+ * a loopback forwarder is not implemented. `allow`/`allowlist` also keep host net.
8
8
  */
9
9
  export declare function unsharesNet(network: NetworkMode): boolean;
10
10
  export declare function makeBubblewrapBackend(exec?: Exec): IsolationBackend;
@@ -1,9 +1,9 @@
1
1
  import { realExec } from '../exec.js';
2
2
  /**
3
- * Phase 2 network policy: only `deny` unshares the network namespace. `broker`
4
- * keeps the host network so ours messaging (a loopback TCP daemon on this host —
5
- * see the Phase-0 spike) keeps working; hardening `broker` to `--unshare-net` +
6
- * a loopback forwarder is Phase 4. `allow`/`allowlist` also keep host net.
3
+ * Only `deny` unshares the network namespace. `broker`
4
+ * keeps the host network so ours messaging through a loopback TCP daemon keeps
5
+ * working; hardening `broker` to `--unshare-net` plus
6
+ * a loopback forwarder is not implemented. `allow`/`allowlist` also keep host net.
7
7
  */
8
8
  export function unsharesNet(network) {
9
9
  return network === 'deny';