@ours.network/fleet 0.10.0-nightly.3 → 0.10.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 (53) hide show
  1. package/README.md +138 -21
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +43 -13
  6. package/dist/cli.js +98 -22
  7. package/dist/config.d.ts +24 -3
  8. package/dist/config.js +84 -11
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +28 -1
  12. package/dist/docs.js +155 -8
  13. package/dist/doctor.js +75 -17
  14. package/dist/harness/claude-code.d.ts +39 -3
  15. package/dist/harness/claude-code.js +128 -26
  16. package/dist/harness/codex.d.ts +7 -1
  17. package/dist/harness/codex.js +58 -11
  18. package/dist/harness/registry.d.ts +2 -0
  19. package/dist/harness/registry.js +19 -0
  20. package/dist/harness/types.d.ts +51 -4
  21. package/dist/isolation/bubblewrap.js +7 -1
  22. package/dist/isolation/policy.d.ts +34 -5
  23. package/dist/isolation/policy.js +114 -7
  24. package/dist/isolation/resources.d.ts +6 -3
  25. package/dist/isolation/resources.js +6 -3
  26. package/dist/isolation/types.d.ts +19 -1
  27. package/dist/monitor.d.ts +33 -4
  28. package/dist/monitor.js +150 -32
  29. package/dist/ops.d.ts +15 -2
  30. package/dist/ops.js +32 -9
  31. package/dist/permissions.d.ts +70 -0
  32. package/dist/permissions.js +97 -0
  33. package/dist/runner.d.ts +65 -2
  34. package/dist/runner.js +262 -27
  35. package/dist/session/acp.d.ts +25 -2
  36. package/dist/session/acp.js +143 -26
  37. package/dist/session/control.d.ts +49 -1
  38. package/dist/session/control.js +116 -12
  39. package/dist/session/tmux.d.ts +9 -2
  40. package/dist/session/tmux.js +36 -4
  41. package/dist/session/types.d.ts +99 -2
  42. package/dist/session/types.js +42 -1
  43. package/dist/spawn.d.ts +27 -2
  44. package/dist/spawn.js +153 -15
  45. package/dist/supervisor/launchd.d.ts +50 -0
  46. package/dist/supervisor/launchd.js +121 -4
  47. package/dist/supervisor/none.js +22 -4
  48. package/dist/supervisor/systemd.d.ts +8 -1
  49. package/dist/supervisor/systemd.js +94 -4
  50. package/dist/supervisor/types.d.ts +36 -3
  51. package/dist/tmux.d.ts +34 -2
  52. package/dist/tmux.js +48 -11
  53. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -8,14 +8,17 @@ import { Command } from 'commander';
8
8
  import { VERSION } from './version.js';
9
9
  import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
10
10
  import { loadConfig } from './config.js';
11
- import { Tmux } from './tmux.js';
11
+ import { Tmux, tmuxArgs } from './tmux.js';
12
12
  import { pickBackend } from './supervisor/index.js';
13
13
  import { up, down, restartRoles, rmRole } from './ops.js';
14
- import { runOnce, runTemp } from './runner.js';
15
- import { spawnPermanent, spawnTemp } from './spawn.js';
14
+ import { readRestartLedger, runSupervised, runTemp } from './runner.js';
15
+ import { lastProvenance, spawnPermanent, spawnTemp } from './spawn.js';
16
+ import { formatProvenance } from './creation.js';
16
17
  import { doctor } from './doctor.js';
18
+ import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
17
19
  import { AI_DOCS } from './docs.js';
18
- import { controlRequest, controlSocketPath, followControl, } from './session/control.js';
20
+ import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
21
+ import { SessionControlError } from './session/types.js';
19
22
  import './harness/claude-code.js'; // registers the claude-code adapter
20
23
  import './harness/codex.js'; // registers the codex adapter
21
24
  // sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
@@ -68,6 +71,17 @@ function renderSessionEvent(event) {
68
71
  console.log(`\n[${event.kind}] ${event.title ?? event.toolCallId ?? ''} ${event.status ?? ''}`.trimEnd());
69
72
  break;
70
73
  case 'permission':
74
+ if (event.status === 'completed') {
75
+ // A settled request. Automatic decisions are the ones nobody saw happen,
76
+ // so peek/attach must show what was decided and which policy decided it.
77
+ console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`.trimEnd());
78
+ console.log(` ${event.decisionSource ?? 'manual'} decision: ${event.decision ?? 'unknown'}`
79
+ + `${event.optionId ? ` (${event.optionId})` : ''}`
80
+ + `${event.policy ? ` via ${event.policy}` : ''}`);
81
+ if (event.reason)
82
+ console.log(` reason: ${event.reason}`);
83
+ break;
84
+ }
71
85
  console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`);
72
86
  for (const option of event.options ?? [])
73
87
  console.log(` ${option.optionId}: ${option.name} (${option.kind})`);
@@ -106,11 +120,20 @@ cOpt(program.command('config').description('validate + print the merged plan (no
106
120
  try {
107
121
  const cfg = loadConfig(opts.configuration);
108
122
  console.log(`config: ${cfg.files.join(' + ') || '(none)'}`);
123
+ const analyses = analyzeFleetPermissions(cfg.roles);
109
124
  for (const r of cfg.roles) {
125
+ const perms = analyses.find(a => a.role === r.name);
110
126
  console.log(`\n● ${r.name}`);
111
127
  console.log(` harness: ${r.harness}`);
112
128
  console.log(` session: ${r.session}`);
129
+ console.log(` monitor: ${r.monitor.mode}`
130
+ + (r.monitor.mode === 'fleet' ? ` (interrupt=${r.monitor.interrupt})` : ''));
113
131
  console.log(` identity: ${r.identity}`);
132
+ console.log(` permissions: approval=${r.permissions.approval} `
133
+ + `filesystem=${r.permissions.filesystem} unattended=${r.permissions.unattended}`);
134
+ if (perms?.supported)
135
+ console.log(` native: ${formatNative(perms.native)}`
136
+ + `${perms.exact ? '' : ' (not an exact representation)'}`);
114
137
  console.log(` source: ${r.sourceFile}`);
115
138
  if (r.cwd)
116
139
  console.log(` cwd: ${r.cwd}`);
@@ -134,6 +157,8 @@ cOpt(program.command('config').description('validate + print the merged plan (no
134
157
  console.log(` isolation: backend=${iso.backend ?? 'auto'} net=${iso.network ?? 'broker'} `
135
158
  + `on_unavailable=${iso.on_unavailable ?? 'warn'} caps=${caps}`);
136
159
  }
160
+ for (const w of perms ? allWarnings(perms) : [])
161
+ console.log(` warning: ${w}`);
137
162
  }
138
163
  }
139
164
  catch (e) {
@@ -178,12 +203,15 @@ cOpt(program.command('force-restart [names...]').description('re-sync + bounce F
178
203
  });
179
204
  program.command('ls').description('list running fleet sessions')
180
205
  .action(async () => {
181
- const tmux = await new Tmux().list();
206
+ // Each session has its own tmux server (#32), so there is no single server
207
+ // to ask: the known role names ARE the list of servers to poll.
208
+ const names = [];
182
209
  const acp = [];
183
210
  for (const root of [agentsRoot(), tmpRoot()]) {
184
211
  if (!existsSync(root))
185
212
  continue;
186
213
  for (const name of readdirSync(root)) {
214
+ names.push(name);
187
215
  const stateDir = joinPath(root, name);
188
216
  if (!existsSync(controlSocketPath(stateDir)))
189
217
  continue;
@@ -195,13 +223,14 @@ program.command('ls').description('list running fleet sessions')
195
223
  catch { /* ignore stale sockets */ }
196
224
  }
197
225
  }
226
+ const tmux = await new Tmux().list(names);
198
227
  console.log([tmux, ...acp].filter(Boolean).join('\n') || '(none)');
199
228
  });
200
229
  program.command('attach <name>').description('open the live console (Ctrl-b d to leave)')
201
230
  .action(async (name) => {
202
231
  const stateDir = acpStateDir(name);
203
232
  if (!stateDir)
204
- process.exit(await passthrough('tmux', ['attach', '-t', name]));
233
+ process.exit(await passthrough('tmux', tmuxArgs(name, ['attach', '-t', name])));
205
234
  try {
206
235
  const { socket, send } = await followControl(stateDir, message => {
207
236
  if ('event' in message)
@@ -238,12 +267,30 @@ program.command('attach <name>').description('open the live console (Ctrl-b d to
238
267
  die(e);
239
268
  }
240
269
  });
270
+ /** Classify a raw tmux failure: only "no such session" proves the pane is gone. */
271
+ const asControlError = (e) => {
272
+ if (e instanceof SessionControlError)
273
+ return e;
274
+ const message = e instanceof Error ? e.message : String(e);
275
+ return new SessionControlError(/can't find session|no server running|session not found/i.test(message) ? 'offline' : 'backend', message);
276
+ };
277
+ /**
278
+ * Report what actually went wrong, then say what it proves about the agent.
279
+ * The old handler replaced every failure — timeouts, socket errors, refusals —
280
+ * with "is not running", which is how an overseer came to restart busy agents.
281
+ */
282
+ const controlFailure = (name, action, e, extra = '') => {
283
+ const err = asControlError(e);
284
+ return `${action} ${name}: ${err.message}\n ${livenessNote(err.kind, name)}${extra}`;
285
+ };
241
286
  program.command('peek <name> [lines]').description('pane snapshot without attaching')
242
287
  .action(async (name, lines) => {
243
288
  try {
244
289
  const stateDir = acpStateDir(name);
245
290
  if (stateDir) {
246
291
  const response = await controlRequest(stateDir, { command: 'follow', since: 0 });
292
+ if (!response.ok)
293
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'peek failed');
247
294
  const events = response.result?.events ?? [];
248
295
  for (const event of events.slice(-(lines ? Number(lines) : 40)))
249
296
  renderSessionEvent(event);
@@ -252,33 +299,40 @@ program.command('peek <name> [lines]').description('pane snapshot without attach
252
299
  console.log(await new Tmux().capture(name, lines ? Number(lines) : 40));
253
300
  }
254
301
  }
255
- catch {
256
- die(`'${name}' is not running; try: ours-fleet status ${name}`);
302
+ catch (e) {
303
+ die(controlFailure(name, 'peek', e));
257
304
  }
258
305
  });
259
306
  program.command('send <name> [text...]').description("type into the agent's console")
260
307
  .option('--key <key>', 'send a raw key instead (Escape, Up, C-c, ...)')
261
308
  .action(async (name, text, opts) => {
309
+ const stateDir = acpStateDir(name);
310
+ if (stateDir && opts.key)
311
+ die('--key is available only for tmux sessions');
312
+ if (!stateDir && !opts.key && !text?.length)
313
+ die('nothing to send: give text or --key');
314
+ if (stateDir && !text?.length)
315
+ die('nothing to send: give text');
262
316
  try {
263
- const stateDir = acpStateDir(name);
264
317
  if (stateDir) {
265
- if (opts.key)
266
- die('--key is available only for tmux sessions');
267
- if (!text?.length)
268
- die('nothing to send: give text');
318
+ // Returns on queue acceptance: a turn already running is not a failure.
269
319
  const response = await controlRequest(stateDir, { command: 'submit_prompt', text: text.join(' ') });
270
320
  if (!response.ok)
271
- throw new Error(response.error ?? 'prompt rejected');
321
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'prompt rejected');
322
+ const queued = response.result;
323
+ console.log(queued?.queuedBehind
324
+ ? `queued for ${name} behind ${queued.queuedBehind} running turn(s)`
325
+ : `queued for ${name}`);
272
326
  }
273
327
  else if (opts.key)
274
328
  await new Tmux().sendKey(name, opts.key);
275
- else if (text?.length)
276
- await new Tmux().sendText(name, text.join(' '));
277
329
  else
278
- die('nothing to send: give text or --key');
330
+ await new Tmux().sendText(name, text.join(' '));
279
331
  }
280
- catch {
281
- die(`'${name}' is not running; try: ours-fleet status ${name}`);
332
+ catch (e) {
333
+ die(controlFailure(name, 'send', e, asControlError(e).kind === 'timeout'
334
+ ? '\n The prompt may already have been delivered — do not assume it was lost.'
335
+ : ''));
282
336
  }
283
337
  });
284
338
  program.command('logs <name>').description('show the role log').option('-f, --follow', 'follow')
@@ -289,6 +343,16 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
289
343
  program.command('status <name>').description('unit/agent state')
290
344
  .action(async (name) => {
291
345
  console.log(await pickBackend().status(name));
346
+ // A held-down role looks like a healthy running unit from the outside — the
347
+ // runner is alive on purpose. Say so, with the reason and when (3.2).
348
+ const ledger = readRestartLedger(agentDir(name));
349
+ if (ledger.circuit === 'open')
350
+ console.log(`HELD DOWN since ${ledger.openedAt ?? ledger.updatedAt} after `
351
+ + `${ledger.consecutiveImmediateFailures} immediate failures: ${ledger.lastReason}`
352
+ + `\n release it with: ours-fleet restart ${name}`);
353
+ else if (ledger.consecutiveImmediateFailures > 0)
354
+ console.log(`restarts: ${ledger.consecutiveImmediateFailures} consecutive immediate `
355
+ + `failures, next delay ${ledger.nextDelayMs}ms (${ledger.lastReason})`);
292
356
  const stateDir = acpStateDir(name);
293
357
  if (stateDir) {
294
358
  try {
@@ -329,9 +393,10 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
329
393
  .option('--search', 'enable Codex live web search')
330
394
  .option('--codex-config <key=value>', 'Codex config override (repeatable)', collect, [])
331
395
  .option('--add-dir <dir>', 'additional Codex writable directory (repeatable)', collect, [])
332
- .option('--monitor', 'explicitly consent to arm this Codex role\'s ours mail monitor')
396
+ .option('--monitor', 'legacy: consent to arm Codex\'s native monitor (wake owner is monitor.mode in YAML)')
333
397
  .option('--bio-file <file>', 'public bio (file)')
334
398
  .option('--persona-file <file>', 'persona / operating contract (file)')
399
+ .option('--isolation-file <path>', 'file holding an isolation: mapping (same schema as fleet.yaml)')
335
400
  .action(async (name, opts) => {
336
401
  try {
337
402
  const o = {
@@ -343,7 +408,8 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
343
408
  sandbox: opts.sandbox, profile: opts.profile,
344
409
  launcher: opts.launcher, search: opts.search,
345
410
  codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
346
- bioFile: opts.bioFile, personaFile: opts.personaFile, configPath: opts.configuration,
411
+ bioFile: opts.bioFile, personaFile: opts.personaFile,
412
+ isolationFile: opts.isolationFile, configPath: opts.configuration,
347
413
  };
348
414
  if (o.temp) {
349
415
  const dir = await spawnTemp(o, binPath);
@@ -353,6 +419,14 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
353
419
  const file = await spawnPermanent(o, deps());
354
420
  console.log(`spawned '${name}' (config: ${file})`);
355
421
  }
422
+ // The same provenance that was persisted, so what the operator reads now
423
+ // and what a reviewer reads later cannot disagree (6.6).
424
+ if (lastProvenance) {
425
+ console.log(` created by ${lastProvenance.command} v${lastProvenance.fleetVersion} `
426
+ + `at ${lastProvenance.createdAt} (${lastProvenance.lifetime})`);
427
+ for (const line of formatProvenance(lastProvenance))
428
+ console.log(line);
429
+ }
356
430
  console.log(`→ watch it: ours-fleet peek ${name} | attach: ours-fleet attach ${name}`);
357
431
  }
358
432
  catch (e) {
@@ -378,8 +452,10 @@ program.command('init').description('one-time host setup (units, dirs, linger)')
378
452
  program.command('_run <name>', { hidden: true }).description('internal: supervisor entrypoint')
379
453
  .option('-c, --configuration <file>')
380
454
  .action(async (name, opts) => {
455
+ // The supervised loop, not a single session: restart policy lives here now,
456
+ // where it can count across attempts (3.2).
381
457
  try {
382
- await runOnce(name, { configPath: opts.configuration });
458
+ await runSupervised(name, { configPath: opts.configuration });
383
459
  }
384
460
  catch (e) {
385
461
  die(e);
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { IsolationConfig } from './isolation/types.js';
1
+ import type { IsolationConfig, WrapContext } from './isolation/types.js';
2
2
  export interface OverseeEntry {
3
3
  role: string;
4
4
  interval: string;
@@ -7,6 +7,7 @@ export interface OverseeEntry {
7
7
  export declare const NOTIFY_EVENT_TYPES: readonly ["message_received", "file_received", "sibling_contact_added", "local_contact_request", "pending_message", "contact_restored", "inbound_error", "state_import_failed"];
8
8
  export type NotifyEventType = (typeof NOTIFY_EVENT_TYPES)[number];
9
9
  export type InjectMode = 'notification' | 'full';
10
+ export type MonitorMode = 'fleet' | 'native';
10
11
  export type SessionBackendId = 'tmux' | 'acp';
11
12
  export type ApprovalMode = 'ask' | 'allow' | 'deny';
12
13
  export type FilesystemMode = 'read-only' | 'workspace' | 'unrestricted';
@@ -27,10 +28,15 @@ export interface SessionOptions {
27
28
  }
28
29
  /** Resolved per-role supervisor-monitor config (see DESIGN-external-monitor §2). */
29
30
  export interface MonitorConfig {
31
+ /** Who owns mail wake delivery: ours-fleet supervisor or the native harness. */
32
+ mode: MonitorMode;
33
+ /** @deprecated Legacy alias retained in resolved snapshots; use mode. */
30
34
  enabled: boolean;
31
35
  wake_sources: string[];
32
36
  batch_ms: number;
33
37
  inject: InjectMode;
38
+ /** Cancel the active agent turn before delivering every configured wake. */
39
+ interrupt: boolean;
34
40
  /**
35
41
  * Consecutive delivered wakes that must end in an `API Error:`-terminated turn
36
42
  * (with no completed turn in between) before `.monitor-status` degrades to
@@ -69,6 +75,13 @@ export interface ResolvedRole extends RoleConfig {
69
75
  harness: string;
70
76
  session: SessionBackendId;
71
77
  permissions: CommonPermissions;
78
+ /**
79
+ * Whether `permissions:` was actually written by the operator (on the role or
80
+ * in defaults), as opposed to resolved from built-in defaults. A role that
81
+ * states its intent only once — neutrally OR natively — has nothing to
82
+ * contradict, and must not be warned at (2.4).
83
+ */
84
+ permissionsDeclared: boolean;
72
85
  identity: string;
73
86
  sourceFile: string;
74
87
  monitor: MonitorConfig;
@@ -83,13 +96,21 @@ export interface FleetConfig {
83
96
  }
84
97
  export declare class ConfigError extends Error {
85
98
  }
99
+ /**
100
+ * The runtime facts the isolation resolver needs for a role. Single-sourced so
101
+ * config validation, doctor, and the runner all judge the SAME mount set — a
102
+ * policy checked against a different context than the one that launches is not
103
+ * a check at all.
104
+ */
105
+ export declare function isolationContextFor(role: ResolvedRole): WrapContext;
86
106
  /** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
87
107
  export declare function loadConfig(configPath?: string): FleetConfig;
88
108
  export declare function resolvePermissions(defaults: unknown, role: Partial<CommonPermissions> | undefined, file?: string, name?: string): CommonPermissions;
89
109
  /**
90
110
  * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
91
- * result, and fill code-constant defaults (design §2). `defaults.monitor.enabled`
92
- * is the fleet-wide default; absent everywhere ⇒ enabled. Throws ConfigError on a
111
+ * result, and fill code-constant defaults (design §2). `monitor.mode` selects
112
+ * fleet-owned or native-harness monitoring; absent everywhere ⇒ fleet. The old
113
+ * `enabled` boolean remains a compatibility alias. Throws ConfigError on a
93
114
  * malformed block so a typo fails loudly rather than silently disarming a monitor.
94
115
  * Exported so temp-spawn (which builds a ResolvedRole by hand) resolves identically.
95
116
  */
package/dist/config.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parse } from 'yaml';
4
- import { defaultConfigPath, fleetDDir } from './paths.js';
5
- import { validateIsolationConfig } from './isolation/policy.js';
4
+ import { agentDir, defaultConfigPath, fleetDDir, home } from './paths.js';
5
+ import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
6
+ import { getAdapter } from './harness/registry.js';
6
7
  /** The 8 content-free event types the ours daemon appends to notifications.log. */
7
8
  export const NOTIFY_EVENT_TYPES = [
8
9
  'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
@@ -10,8 +11,11 @@ export const NOTIFY_EVENT_TYPES = [
10
11
  ];
11
12
  /** Default wake sources when a role does not list its own (design §2). */
12
13
  export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
13
- const MONITOR_KEYS = ['enabled', 'wake_sources', 'batch_ms', 'inject', 'turn_fail_threshold'];
14
+ const MONITOR_KEYS = [
15
+ 'mode', 'enabled', 'wake_sources', 'batch_ms', 'inject', 'interrupt', 'turn_fail_threshold',
16
+ ];
14
17
  const INJECT_MODES = ['notification', 'full'];
18
+ const MONITOR_MODES = ['fleet', 'native'];
15
19
  const MONITOR_DEFAULT_BATCH_MS = 2000;
16
20
  const MONITOR_DEFAULT_TURN_FAIL_THRESHOLD = 3;
17
21
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -26,11 +30,18 @@ export function validateMonitorConfig(raw) {
26
30
  problems.push(`monitor: unknown key(s) ${bad.join(', ')}; allowed: ${MONITOR_KEYS.join(', ')}`);
27
31
  if (m.enabled !== undefined && typeof m.enabled !== 'boolean')
28
32
  problems.push('monitor.enabled: must be true or false');
33
+ if (m.mode !== undefined && !MONITOR_MODES.includes(m.mode))
34
+ problems.push(`monitor.mode: invalid value '${m.mode}'; allowed: ${MONITOR_MODES.join(', ')}`);
35
+ if (m.mode !== undefined && typeof m.enabled === 'boolean'
36
+ && m.enabled !== (m.mode === 'fleet'))
37
+ problems.push(`monitor.mode '${m.mode}' conflicts with legacy monitor.enabled ${m.enabled}`);
29
38
  if (m.batch_ms !== undefined
30
39
  && (typeof m.batch_ms !== 'number' || !Number.isFinite(m.batch_ms) || m.batch_ms < 0))
31
40
  problems.push('monitor.batch_ms: must be a non-negative number');
32
41
  if (m.inject !== undefined && !INJECT_MODES.includes(m.inject))
33
42
  problems.push(`monitor.inject: invalid value '${m.inject}'; allowed: ${INJECT_MODES.join(', ')}`);
43
+ if (m.interrupt !== undefined && typeof m.interrupt !== 'boolean')
44
+ problems.push('monitor.interrupt: must be true or false');
34
45
  if (m.turn_fail_threshold !== undefined
35
46
  && (typeof m.turn_fail_threshold !== 'number' || !Number.isInteger(m.turn_fail_threshold)
36
47
  || m.turn_fail_threshold < 1))
@@ -49,6 +60,37 @@ export function validateMonitorConfig(raw) {
49
60
  }
50
61
  export class ConfigError extends Error {
51
62
  }
63
+ /**
64
+ * The runtime facts the isolation resolver needs for a role. Single-sourced so
65
+ * config validation, doctor, and the runner all judge the SAME mount set — a
66
+ * policy checked against a different context than the one that launches is not
67
+ * a check at all.
68
+ */
69
+ export function isolationContextFor(role) {
70
+ const stateDir = agentDir(role.name, role.__temp === true);
71
+ const runCwd = role.cwd ?? stateDir;
72
+ // Ask the harness how its host state splits (5.1). An adapter that declares
73
+ // none keeps the historical whole-home mount.
74
+ let split;
75
+ try {
76
+ split = getAdapter(role.harness).isolationPaths?.(role, { stateDir, runCwd });
77
+ }
78
+ catch {
79
+ split = undefined;
80
+ }
81
+ return {
82
+ stateDir,
83
+ runCwd,
84
+ home: home(),
85
+ harness: role.harness,
86
+ additionalWriteDirs: role.harness === 'codex'
87
+ ? (role.harness_options?.add_dirs ?? [])
88
+ : [],
89
+ harnessHome: split?.home,
90
+ harnessRuntimeDir: split?.home ? harnessRuntimeDir(stateDir, role.harness) : undefined,
91
+ harnessSharedPaths: split?.shared,
92
+ };
93
+ }
52
94
  const NAME_RE = /^[A-Za-z0-9_-]+$/;
53
95
  const ROLE_KEYS = [
54
96
  'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
@@ -110,6 +152,7 @@ export function loadConfig(configPath) {
110
152
  const session = resolveSession(r.session ?? defaults.session, file, name);
111
153
  const sessionOptions = resolveSessionOptions(defaults.session_options, r.session_options, session, file, name);
112
154
  const permissions = resolvePermissions(defaults.permissions, r.permissions, file, name);
155
+ const permissionsDeclared = r.permissions !== undefined || defaults.permissions !== undefined;
113
156
  const defaultHarnessOptions = defaults.harness_options;
114
157
  if (defaultHarnessOptions !== undefined
115
158
  && (typeof defaultHarnessOptions !== 'object' || defaultHarnessOptions === null
@@ -135,6 +178,7 @@ export function loadConfig(configPath) {
135
178
  session,
136
179
  session_options: sessionOptions,
137
180
  permissions,
181
+ permissionsDeclared,
138
182
  identity: r.identity ?? name,
139
183
  model: r.model ?? defaults.model,
140
184
  max_tokens: r.max_tokens ?? defaults.max_tokens,
@@ -142,6 +186,17 @@ export function loadConfig(configPath) {
142
186
  isolation,
143
187
  monitor,
144
188
  });
189
+ // Forbidden-path enforcement (5.2): a mount that would breach the policy
190
+ // is a configuration error, caught by `config` rather than at launch.
191
+ if (isolation !== undefined) {
192
+ const role = roles[roles.length - 1];
193
+ try {
194
+ resolveIsolation(isolation, isolationContextFor(role));
195
+ }
196
+ catch (e) {
197
+ throw new ConfigError(`${file}: role '${name}' ${e.message}`);
198
+ }
199
+ }
145
200
  }
146
201
  }
147
202
  return { roles, vars, defaults, files, startStaggerMs };
@@ -228,8 +283,9 @@ function resolveStartStaggerMs(raw, base) {
228
283
  }
229
284
  /**
230
285
  * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
231
- * result, and fill code-constant defaults (design §2). `defaults.monitor.enabled`
232
- * is the fleet-wide default; absent everywhere ⇒ enabled. Throws ConfigError on a
286
+ * result, and fill code-constant defaults (design §2). `monitor.mode` selects
287
+ * fleet-owned or native-harness monitoring; absent everywhere ⇒ fleet. The old
288
+ * `enabled` boolean remains a compatibility alias. Throws ConfigError on a
233
289
  * malformed block so a typo fails loudly rather than silently disarming a monitor.
234
290
  * Exported so temp-spawn (which builds a ResolvedRole by hand) resolves identically.
235
291
  */
@@ -239,18 +295,35 @@ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
239
295
  throw new ConfigError(`${labels.base ?? 'config'}: defaults.monitor must be a map`);
240
296
  if (roleMonitor !== undefined && !isPlainObject(roleMonitor))
241
297
  throw new ConfigError(`${where}monitor: must be a mapping`);
298
+ const def = (defMonitor ?? {});
299
+ const own = (roleMonitor ?? {});
300
+ const defProblems = validateMonitorConfig(def);
301
+ if (defProblems.length)
302
+ throw new ConfigError(`${labels.base ?? 'config'}: defaults.${defProblems.join('; defaults.')}`);
303
+ const ownProblems = validateMonitorConfig(own);
304
+ if (ownProblems.length)
305
+ throw new ConfigError(`${where}${ownProblems.join('; ')}`);
242
306
  const merged = {
243
- ...(defMonitor ?? {}),
244
- ...(roleMonitor ?? {}),
307
+ ...def,
308
+ ...own,
245
309
  };
246
- const problems = validateMonitorConfig(merged);
247
- if (problems.length)
248
- throw new ConfigError(`${where}${problems.join('; ')}`);
310
+ const selected = own.mode !== undefined
311
+ ? own.mode
312
+ : own.enabled !== undefined
313
+ ? (own.enabled ? 'fleet' : 'native')
314
+ : def.mode !== undefined
315
+ ? def.mode
316
+ : def.enabled !== undefined
317
+ ? (def.enabled ? 'fleet' : 'native')
318
+ : 'fleet';
319
+ const mode = selected;
249
320
  return {
250
- enabled: merged.enabled ?? true,
321
+ mode,
322
+ enabled: mode === 'fleet',
251
323
  wake_sources: merged.wake_sources ?? [...DEFAULT_WAKE_SOURCES],
252
324
  batch_ms: merged.batch_ms ?? MONITOR_DEFAULT_BATCH_MS,
253
325
  inject: merged.inject ?? 'notification',
326
+ interrupt: merged.interrupt ?? false,
254
327
  turn_fail_threshold: merged.turn_fail_threshold ?? MONITOR_DEFAULT_TURN_FAIL_THRESHOLD,
255
328
  };
256
329
  }
@@ -0,0 +1,179 @@
1
+ import { type LockDeps } from './atomic-file.js';
2
+ import { type FetchLike } from './monitor.js';
3
+ export type ReservationKind = 'role' | 'identity';
4
+ export interface Reservation {
5
+ kind: ReservationKind;
6
+ name: string;
7
+ }
8
+ export declare class CreationConflictError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /**
12
+ * Artifacts a transaction created, newest last. Rollback walks this in reverse,
13
+ * and may only delete what THIS transaction made — an object that already
14
+ * existed is never touched.
15
+ */
16
+ export interface JournalEntry {
17
+ stage: string;
18
+ undo(): void | Promise<void>;
19
+ }
20
+ export interface CreationDeps {
21
+ lock?: LockDeps;
22
+ log?(line: string): void;
23
+ /** Reserve the ours identity name. Injectable so tests need no daemon. */
24
+ identityRegistry?: IdentityRegistry;
25
+ /** Verify/create the ours identity. Injectable so tests need no daemon. */
26
+ identityProvisioner?: IdentityProvisioner;
27
+ }
28
+ /**
29
+ * The contract the ours daemon must satisfy for identity names to be reserved
30
+ * atomically across ALL of its clients, not just across fleet processes.
31
+ *
32
+ * `check-then-create` is not atomic across processes, which is the whole point:
33
+ * two spawns can both observe a free identity name and both create it. The
34
+ * daemon is the only component that sees every client, so only the daemon can
35
+ * make the reservation authoritative.
36
+ */
37
+ export interface IdentityRegistry {
38
+ /** Claim `name`. Returns false if it is already taken or reserved. */
39
+ reserve(name: string): Promise<boolean>;
40
+ /** Give the claim back (rollback). Must be safe to call on an unheld name. */
41
+ release(name: string): Promise<void>;
42
+ }
43
+ /**
44
+ * Host-local identity reservation: atomic across every ours-fleet process on
45
+ * this host, because it is taken under the same host-wide creation lock as the
46
+ * role name.
47
+ *
48
+ * It is NOT atomic against other clients of the same ours daemon — another tool
49
+ * creating the identity between our reservation and our creation would still
50
+ * win. Closing that needs a reserve/commit/release operation in the daemon
51
+ * itself; see the release notes.
52
+ */
53
+ export declare const hostLocalIdentityRegistry: IdentityRegistry;
54
+ export interface CreationTransaction {
55
+ /** Record an artifact this transaction created, with how to undo it. */
56
+ record(entry: JournalEntry): void;
57
+ /** Stages recorded so far, in order. */
58
+ readonly stages: string[];
59
+ }
60
+ /**
61
+ * Run `body` inside a creation transaction.
62
+ *
63
+ * Under one host-wide lock: both names are reserved, then `body` builds the
64
+ * role. If anything throws, every recorded stage is undone in reverse order and
65
+ * both reservations are released, so the names can be reused immediately. On
66
+ * success the reservations are released too — the role's own config and state
67
+ * are the durable record from then on.
68
+ *
69
+ * Rollback errors are collected and reported, never allowed to hide the failure
70
+ * that caused the rollback.
71
+ */
72
+ export declare function withCreationTransaction<T>(names: {
73
+ role: string;
74
+ identity: string;
75
+ }, body: (tx: CreationTransaction) => Promise<T>, deps?: CreationDeps): Promise<T>;
76
+ /** Forget reservations left behind by a process that died mid-transaction. */
77
+ export declare function clearStaleReservations(olderThanMs?: number, now?: number): number;
78
+ /**
79
+ * Identity provisioning (7.3). The fleet must know — before the harness starts
80
+ * — whether the role's identity exists, and create it when it does not.
81
+ *
82
+ * `exists()` is answerable today: the daemon's authenticated `/identities`
83
+ * endpoint is already used by doctor. `create()` is NOT: `ours-mcp` exposes only
84
+ * `create-root`, and role identities are minted through the MCP `create_identity`
85
+ * tool inside an agent session. So creation is a seam, injected by whoever can
86
+ * satisfy it, and its absence is reported rather than papered over.
87
+ */
88
+ export interface IdentityProvisioner {
89
+ /** Does this identity exist? `unknown` when the daemon could not be asked. */
90
+ exists(name: string): Promise<boolean | 'unknown'>;
91
+ /** Create it, publishing bio/persona through the same path. Absent = cannot. */
92
+ create?(name: string, profile: {
93
+ bio?: string;
94
+ persona?: string;
95
+ }): Promise<void>;
96
+ /**
97
+ * Undo a `create` during rollback. Only ever called for an identity THIS
98
+ * transaction created; absent means "cannot", and the orphan is reported.
99
+ */
100
+ remove?(name: string): Promise<void>;
101
+ }
102
+ export type IdentityGuarantee = {
103
+ state: 'verified';
104
+ detail: string;
105
+ } | {
106
+ state: 'created';
107
+ detail: string;
108
+ } | {
109
+ state: 'unverified';
110
+ detail: string;
111
+ };
112
+ /**
113
+ * Establish the identity before the role's service is enabled.
114
+ *
115
+ * Returns what was actually GUARANTEED, so the generated briefing can say
116
+ * something true instead of asserting a "predefined" identity nobody checked —
117
+ * the failure a real agent hit on its first boot, having been told to bind an
118
+ * identity that did not exist.
119
+ */
120
+ export declare function ensureIdentity(name: string, profile: {
121
+ bio?: string;
122
+ persona?: string;
123
+ }, provisioner: IdentityProvisioner | undefined, log?: (line: string) => void): Promise<IdentityGuarantee>;
124
+ /**
125
+ * Ask the running ours daemon whether an identity exists, over the same
126
+ * authenticated endpoint doctor already probes. Answers `unknown` rather than
127
+ * guessing when the daemon cannot be reached — an unreachable daemon is not
128
+ * evidence that the identity is missing.
129
+ *
130
+ * It deliberately has no `create()`: role identities are minted through the MCP
131
+ * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
132
+ * failure mode this release exists to stop.
133
+ */
134
+ export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, fetchImpl?: FetchLike): IdentityProvisioner;
135
+ /** Atomically write a role's fleet.d file, journalling it for rollback. */
136
+ export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
137
+ /** Where a setting's effective value came from. */
138
+ export type ProvenanceSource = 'cli' | 'fleet-default' | 'built-in';
139
+ export interface ProvenanceEntry {
140
+ value: unknown;
141
+ source: ProvenanceSource;
142
+ }
143
+ export interface CreationProvenance {
144
+ version: 1;
145
+ /** The command that created the role, without its arguments. */
146
+ command: string;
147
+ fleetVersion: string;
148
+ createdAt: string;
149
+ lifetime: 'permanent' | 'temporary';
150
+ role: string;
151
+ /** Effective settings, each tagged with where its value came from. */
152
+ settings: Record<string, ProvenanceEntry>;
153
+ }
154
+ export declare const CREATION_PROVENANCE_FILE = "creation.json";
155
+ /**
156
+ * Record HOW a role was created, so nobody has to remember.
157
+ *
158
+ * Six months on, "why does this role have `approval: allow`?" is unanswerable:
159
+ * the resolved config shows the value but not whether an operator typed it, a
160
+ * fleet default supplied it, or it fell through to a built-in. Those have very
161
+ * different implications for whether it is safe to change.
162
+ *
163
+ * Deliberately excluded: `env`, `bio`, `persona`, and `harness_options`. The
164
+ * first two can carry credentials, and this file exists to be read — it must
165
+ * never become a place secrets accumulate.
166
+ */
167
+ export declare function buildProvenance(o: {
168
+ role: string;
169
+ lifetime: 'permanent' | 'temporary';
170
+ fleetVersion: string;
171
+ now?: Date;
172
+ settings: Record<string, ProvenanceEntry>;
173
+ }): CreationProvenance;
174
+ /** Write the provenance record atomically, before the role is started. */
175
+ export declare function writeProvenance(stateDir: string, p: CreationProvenance): void;
176
+ /** One concise line per non-built-in setting, for the post-creation summary. */
177
+ export declare function formatProvenance(p: CreationProvenance): string[];
178
+ /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
179
+ export declare function provenanceOf(cliValue: unknown, fleetDefault: unknown, builtIn?: unknown): ProvenanceEntry;