@ours.network/fleet 0.9.4 → 0.9.7

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 (59) hide show
  1. package/README.md +148 -30
  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 +41 -11
  6. package/dist/cli.js +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
package/dist/cli.js CHANGED
@@ -1,17 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn as spawnChild } from 'node:child_process';
3
- import { mkdirSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readdirSync } from 'node:fs';
4
4
  import { realpathSync } from 'node:fs';
5
+ import { join as joinPath } from 'node:path';
6
+ import { createInterface } from 'node:readline';
5
7
  import { Command } from 'commander';
6
8
  import { VERSION } from './version.js';
7
- import { agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
9
+ import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
8
10
  import { loadConfig } from './config.js';
9
- import { Tmux } from './tmux.js';
11
+ import { Tmux, tmuxArgs } from './tmux.js';
10
12
  import { pickBackend } from './supervisor/index.js';
11
13
  import { up, down, restartRoles, rmRole } from './ops.js';
12
- import { runOnce, runTemp } from './runner.js';
13
- 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';
14
17
  import { doctor } from './doctor.js';
18
+ import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
19
+ import { AI_DOCS } from './docs.js';
20
+ import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
21
+ import { SessionControlError } from './session/types.js';
15
22
  import './harness/claude-code.js'; // registers the claude-code adapter
16
23
  import './harness/codex.js'; // registers the codex adapter
17
24
  // sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
@@ -36,10 +43,59 @@ const passthrough = (cmd, args) => new Promise(resolve => {
36
43
  });
37
44
  const program = new Command()
38
45
  .name('ours-fleet')
39
- .description('Fleet of persistent, identity-bound AI agents — harness-agnostic, tmux + systemd/launchd.')
46
+ .description('Fleet of persistent, identity-bound AI agents — selectable harness and tmux/ACP sessions.')
40
47
  .version(VERSION);
41
48
  const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
42
49
  const collect = (value, previous) => [...previous, value];
50
+ program.command('docs')
51
+ .alias('man')
52
+ .description('print the complete AI-friendly command and configuration reference')
53
+ .action(() => { process.stdout.write(AI_DOCS); });
54
+ function acpStateDir(name) {
55
+ const permanent = agentDir(name);
56
+ if (existsSync(controlSocketPath(permanent)))
57
+ return permanent;
58
+ const temp = agentDir(name, true);
59
+ if (existsSync(controlSocketPath(temp)))
60
+ return temp;
61
+ return undefined;
62
+ }
63
+ function renderSessionEvent(event) {
64
+ switch (event.kind) {
65
+ case 'agent_text':
66
+ process.stdout.write(event.text ?? '');
67
+ break;
68
+ case 'thought': break;
69
+ case 'tool_call':
70
+ case 'tool_update':
71
+ console.log(`\n[${event.kind}] ${event.title ?? event.toolCallId ?? ''} ${event.status ?? ''}`.trimEnd());
72
+ break;
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
+ }
85
+ console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`);
86
+ for (const option of event.options ?? [])
87
+ console.log(` ${option.optionId}: ${option.name} (${option.kind})`);
88
+ console.log(' respond: /permit <permission-id> <option-id>');
89
+ break;
90
+ case 'turn_stop':
91
+ console.log(`\n[turn stopped: ${event.stopReason ?? 'unknown'}]`);
92
+ break;
93
+ case 'error':
94
+ console.error(`\n[error] ${event.text ?? ''}`);
95
+ break;
96
+ case 'state': break;
97
+ }
98
+ }
43
99
  function parseCodexConfig(values) {
44
100
  if (!values?.length)
45
101
  return undefined;
@@ -64,10 +120,18 @@ cOpt(program.command('config').description('validate + print the merged plan (no
64
120
  try {
65
121
  const cfg = loadConfig(opts.configuration);
66
122
  console.log(`config: ${cfg.files.join(' + ') || '(none)'}`);
123
+ const analyses = analyzeFleetPermissions(cfg.roles);
67
124
  for (const r of cfg.roles) {
125
+ const perms = analyses.find(a => a.role === r.name);
68
126
  console.log(`\n● ${r.name}`);
69
127
  console.log(` harness: ${r.harness}`);
128
+ console.log(` session: ${r.session}`);
70
129
  console.log(` identity: ${r.identity}`);
130
+ console.log(` permissions: approval=${r.permissions.approval} `
131
+ + `filesystem=${r.permissions.filesystem} unattended=${r.permissions.unattended}`);
132
+ if (perms?.supported)
133
+ console.log(` native: ${formatNative(perms.native)}`
134
+ + `${perms.exact ? '' : ' (not an exact representation)'}`);
71
135
  console.log(` source: ${r.sourceFile}`);
72
136
  if (r.cwd)
73
137
  console.log(` cwd: ${r.cwd}`);
@@ -91,6 +155,8 @@ cOpt(program.command('config').description('validate + print the merged plan (no
91
155
  console.log(` isolation: backend=${iso.backend ?? 'auto'} net=${iso.network ?? 'broker'} `
92
156
  + `on_unavailable=${iso.on_unavailable ?? 'warn'} caps=${caps}`);
93
157
  }
158
+ for (const w of perms ? allWarnings(perms) : [])
159
+ console.log(` warning: ${w}`);
94
160
  }
95
161
  }
96
162
  catch (e) {
@@ -133,33 +199,138 @@ cOpt(program.command('force-restart [names...]').description('re-sync + bounce F
133
199
  die(e);
134
200
  }
135
201
  });
136
- program.command('ls').description('list running tmux consoles')
137
- .action(async () => { console.log(await new Tmux().list() || '(none)'); });
202
+ program.command('ls').description('list running fleet sessions')
203
+ .action(async () => {
204
+ // Each session has its own tmux server (#32), so there is no single server
205
+ // to ask: the known role names ARE the list of servers to poll.
206
+ const names = [];
207
+ const acp = [];
208
+ for (const root of [agentsRoot(), tmpRoot()]) {
209
+ if (!existsSync(root))
210
+ continue;
211
+ for (const name of readdirSync(root)) {
212
+ names.push(name);
213
+ const stateDir = joinPath(root, name);
214
+ if (!existsSync(controlSocketPath(stateDir)))
215
+ continue;
216
+ try {
217
+ const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
218
+ if (response.ok && response.result?.alive)
219
+ acp.push(`${name}: acp`);
220
+ }
221
+ catch { /* ignore stale sockets */ }
222
+ }
223
+ }
224
+ const tmux = await new Tmux().list(names);
225
+ console.log([tmux, ...acp].filter(Boolean).join('\n') || '(none)');
226
+ });
138
227
  program.command('attach <name>').description('open the live console (Ctrl-b d to leave)')
139
- .action(async (name) => process.exit(await passthrough('tmux', ['attach', '-t', name])));
228
+ .action(async (name) => {
229
+ const stateDir = acpStateDir(name);
230
+ if (!stateDir)
231
+ process.exit(await passthrough('tmux', tmuxArgs(name, ['attach', '-t', name])));
232
+ try {
233
+ const { socket, send } = await followControl(stateDir, message => {
234
+ if ('event' in message)
235
+ renderSessionEvent(message.event);
236
+ const result = message.result;
237
+ for (const event of result?.events ?? [])
238
+ renderSessionEvent(event);
239
+ if (message.ok === false)
240
+ console.error(`[control] ${String(message.error ?? 'request failed')}`);
241
+ });
242
+ console.log(`[attached to ${name} via ACP; type a prompt, /permit …, /interrupt, or /detach]`);
243
+ const input = createInterface({ input: process.stdin });
244
+ input.on('line', line => {
245
+ if (line === '/detach') {
246
+ input.close();
247
+ socket.end();
248
+ return;
249
+ }
250
+ if (line === '/interrupt') {
251
+ send({ command: 'interrupt' });
252
+ return;
253
+ }
254
+ const permit = line.match(/^\/permit\s+(\S+)\s+(\S+)$/);
255
+ if (permit) {
256
+ send({ command: 'respond_permission', permissionId: permit[1], optionId: permit[2] });
257
+ return;
258
+ }
259
+ if (line.trim())
260
+ send({ command: 'submit_prompt', text: line });
261
+ });
262
+ await new Promise(resolve => socket.once('close', resolve));
263
+ }
264
+ catch (e) {
265
+ die(e);
266
+ }
267
+ });
268
+ /** Classify a raw tmux failure: only "no such session" proves the pane is gone. */
269
+ const asControlError = (e) => {
270
+ if (e instanceof SessionControlError)
271
+ return e;
272
+ const message = e instanceof Error ? e.message : String(e);
273
+ return new SessionControlError(/can't find session|no server running|session not found/i.test(message) ? 'offline' : 'backend', message);
274
+ };
275
+ /**
276
+ * Report what actually went wrong, then say what it proves about the agent.
277
+ * The old handler replaced every failure — timeouts, socket errors, refusals —
278
+ * with "is not running", which is how an overseer came to restart busy agents.
279
+ */
280
+ const controlFailure = (name, action, e, extra = '') => {
281
+ const err = asControlError(e);
282
+ return `${action} ${name}: ${err.message}\n ${livenessNote(err.kind, name)}${extra}`;
283
+ };
140
284
  program.command('peek <name> [lines]').description('pane snapshot without attaching')
141
285
  .action(async (name, lines) => {
142
286
  try {
143
- console.log(await new Tmux().capture(name, lines ? Number(lines) : 40));
287
+ const stateDir = acpStateDir(name);
288
+ if (stateDir) {
289
+ const response = await controlRequest(stateDir, { command: 'follow', since: 0 });
290
+ if (!response.ok)
291
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'peek failed');
292
+ const events = response.result?.events ?? [];
293
+ for (const event of events.slice(-(lines ? Number(lines) : 40)))
294
+ renderSessionEvent(event);
295
+ }
296
+ else {
297
+ console.log(await new Tmux().capture(name, lines ? Number(lines) : 40));
298
+ }
144
299
  }
145
- catch {
146
- die(`'${name}' is not running; try: ours-fleet status ${name}`);
300
+ catch (e) {
301
+ die(controlFailure(name, 'peek', e));
147
302
  }
148
303
  });
149
304
  program.command('send <name> [text...]').description("type into the agent's console")
150
305
  .option('--key <key>', 'send a raw key instead (Escape, Up, C-c, ...)')
151
306
  .action(async (name, text, opts) => {
307
+ const stateDir = acpStateDir(name);
308
+ if (stateDir && opts.key)
309
+ die('--key is available only for tmux sessions');
310
+ if (!stateDir && !opts.key && !text?.length)
311
+ die('nothing to send: give text or --key');
312
+ if (stateDir && !text?.length)
313
+ die('nothing to send: give text');
152
314
  try {
153
- const tmux = new Tmux();
154
- if (opts.key)
155
- await tmux.sendKey(name, opts.key);
156
- else if (text?.length)
157
- await tmux.sendText(name, text.join(' '));
315
+ if (stateDir) {
316
+ // Returns on queue acceptance: a turn already running is not a failure.
317
+ const response = await controlRequest(stateDir, { command: 'submit_prompt', text: text.join(' ') });
318
+ if (!response.ok)
319
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'prompt rejected');
320
+ const queued = response.result;
321
+ console.log(queued?.queuedBehind
322
+ ? `queued for ${name} behind ${queued.queuedBehind} running turn(s)`
323
+ : `queued for ${name}`);
324
+ }
325
+ else if (opts.key)
326
+ await new Tmux().sendKey(name, opts.key);
158
327
  else
159
- die('nothing to send: give text or --key');
328
+ await new Tmux().sendText(name, text.join(' '));
160
329
  }
161
- catch {
162
- die(`'${name}' is not running; try: ours-fleet status ${name}`);
330
+ catch (e) {
331
+ die(controlFailure(name, 'send', e, asControlError(e).kind === 'timeout'
332
+ ? '\n The prompt may already have been delivered — do not assume it was lost.'
333
+ : ''));
163
334
  }
164
335
  });
165
336
  program.command('logs <name>').description('show the role log').option('-f, --follow', 'follow')
@@ -168,7 +339,30 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
168
339
  process.exit(await passthrough(cmd, args));
169
340
  });
170
341
  program.command('status <name>').description('unit/agent state')
171
- .action(async (name) => { console.log(await pickBackend().status(name)); });
342
+ .action(async (name) => {
343
+ console.log(await pickBackend().status(name));
344
+ // A held-down role looks like a healthy running unit from the outside — the
345
+ // runner is alive on purpose. Say so, with the reason and when (3.2).
346
+ const ledger = readRestartLedger(agentDir(name));
347
+ if (ledger.circuit === 'open')
348
+ console.log(`HELD DOWN since ${ledger.openedAt ?? ledger.updatedAt} after `
349
+ + `${ledger.consecutiveImmediateFailures} immediate failures: ${ledger.lastReason}`
350
+ + `\n release it with: ours-fleet restart ${name}`);
351
+ else if (ledger.consecutiveImmediateFailures > 0)
352
+ console.log(`restarts: ${ledger.consecutiveImmediateFailures} consecutive immediate `
353
+ + `failures, next delay ${ledger.nextDelayMs}ms (${ledger.lastReason})`);
354
+ const stateDir = acpStateDir(name);
355
+ if (stateDir) {
356
+ try {
357
+ const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
358
+ if (response.ok)
359
+ console.log(`session: ${JSON.stringify(response.result)}`);
360
+ }
361
+ catch {
362
+ console.log('session: acp control unavailable');
363
+ }
364
+ }
365
+ });
172
366
  cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fleet.d file if spawned)'))
173
367
  .action(async (name, opts) => {
174
368
  try {
@@ -179,14 +373,18 @@ cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fl
179
373
  }
180
374
  });
181
375
  cOpt(program.command('spawn <name>').description('spawn a new agent (permanent by default)'))
182
- .option('--temp', 'temporary: plain tmux, auto-cleaned, gone on reboot')
376
+ .option('--temp', 'temporary: detached supervisor, auto-cleaned, gone on reboot')
183
377
  .option('--harness <id>', 'harness adapter (default: defaults.harness)')
378
+ .option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
184
379
  .option('--mission <text>', 'one-line mission')
185
380
  .option('--identity <name>', 'ours identity to bind (default: role name)')
186
381
  .option('--cwd <dir>', 'working directory')
187
382
  .option('--coordinator <name>', 'announce target')
188
383
  .option('--model <id>', 'model id to launch on (e.g. claude-fable-5); default: launcher default')
189
384
  .option('--permission-mode <mode>', 'harness permission mode (Codex: untrusted|on-request|never; Claude: native values)')
385
+ .option('--approval <mode>', 'common approval intent: ask|allow|deny')
386
+ .option('--filesystem <mode>', 'common filesystem intent: read-only|workspace|unrestricted')
387
+ .option('--unattended <mode>', 'permission behavior without a console: deny|wait')
190
388
  .option('--sandbox <mode>', 'Codex sandbox: read-only|workspace-write|danger-full-access')
191
389
  .option('--profile <name>', 'Codex profile file name ($CODEX_HOME/<name>.config.toml)')
192
390
  .option('--launcher <mode>', 'Codex launcher: auto|ours-codex|codex (default: auto)')
@@ -196,16 +394,20 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
196
394
  .option('--monitor', 'explicitly consent to arm this Codex role\'s ours mail monitor')
197
395
  .option('--bio-file <file>', 'public bio (file)')
198
396
  .option('--persona-file <file>', 'persona / operating contract (file)')
397
+ .option('--isolation-file <path>', 'file holding an isolation: mapping (same schema as fleet.yaml)')
199
398
  .action(async (name, opts) => {
200
399
  try {
201
400
  const o = {
202
- name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
401
+ name, temp: opts.temp, harness: opts.harness, session: opts.session, mission: opts.mission,
203
402
  identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
204
403
  model: opts.model,
205
- permissionMode: opts.permissionMode, sandbox: opts.sandbox, profile: opts.profile,
404
+ permissionMode: opts.permissionMode, approval: opts.approval,
405
+ filesystem: opts.filesystem, unattended: opts.unattended,
406
+ sandbox: opts.sandbox, profile: opts.profile,
206
407
  launcher: opts.launcher, search: opts.search,
207
408
  codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
208
- bioFile: opts.bioFile, personaFile: opts.personaFile, configPath: opts.configuration,
409
+ bioFile: opts.bioFile, personaFile: opts.personaFile,
410
+ isolationFile: opts.isolationFile, configPath: opts.configuration,
209
411
  };
210
412
  if (o.temp) {
211
413
  const dir = await spawnTemp(o, binPath);
@@ -215,6 +417,14 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
215
417
  const file = await spawnPermanent(o, deps());
216
418
  console.log(`spawned '${name}' (config: ${file})`);
217
419
  }
420
+ // The same provenance that was persisted, so what the operator reads now
421
+ // and what a reviewer reads later cannot disagree (6.6).
422
+ if (lastProvenance) {
423
+ console.log(` created by ${lastProvenance.command} v${lastProvenance.fleetVersion} `
424
+ + `at ${lastProvenance.createdAt} (${lastProvenance.lifetime})`);
425
+ for (const line of formatProvenance(lastProvenance))
426
+ console.log(line);
427
+ }
218
428
  console.log(`→ watch it: ours-fleet peek ${name} | attach: ours-fleet attach ${name}`);
219
429
  }
220
430
  catch (e) {
@@ -240,8 +450,10 @@ program.command('init').description('one-time host setup (units, dirs, linger)')
240
450
  program.command('_run <name>', { hidden: true }).description('internal: supervisor entrypoint')
241
451
  .option('-c, --configuration <file>')
242
452
  .action(async (name, opts) => {
453
+ // The supervised loop, not a single session: restart policy lives here now,
454
+ // where it can count across attempts (3.2).
243
455
  try {
244
- await runOnce(name, { configPath: opts.configuration });
456
+ await runSupervised(name, { configPath: opts.configuration });
245
457
  }
246
458
  catch (e) {
247
459
  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,24 @@ 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 SessionBackendId = 'tmux' | 'acp';
11
+ export type ApprovalMode = 'ask' | 'allow' | 'deny';
12
+ export type FilesystemMode = 'read-only' | 'workspace' | 'unrestricted';
13
+ export type UnattendedMode = 'deny' | 'wait';
14
+ export interface CommonPermissions {
15
+ approval: ApprovalMode;
16
+ filesystem: FilesystemMode;
17
+ unattended: UnattendedMode;
18
+ }
19
+ export interface SessionOptions {
20
+ acp?: {
21
+ /** ACP agent command and arguments. Defaults are supplied by the harness adapter. */
22
+ command?: string | string[];
23
+ };
24
+ tmux?: {
25
+ boot_grace_ms?: number;
26
+ };
27
+ }
10
28
  /** Resolved per-role supervisor-monitor config (see DESIGN-external-monitor §2). */
11
29
  export interface MonitorConfig {
12
30
  enabled: boolean;
@@ -27,6 +45,9 @@ export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
27
45
  export declare function validateMonitorConfig(raw: unknown): string[];
28
46
  export interface RoleConfig {
29
47
  harness?: string;
48
+ session?: SessionBackendId;
49
+ session_options?: SessionOptions;
50
+ permissions?: Partial<CommonPermissions>;
30
51
  identity?: string;
31
52
  cwd?: string;
32
53
  coordinator?: string;
@@ -46,6 +67,15 @@ export interface RoleConfig {
46
67
  export interface ResolvedRole extends RoleConfig {
47
68
  name: string;
48
69
  harness: string;
70
+ session: SessionBackendId;
71
+ permissions: CommonPermissions;
72
+ /**
73
+ * Whether `permissions:` was actually written by the operator (on the role or
74
+ * in defaults), as opposed to resolved from built-in defaults. A role that
75
+ * states its intent only once — neutrally OR natively — has nothing to
76
+ * contradict, and must not be warned at (2.4).
77
+ */
78
+ permissionsDeclared: boolean;
49
79
  identity: string;
50
80
  sourceFile: string;
51
81
  monitor: MonitorConfig;
@@ -60,8 +90,16 @@ export interface FleetConfig {
60
90
  }
61
91
  export declare class ConfigError extends Error {
62
92
  }
93
+ /**
94
+ * The runtime facts the isolation resolver needs for a role. Single-sourced so
95
+ * config validation, doctor, and the runner all judge the SAME mount set — a
96
+ * policy checked against a different context than the one that launches is not
97
+ * a check at all.
98
+ */
99
+ export declare function isolationContextFor(role: ResolvedRole): WrapContext;
63
100
  /** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
64
101
  export declare function loadConfig(configPath?: string): FleetConfig;
102
+ export declare function resolvePermissions(defaults: unknown, role: Partial<CommonPermissions> | undefined, file?: string, name?: string): CommonPermissions;
65
103
  /**
66
104
  * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
67
105
  * result, and fill code-constant defaults (design §2). `defaults.monitor.enabled`
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',
@@ -49,9 +50,40 @@ export function validateMonitorConfig(raw) {
49
50
  }
50
51
  export class ConfigError extends Error {
51
52
  }
53
+ /**
54
+ * The runtime facts the isolation resolver needs for a role. Single-sourced so
55
+ * config validation, doctor, and the runner all judge the SAME mount set — a
56
+ * policy checked against a different context than the one that launches is not
57
+ * a check at all.
58
+ */
59
+ export function isolationContextFor(role) {
60
+ const stateDir = agentDir(role.name, role.__temp === true);
61
+ const runCwd = role.cwd ?? stateDir;
62
+ // Ask the harness how its host state splits (5.1). An adapter that declares
63
+ // none keeps the historical whole-home mount.
64
+ let split;
65
+ try {
66
+ split = getAdapter(role.harness).isolationPaths?.(role, { stateDir, runCwd });
67
+ }
68
+ catch {
69
+ split = undefined;
70
+ }
71
+ return {
72
+ stateDir,
73
+ runCwd,
74
+ home: home(),
75
+ harness: role.harness,
76
+ additionalWriteDirs: role.harness === 'codex'
77
+ ? (role.harness_options?.add_dirs ?? [])
78
+ : [],
79
+ harnessHome: split?.home,
80
+ harnessRuntimeDir: split?.home ? harnessRuntimeDir(stateDir, role.harness) : undefined,
81
+ harnessSharedPaths: split?.shared,
82
+ };
83
+ }
52
84
  const NAME_RE = /^[A-Za-z0-9_-]+$/;
53
85
  const ROLE_KEYS = [
54
- 'harness', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
86
+ 'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
55
87
  'briefing_file', 'model', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
56
88
  'isolation', 'monitor',
57
89
  ];
@@ -107,6 +139,10 @@ export function loadConfig(configPath) {
107
139
  if (bad.length)
108
140
  throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
109
141
  const isolation = r.isolation ?? defaults.isolation;
142
+ const session = resolveSession(r.session ?? defaults.session, file, name);
143
+ const sessionOptions = resolveSessionOptions(defaults.session_options, r.session_options, session, file, name);
144
+ const permissions = resolvePermissions(defaults.permissions, r.permissions, file, name);
145
+ const permissionsDeclared = r.permissions !== undefined || defaults.permissions !== undefined;
110
146
  const defaultHarnessOptions = defaults.harness_options;
111
147
  if (defaultHarnessOptions !== undefined
112
148
  && (typeof defaultHarnessOptions !== 'object' || defaultHarnessOptions === null
@@ -129,6 +165,10 @@ export function loadConfig(configPath) {
129
165
  name,
130
166
  sourceFile: file,
131
167
  harness: r.harness ?? defaults.harness ?? 'claude-code',
168
+ session,
169
+ session_options: sessionOptions,
170
+ permissions,
171
+ permissionsDeclared,
132
172
  identity: r.identity ?? name,
133
173
  model: r.model ?? defaults.model,
134
174
  max_tokens: r.max_tokens ?? defaults.max_tokens,
@@ -136,10 +176,93 @@ export function loadConfig(configPath) {
136
176
  isolation,
137
177
  monitor,
138
178
  });
179
+ // Forbidden-path enforcement (5.2): a mount that would breach the policy
180
+ // is a configuration error, caught by `config` rather than at launch.
181
+ if (isolation !== undefined) {
182
+ const role = roles[roles.length - 1];
183
+ try {
184
+ resolveIsolation(isolation, isolationContextFor(role));
185
+ }
186
+ catch (e) {
187
+ throw new ConfigError(`${file}: role '${name}' ${e.message}`);
188
+ }
189
+ }
139
190
  }
140
191
  }
141
192
  return { roles, vars, defaults, files, startStaggerMs };
142
193
  }
194
+ function resolveSession(raw, file, name) {
195
+ const value = raw ?? 'tmux';
196
+ if (value !== 'tmux' && value !== 'acp')
197
+ throw new ConfigError(`${file}: role '${name}' session: must be one of: tmux, acp`);
198
+ return value;
199
+ }
200
+ function resolveSessionOptions(defaults, role, session, file, name) {
201
+ if (defaults !== undefined && !isPlainObject(defaults))
202
+ throw new ConfigError(`${file}: defaults.session_options must be a map`);
203
+ if (role !== undefined && !isPlainObject(role))
204
+ throw new ConfigError(`${file}: role '${name}' session_options must be a map`);
205
+ const merged = {
206
+ ...(defaults ?? {}),
207
+ ...(role ?? {}),
208
+ acp: {
209
+ ...((defaults?.acp) ?? {}),
210
+ ...(role?.acp ?? {}),
211
+ },
212
+ tmux: {
213
+ ...((defaults?.tmux) ?? {}),
214
+ ...(role?.tmux ?? {}),
215
+ },
216
+ };
217
+ const bad = Object.keys(merged).filter(k => k !== 'acp' && k !== 'tmux');
218
+ if (bad.length)
219
+ throw new ConfigError(`${file}: role '${name}' session_options: unknown key(s) ${bad.join(', ')}`);
220
+ if (!isPlainObject(merged.acp) || !isPlainObject(merged.tmux))
221
+ throw new ConfigError(`${file}: role '${name}' session_options.${session} must be a map`);
222
+ const acpBad = Object.keys(merged.acp).filter(k => k !== 'command');
223
+ const tmuxBad = Object.keys(merged.tmux).filter(k => k !== 'boot_grace_ms');
224
+ if (acpBad.length)
225
+ throw new ConfigError(`${file}: role '${name}' session_options.acp: unknown key(s) ${acpBad.join(', ')}`);
226
+ if (tmuxBad.length)
227
+ throw new ConfigError(`${file}: role '${name}' session_options.tmux: unknown key(s) ${tmuxBad.join(', ')}`);
228
+ const command = merged.acp.command;
229
+ if (command !== undefined
230
+ && !(typeof command === 'string' && command.trim())
231
+ && !(Array.isArray(command) && command.length > 0
232
+ && command.every(v => typeof v === 'string' && v.length > 0)))
233
+ throw new ConfigError(`${file}: role '${name}' session_options.acp.command must be a non-empty string or string list`);
234
+ const grace = merged.tmux.boot_grace_ms;
235
+ if (grace !== undefined
236
+ && (typeof grace !== 'number' || !Number.isFinite(grace) || grace < 0))
237
+ throw new ConfigError(`${file}: role '${name}' session_options.tmux.boot_grace_ms must be a non-negative number`);
238
+ return Object.keys(merged.acp).length || Object.keys(merged.tmux).length ? merged : undefined;
239
+ }
240
+ export function resolvePermissions(defaults, role, file = 'config', name = 'role') {
241
+ if (defaults !== undefined && !isPlainObject(defaults))
242
+ throw new ConfigError(`${file}: defaults.permissions must be a map`);
243
+ if (role !== undefined && !isPlainObject(role))
244
+ throw new ConfigError(`${file}: role '${name}' permissions must be a map`);
245
+ const merged = {
246
+ ...(defaults ?? {}),
247
+ ...(role ?? {}),
248
+ };
249
+ const allowed = ['approval', 'filesystem', 'unattended'];
250
+ const bad = Object.keys(merged).filter(k => !allowed.includes(k));
251
+ if (bad.length)
252
+ throw new ConfigError(`${file}: role '${name}' permissions: unknown key(s) ${bad.join(', ')}`);
253
+ if (merged.approval !== undefined && !['ask', 'allow', 'deny'].includes(merged.approval))
254
+ throw new ConfigError(`${file}: role '${name}' permissions.approval must be one of: ask, allow, deny`);
255
+ if (merged.filesystem !== undefined
256
+ && !['read-only', 'workspace', 'unrestricted'].includes(merged.filesystem))
257
+ throw new ConfigError(`${file}: role '${name}' permissions.filesystem must be one of: read-only, workspace, unrestricted`);
258
+ if (merged.unattended !== undefined && !['deny', 'wait'].includes(merged.unattended))
259
+ throw new ConfigError(`${file}: role '${name}' permissions.unattended must be one of: deny, wait`);
260
+ return {
261
+ approval: merged.approval ?? 'ask',
262
+ filesystem: merged.filesystem ?? 'workspace',
263
+ unattended: merged.unattended ?? 'deny',
264
+ };
265
+ }
143
266
  /** Validate the top-level `start_stagger_ms` (supervisor launch spacing); default 0. */
144
267
  function resolveStartStaggerMs(raw, base) {
145
268
  if (raw === undefined || raw === null)