@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.
- package/README.md +138 -21
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +43 -13
- package/dist/cli.js +98 -22
- package/dist/config.d.ts +24 -3
- package/dist/config.js +84 -11
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +155 -8
- package/dist/doctor.js +75 -17
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +128 -26
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +58 -11
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +51 -4
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +33 -4
- package/dist/monitor.js +150 -32
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +262 -27
- package/dist/session/acp.d.ts +25 -2
- package/dist/session/acp.js +143 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +9 -2
- package/dist/session/tmux.js +36 -4
- package/dist/session/types.d.ts +99 -2
- package/dist/session/types.js +42 -1
- package/dist/spawn.d.ts +27 -2
- package/dist/spawn.js +153 -15
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- 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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
330
|
+
await new Tmux().sendText(name, text.join(' '));
|
|
279
331
|
}
|
|
280
|
-
catch {
|
|
281
|
-
die(
|
|
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', '
|
|
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,
|
|
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
|
|
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). `
|
|
92
|
-
*
|
|
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 = [
|
|
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). `
|
|
232
|
-
*
|
|
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
|
-
...
|
|
244
|
-
...
|
|
307
|
+
...def,
|
|
308
|
+
...own,
|
|
245
309
|
};
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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;
|