@ours.network/fleet 0.11.1 → 0.13.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 +225 -0
- package/dist/briefing.js +25 -0
- package/dist/cli.js +408 -1
- package/dist/config.d.ts +31 -1
- package/dist/config.js +123 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +143 -0
- package/dist/duration.js +7 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/loops/config.d.ts +30 -0
- package/dist/loops/config.js +135 -0
- package/dist/loops/manager.d.ts +48 -0
- package/dist/loops/manager.js +237 -0
- package/dist/loops/state.d.ts +54 -0
- package/dist/loops/state.js +148 -0
- package/dist/monitor.js +26 -2
- package/dist/owner-channel/attachments.d.ts +74 -0
- package/dist/owner-channel/attachments.js +378 -0
- package/dist/owner-channel/channel.d.ts +167 -0
- package/dist/owner-channel/channel.js +874 -0
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +123 -0
- package/dist/owner-channel/notices.d.ts +21 -0
- package/dist/owner-channel/notices.js +66 -0
- package/dist/owner-channel/state.d.ts +44 -0
- package/dist/owner-channel/state.js +184 -0
- package/dist/owner-channel/tasks.d.ts +62 -0
- package/dist/owner-channel/tasks.js +246 -0
- package/dist/resolved-plan.js +12 -0
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +112 -5
- package/dist/session/acp.d.ts +4 -2
- package/dist/session/acp.js +82 -25
- package/dist/session/arbiter.d.ts +42 -0
- package/dist/session/arbiter.js +72 -0
- package/dist/session/control.d.ts +12 -1
- package/dist/session/control.js +56 -3
- package/dist/session/types.d.ts +28 -2
- package/dist/session/types.js +5 -2
- package/dist/spawn.js +7 -3
- package/dist/supervisor/systemd.js +12 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createInterface } from 'node:readline';
|
|
|
7
7
|
import { Command } from 'commander';
|
|
8
8
|
import { VERSION } from './version.js';
|
|
9
9
|
import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir, watchdogsRoot } from './paths.js';
|
|
10
|
-
import { loadConfig, ROLE_NAME_RE } from './config.js';
|
|
10
|
+
import { findRole, loadConfig, ROLE_NAME_RE } from './config.js';
|
|
11
11
|
import { formatDuration } from './duration.js';
|
|
12
12
|
import { resolvedPlan } from './resolved-plan.js';
|
|
13
13
|
import { Tmux, tmuxArgs } from './tmux.js';
|
|
@@ -28,6 +28,7 @@ import { allWarnings, analyzeFleetPermissions, formatNative } from './permission
|
|
|
28
28
|
import { AI_DOCS } from './docs.js';
|
|
29
29
|
import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
|
|
30
30
|
import { SessionControlError } from './session/types.js';
|
|
31
|
+
import { readScheduledLoops } from './loops/state.js';
|
|
31
32
|
import { startWebConsole } from './web/runtime.js';
|
|
32
33
|
import { requestWebControl } from './web/control.js';
|
|
33
34
|
import { WebServiceManager } from './web/service.js';
|
|
@@ -74,6 +75,31 @@ function acpStateDir(name) {
|
|
|
74
75
|
return temp;
|
|
75
76
|
return undefined;
|
|
76
77
|
}
|
|
78
|
+
const CONTACT_CID_RE = /^[A-Fa-f0-9]{64}$/;
|
|
79
|
+
const OWNER_REQUEST_ID_RE = /^[a-f0-9]{64}$/;
|
|
80
|
+
const MAX_INVITE_BYTES = 48 * 1024;
|
|
81
|
+
const MAX_OWNER_UPDATE_BYTES = 1_024;
|
|
82
|
+
function ownerChannelStateDir(roleName, configuration) {
|
|
83
|
+
const role = findRole(loadConfig(configuration), roleName);
|
|
84
|
+
if (role.session !== 'acp')
|
|
85
|
+
throw new Error(`role '${roleName}' uses session '${role.session}', but owner-channel management requires ACP`);
|
|
86
|
+
if (!role.owner_channel)
|
|
87
|
+
throw new Error(`role '${roleName}' has no owner_channel configured`);
|
|
88
|
+
const stateDir = acpStateDir(roleName);
|
|
89
|
+
if (!stateDir)
|
|
90
|
+
throw new Error(`role '${roleName}' is stopped or its authenticated ACP control socket is unavailable`);
|
|
91
|
+
return stateDir;
|
|
92
|
+
}
|
|
93
|
+
async function manageOwnerChannel(role, configuration, ownerChannel) {
|
|
94
|
+
const response = await controlRequest(ownerChannelStateDir(role, configuration), { command: 'owner_channel_manage', ownerChannel });
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'owner-channel request failed');
|
|
97
|
+
return response.result;
|
|
98
|
+
}
|
|
99
|
+
function assertContactCid(cid) {
|
|
100
|
+
if (!CONTACT_CID_RE.test(cid))
|
|
101
|
+
throw new Error('contact CID must be exactly 64 hexadecimal characters');
|
|
102
|
+
}
|
|
77
103
|
function renderSessionEvent(event) {
|
|
78
104
|
switch (event.kind) {
|
|
79
105
|
case 'agent_text':
|
|
@@ -155,6 +181,10 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
155
181
|
console.log(` monitor: ${r.monitor.mode}`
|
|
156
182
|
+ (r.monitor.mode === 'fleet' ? ` (interrupt=${r.monitor.interrupt})` : ''));
|
|
157
183
|
console.log(` identity: ${r.identity}`);
|
|
184
|
+
if (r.owner_channel)
|
|
185
|
+
console.log(` owner ch: ${r.owner_channel.identity} `
|
|
186
|
+
+ `(${r.owner_channel.owners.length} authorized sender(s), `
|
|
187
|
+
+ `interrupt=${r.owner_channel.interrupt})`);
|
|
158
188
|
console.log(` permissions: approval=${r.permissions.approval} `
|
|
159
189
|
+ `filesystem=${r.permissions.filesystem} unattended=${r.permissions.unattended}`);
|
|
160
190
|
if (perms?.supported)
|
|
@@ -201,6 +231,15 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
201
231
|
console.log(` isolation: ${JSON.stringify(w.isolation)}`);
|
|
202
232
|
}
|
|
203
233
|
}
|
|
234
|
+
if (cfg.loops.length) {
|
|
235
|
+
console.log('loops:');
|
|
236
|
+
for (const loop of cfg.loops) {
|
|
237
|
+
console.log(`● ${loop.name}${loop.enabled ? '' : ' (disabled)'}`);
|
|
238
|
+
console.log(` every ${formatDuration(loop.intervalMs)} initial=${formatDuration(loop.initialDelayMs)} jitter=${formatDuration(loop.jitterMs)}`);
|
|
239
|
+
console.log(` roles: ${loop.roleNames.join(', ')}`);
|
|
240
|
+
console.log(` prompt: ${loop.promptBytes} bytes sha256=${loop.promptHash.slice(0, 12)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
204
243
|
}
|
|
205
244
|
catch (e) {
|
|
206
245
|
die(e);
|
|
@@ -433,6 +472,374 @@ program.command('status <name>').description('unit/agent state')
|
|
|
433
472
|
console.log('session: acp control unavailable');
|
|
434
473
|
}
|
|
435
474
|
}
|
|
475
|
+
const loopState = readScheduledLoops(agentDir(name));
|
|
476
|
+
if (loopState) {
|
|
477
|
+
const values = Object.values(loopState.loops);
|
|
478
|
+
const enabled = values.filter(loop => loop.enabled && !loop.operatorDisabled).length;
|
|
479
|
+
const next = values.filter(loop => loop.enabled && !loop.operatorDisabled)
|
|
480
|
+
.map(loop => Date.parse(loop.nextDueAt)).filter(Number.isFinite).sort((a, b) => a - b)[0];
|
|
481
|
+
console.log(`loops: ${enabled} enabled${next ? `, next ${formatDuration(Math.max(0, next - Date.now()))}` : ''}, ${loopState.health}`);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
const loopsCommand = program.command('loops')
|
|
485
|
+
.description('validate, inspect, and control strict idle-only scheduled agent loops');
|
|
486
|
+
function loopFailure(error, json, code = 1) {
|
|
487
|
+
const err = error instanceof SessionControlError ? error : undefined;
|
|
488
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
489
|
+
if (json)
|
|
490
|
+
console.log(JSON.stringify({
|
|
491
|
+
schemaVersion: 1, ok: false,
|
|
492
|
+
error: { kind: err?.kind ?? 'invalid', message, retrySafe: err?.kind !== 'timeout' },
|
|
493
|
+
}));
|
|
494
|
+
else
|
|
495
|
+
console.error(message);
|
|
496
|
+
process.exitCode = code;
|
|
497
|
+
}
|
|
498
|
+
function permanentLoopControlDir(role) {
|
|
499
|
+
const dir = agentDir(role);
|
|
500
|
+
return existsSync(controlSocketPath(dir)) ? dir : undefined;
|
|
501
|
+
}
|
|
502
|
+
cOpt(loopsCommand.command('validate').description('validate loop config and expanded ACP targets'))
|
|
503
|
+
.option('--json', 'emit stable JSON')
|
|
504
|
+
.action(opts => {
|
|
505
|
+
try {
|
|
506
|
+
const cfg = loadConfig(opts.configuration);
|
|
507
|
+
const result = { schemaVersion: 1, ok: true, loops: cfg.loops.length,
|
|
508
|
+
pairs: cfg.roles.reduce((sum, role) => sum + (role.loops?.length ?? 0), 0) };
|
|
509
|
+
if (opts.json)
|
|
510
|
+
console.log(JSON.stringify(result));
|
|
511
|
+
else
|
|
512
|
+
console.log(`valid: ${result.loops} loop(s), ${result.pairs} resolved role pair(s)`);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
loopFailure(error, opts.json === true);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
cOpt(loopsCommand.command('list').description('list redacted resolved loop definitions'))
|
|
519
|
+
.option('--role <role>', 'filter to one permanent role')
|
|
520
|
+
.option('--json', 'emit stable JSON')
|
|
521
|
+
.action(opts => {
|
|
522
|
+
try {
|
|
523
|
+
const cfg = loadConfig(opts.configuration);
|
|
524
|
+
if (opts.role)
|
|
525
|
+
findRole(cfg, opts.role);
|
|
526
|
+
const values = cfg.loops.filter(loop => !opts.role || loop.roleNames.includes(opts.role)).map(loop => ({
|
|
527
|
+
name: loop.name, selectors: loop.selectors, roles: loop.roleNames,
|
|
528
|
+
enabled: loop.enabled, intervalMs: loop.intervalMs, initialDelayMs: loop.initialDelayMs,
|
|
529
|
+
jitterMs: loop.jitterMs, prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
|
|
530
|
+
sourceFile: loop.sourceFile,
|
|
531
|
+
}));
|
|
532
|
+
if (opts.json)
|
|
533
|
+
console.log(JSON.stringify({ schemaVersion: 1, loops: values }));
|
|
534
|
+
else
|
|
535
|
+
for (const loop of values)
|
|
536
|
+
console.log(`${loop.name} ${loop.enabled ? 'enabled' : 'disabled'} every=${formatDuration(loop.intervalMs)} roles=${loop.roles.join(',')} prompt=${loop.prompt.bytes}B/${loop.prompt.sha256.slice(0, 12)}`);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
loopFailure(error, opts.json === true);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
function selectLoopConfig(configuration, roleName, loopName) {
|
|
543
|
+
const cfg = loadConfig(configuration);
|
|
544
|
+
const role = findRole(cfg, roleName);
|
|
545
|
+
const definitions = role.loops ?? [];
|
|
546
|
+
if (loopName && !definitions.some(loop => loop.name === loopName))
|
|
547
|
+
throw new Error(`role '${roleName}' has no scheduled loop '${loopName}'`);
|
|
548
|
+
return { role, definitions };
|
|
549
|
+
}
|
|
550
|
+
function renderLoopRows(role, state, loop) {
|
|
551
|
+
if (!state)
|
|
552
|
+
return [];
|
|
553
|
+
return Object.entries(state.loops).filter(([name]) => !loop || name === loop).map(([name, item]) => `${role}/${name} ${item.enabled && !item.operatorDisabled ? 'enabled' : 'disabled'} `
|
|
554
|
+
+ `${item.activeRunId ? 'running' : 'idle'} next=${item.nextDueAt} last=${item.lastOutcome ?? 'never'} `
|
|
555
|
+
+ `counts=${item.counts.started}/${item.counts.completed}/${item.counts.failed} `
|
|
556
|
+
+ `skip=${item.counts.skipped}(busy=${item.counts.skippedBusy},missed=${item.counts.skippedMissed})`);
|
|
557
|
+
}
|
|
558
|
+
cOpt(loopsCommand.command('status [role] [loop]').description('show live or stored loop state'))
|
|
559
|
+
.option('--json', 'emit stable JSON')
|
|
560
|
+
.action(async (roleName, loopName, opts) => {
|
|
561
|
+
try {
|
|
562
|
+
const cfg = loadConfig(opts.configuration);
|
|
563
|
+
const roles = roleName ? [findRole(cfg, roleName)] : cfg.roles.filter(role => role.loops?.length);
|
|
564
|
+
if (roleName && loopName)
|
|
565
|
+
selectLoopConfig(opts.configuration, roleName, loopName);
|
|
566
|
+
const results = [];
|
|
567
|
+
for (const role of roles) {
|
|
568
|
+
let state = readScheduledLoops(agentDir(role.name));
|
|
569
|
+
let evidence = 'stored';
|
|
570
|
+
const live = permanentLoopControlDir(role.name);
|
|
571
|
+
if (live)
|
|
572
|
+
try {
|
|
573
|
+
const response = await controlRequest(live, { command: 'loop_status' }, 2_000);
|
|
574
|
+
if (response.ok) {
|
|
575
|
+
state = response.result;
|
|
576
|
+
evidence = 'live';
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
catch { /* stored evidence remains honest; timeout is not offline */ }
|
|
580
|
+
results.push({ role: role.name, evidence, state });
|
|
581
|
+
}
|
|
582
|
+
if (opts.json)
|
|
583
|
+
console.log(JSON.stringify({ schemaVersion: 1, roles: results }));
|
|
584
|
+
else {
|
|
585
|
+
const rows = results.flatMap(result => renderLoopRows(result.role, result.state, loopName)
|
|
586
|
+
.map(row => `${row} evidence=${result.evidence}`));
|
|
587
|
+
console.log(rows.join('\n') || '(no scheduled loop state)');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
loopFailure(error, opts.json === true);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
cOpt(loopsCommand.command('reload <role>').description('reload trusted scheduled-loop config in a live ACP role'))
|
|
595
|
+
.option('--json', 'emit stable JSON')
|
|
596
|
+
.action(async (roleName, opts) => {
|
|
597
|
+
try {
|
|
598
|
+
const { role } = selectLoopConfig(opts.configuration, roleName);
|
|
599
|
+
if (role.session !== 'acp')
|
|
600
|
+
throw new Error(`role '${roleName}' is not ACP-compatible`);
|
|
601
|
+
const stateDir = permanentLoopControlDir(roleName);
|
|
602
|
+
if (!stateDir)
|
|
603
|
+
throw new SessionControlError('control-unavailable', `role '${roleName}' has no live authenticated ACP control socket`);
|
|
604
|
+
const response = await controlRequest(stateDir, { command: 'reload_config' });
|
|
605
|
+
if (!response.ok)
|
|
606
|
+
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'config reload failed');
|
|
607
|
+
const result = response.result;
|
|
608
|
+
if (opts.json)
|
|
609
|
+
console.log(JSON.stringify({ schemaVersion: 1, ok: true, role: roleName, ...result }));
|
|
610
|
+
else
|
|
611
|
+
console.log(`${roleName}: ${result.changed ? 'reloaded' : 'unchanged'} (${result.loops} loops)`);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
loopFailure(error, opts.json === true, error instanceof SessionControlError
|
|
615
|
+
&& ['timeout', 'control-unavailable'].includes(error.kind) ? 2 : 1);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
for (const command of ['run-now', 'disable', 'enable']) {
|
|
619
|
+
cOpt(loopsCommand.command(`${command} <role> <loop>`)
|
|
620
|
+
.description(`${command} one trusted configured loop through the live private control socket`))
|
|
621
|
+
.option('--json', 'emit stable JSON')
|
|
622
|
+
.action(async (roleName, loopName, opts) => {
|
|
623
|
+
try {
|
|
624
|
+
const { role, definitions } = selectLoopConfig(opts.configuration, roleName, loopName);
|
|
625
|
+
if (role.session !== 'acp')
|
|
626
|
+
throw new Error(`role '${roleName}' is not ACP-compatible`);
|
|
627
|
+
const definition = definitions.find(loop => loop.name === loopName);
|
|
628
|
+
if (command === 'enable' && !definition.enabled)
|
|
629
|
+
throw new Error(`loop '${loopName}' is disabled in YAML; edit the config before enabling it`);
|
|
630
|
+
const stateDir = permanentLoopControlDir(roleName);
|
|
631
|
+
if (!stateDir)
|
|
632
|
+
throw new SessionControlError('control-unavailable', `role '${roleName}' has no live authenticated ACP control socket`);
|
|
633
|
+
const response = await controlRequest(stateDir, {
|
|
634
|
+
command: command === 'run-now' ? 'loop_run_now'
|
|
635
|
+
: command === 'disable' ? 'loop_disable' : 'loop_enable',
|
|
636
|
+
loop: loopName,
|
|
637
|
+
});
|
|
638
|
+
if (!response.ok)
|
|
639
|
+
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'loop control failed');
|
|
640
|
+
const result = response.result;
|
|
641
|
+
if (opts.json)
|
|
642
|
+
console.log(JSON.stringify({ schemaVersion: 1, ok: true, role: roleName, loop: loopName, ...result }));
|
|
643
|
+
else
|
|
644
|
+
console.log(`${roleName}/${loopName}: ${result.state}${result.runId ? ` ${result.runId}` : ''}`);
|
|
645
|
+
if (command === 'run-now' && result.state !== 'started')
|
|
646
|
+
process.exitCode = 3;
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
loopFailure(error, opts.json === true, error instanceof SessionControlError
|
|
650
|
+
&& ['timeout', 'control-unavailable'].includes(error.kind) ? 2 : 1);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
const ownerChannelCommand = program.command('owner-channel')
|
|
655
|
+
.description('manage owner routing and task-correlated reports through a running role supervisor')
|
|
656
|
+
.addHelpText('after', '\nPairing is two-step: establish a contact first, then explicitly authorize its exact CID.');
|
|
657
|
+
const ownerContactCommand = ownerChannelCommand.command('contact')
|
|
658
|
+
.description('establish contacts without granting owner authority');
|
|
659
|
+
const ownerAuthorizationCommand = ownerChannelCommand.command('owner')
|
|
660
|
+
.description('manage the effective owner CID set (separate from contacts)');
|
|
661
|
+
const ownerTaskCommand = ownerChannelCommand.command('task')
|
|
662
|
+
.description('register and report bounded follow-up work correlated to an authenticated owner request');
|
|
663
|
+
cOpt(ownerTaskCommand.command('open <Role> <active-request-id>')
|
|
664
|
+
.description('register a durable follow-up task during the exact active owner request'))
|
|
665
|
+
.action(async (role, requestId, opts) => {
|
|
666
|
+
try {
|
|
667
|
+
if (!OWNER_REQUEST_ID_RE.test(requestId))
|
|
668
|
+
throw new Error('owner task request ID must be exactly 64 lowercase hexadecimal characters');
|
|
669
|
+
const result = await manageOwnerChannel(role, opts.configuration, {
|
|
670
|
+
action: 'task_open', requestId,
|
|
671
|
+
});
|
|
672
|
+
if (result.action !== 'task_open')
|
|
673
|
+
throw new Error('unexpected owner-channel response');
|
|
674
|
+
console.log(`Owner task ${result.taskId} opened; expires ${result.expiresAt}.`);
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
die(e);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
cOpt(ownerTaskCommand.command('report <Role> <task-id>')
|
|
681
|
+
.description('send a proactive follow-up to the task\'s stored authenticated origin')
|
|
682
|
+
.requiredOption('--phase <phase>', 'progress, done, or blocked')
|
|
683
|
+
.requiredOption('--message-stdin', 'read the one-line report body from stdin'))
|
|
684
|
+
.action(async (role, taskId, opts) => {
|
|
685
|
+
try {
|
|
686
|
+
if (!OWNER_REQUEST_ID_RE.test(taskId))
|
|
687
|
+
throw new Error('owner task ID must be exactly 64 lowercase hexadecimal characters');
|
|
688
|
+
const phase = String(opts.phase);
|
|
689
|
+
if (!['progress', 'done', 'blocked'].includes(phase))
|
|
690
|
+
throw new Error('owner task report phase must be progress, done, or blocked');
|
|
691
|
+
const message = readFileSync(0, 'utf8');
|
|
692
|
+
if (Buffer.byteLength(message) > MAX_OWNER_UPDATE_BYTES)
|
|
693
|
+
throw new Error(`owner task report input exceeds ${MAX_OWNER_UPDATE_BYTES} bytes`);
|
|
694
|
+
const result = await manageOwnerChannel(role, opts.configuration, {
|
|
695
|
+
action: 'task_report', taskId, phase: phase, message,
|
|
696
|
+
});
|
|
697
|
+
if (result.action !== 'task_report')
|
|
698
|
+
throw new Error('unexpected owner-channel response');
|
|
699
|
+
console.log(`Owner task report ${result.sequence} delivered; task ${result.state}.`);
|
|
700
|
+
}
|
|
701
|
+
catch (e) {
|
|
702
|
+
die(e);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
cOpt(ownerChannelCommand.command('update <Role> <request-id>')
|
|
706
|
+
.description('send one bounded agent-authored update for an active owner request')
|
|
707
|
+
.requiredOption('--phase <phase>', 'working, approval, or blocked')
|
|
708
|
+
.requiredOption('--message-stdin', 'read the one-line update body from stdin'))
|
|
709
|
+
.action(async (role, requestId, opts) => {
|
|
710
|
+
try {
|
|
711
|
+
if (!OWNER_REQUEST_ID_RE.test(requestId))
|
|
712
|
+
throw new Error('owner update request ID must be exactly 64 lowercase hexadecimal characters');
|
|
713
|
+
const phase = String(opts.phase);
|
|
714
|
+
if (!['working', 'approval', 'blocked'].includes(phase))
|
|
715
|
+
throw new Error('owner update phase must be working, approval, or blocked');
|
|
716
|
+
const message = readFileSync(0, 'utf8');
|
|
717
|
+
if (Buffer.byteLength(message) > MAX_OWNER_UPDATE_BYTES)
|
|
718
|
+
throw new Error(`owner update input exceeds ${MAX_OWNER_UPDATE_BYTES} bytes`);
|
|
719
|
+
const result = await manageOwnerChannel(role, opts.configuration, {
|
|
720
|
+
action: 'request_update', requestId,
|
|
721
|
+
phase: phase, message,
|
|
722
|
+
});
|
|
723
|
+
if (result.action !== 'request_update')
|
|
724
|
+
throw new Error('unexpected owner-channel response');
|
|
725
|
+
console.log(`Owner update ${result.sequence} delivered.`);
|
|
726
|
+
}
|
|
727
|
+
catch (e) {
|
|
728
|
+
die(e);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
cOpt(ownerContactCommand.command('list <Role>')
|
|
732
|
+
.description('list established/pending contacts using safe identity metadata only'))
|
|
733
|
+
.action(async (role, opts) => {
|
|
734
|
+
try {
|
|
735
|
+
const result = await manageOwnerChannel(role, opts.configuration, { action: 'contact_list' });
|
|
736
|
+
if (result.action !== 'contact_list')
|
|
737
|
+
throw new Error('unexpected owner-channel response');
|
|
738
|
+
if (!result.contacts.length) {
|
|
739
|
+
console.log('(no contacts)');
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
console.log('CID\tNAME\tSTATUS\tKIND\tHUMAN');
|
|
743
|
+
for (const contact of result.contacts)
|
|
744
|
+
console.log([
|
|
745
|
+
contact.cid, contact.name, contact.status, contact.kind ?? '', contact.human?.name ?? '',
|
|
746
|
+
].join('\t'));
|
|
747
|
+
}
|
|
748
|
+
catch (e) {
|
|
749
|
+
die(e);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
cOpt(ownerContactCommand.command('invite <Role>')
|
|
753
|
+
.description('generate an invite on the already-bound owner channel')
|
|
754
|
+
.option('--name <label>', 'optional contact label'))
|
|
755
|
+
.action(async (role, opts) => {
|
|
756
|
+
try {
|
|
757
|
+
const result = await manageOwnerChannel(role, opts.configuration, {
|
|
758
|
+
action: 'contact_invite', ...(opts.name ? { name: String(opts.name) } : {}),
|
|
759
|
+
});
|
|
760
|
+
if (result.action !== 'contact_invite')
|
|
761
|
+
throw new Error('unexpected owner-channel response');
|
|
762
|
+
// Invite material is intentionally the only stdout content.
|
|
763
|
+
process.stdout.write(result.invite + '\n');
|
|
764
|
+
}
|
|
765
|
+
catch (e) {
|
|
766
|
+
die(e);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
cOpt(ownerContactCommand.command('add <Role>')
|
|
770
|
+
.description('accept an invite without granting owner authority')
|
|
771
|
+
.option('--invite-file <path>', 'read invite material from a file')
|
|
772
|
+
.option('--invite-stdin', 'read invite material from stdin')
|
|
773
|
+
.option('--name <label>', 'optional contact label'))
|
|
774
|
+
.action(async (role, opts) => {
|
|
775
|
+
try {
|
|
776
|
+
if (Boolean(opts.inviteFile) === Boolean(opts.inviteStdin))
|
|
777
|
+
throw new Error('choose exactly one of --invite-file <path> or --invite-stdin');
|
|
778
|
+
const invite = readFileSync(opts.inviteStdin ? 0 : String(opts.inviteFile), 'utf8').trim();
|
|
779
|
+
if (!invite)
|
|
780
|
+
throw new Error('invite input is empty');
|
|
781
|
+
if (Buffer.byteLength(invite) > MAX_INVITE_BYTES)
|
|
782
|
+
throw new Error(`invite input exceeds ${MAX_INVITE_BYTES} bytes`);
|
|
783
|
+
const result = await manageOwnerChannel(role, opts.configuration, {
|
|
784
|
+
action: 'contact_add', invite, ...(opts.name ? { name: String(opts.name) } : {}),
|
|
785
|
+
});
|
|
786
|
+
if (result.action !== 'contact_add')
|
|
787
|
+
throw new Error('unexpected owner-channel response');
|
|
788
|
+
console.log('Invite accepted; contact establishment is pending peer verification.');
|
|
789
|
+
console.log('No owner authority was granted. After the contact is established, authorize its exact CID separately.');
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
die(e);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
cOpt(ownerAuthorizationCommand.command('list <Role>')
|
|
796
|
+
.description('show baseline/dynamic source and effective authorization state'))
|
|
797
|
+
.action(async (role, opts) => {
|
|
798
|
+
try {
|
|
799
|
+
const result = await manageOwnerChannel(role, opts.configuration, { action: 'owner_list' });
|
|
800
|
+
if (result.action !== 'owner_list')
|
|
801
|
+
throw new Error('unexpected owner-channel response');
|
|
802
|
+
if (!result.integrity.ok)
|
|
803
|
+
console.error('warning: owner authorization overlay is corrupt; effective authorization is fail-closed');
|
|
804
|
+
console.log('CID\tSOURCE\tEFFECTIVE');
|
|
805
|
+
for (const owner of result.owners)
|
|
806
|
+
console.log(`${owner.cid}\t${owner.source}\t${owner.effective ? 'yes' : 'no'}`);
|
|
807
|
+
}
|
|
808
|
+
catch (e) {
|
|
809
|
+
die(e);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
cOpt(ownerAuthorizationCommand.command('authorize <Role> <contact-cid>')
|
|
813
|
+
.description('authorize an exact CID which is already an established contact'))
|
|
814
|
+
.action(async (role, cid, opts) => {
|
|
815
|
+
try {
|
|
816
|
+
assertContactCid(cid);
|
|
817
|
+
const result = await manageOwnerChannel(role, opts.configuration, {
|
|
818
|
+
action: 'owner_authorize', cid,
|
|
819
|
+
});
|
|
820
|
+
if (result.action !== 'owner_authorize')
|
|
821
|
+
throw new Error('unexpected owner-channel response');
|
|
822
|
+
console.log(`Authorized owner ${result.owner.cid} (${result.owner.source}).`);
|
|
823
|
+
}
|
|
824
|
+
catch (e) {
|
|
825
|
+
die(e);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
cOpt(ownerAuthorizationCommand.command('revoke <Role> <contact-cid>')
|
|
829
|
+
.description('revoke an exact effective owner CID; the last owner is protected'))
|
|
830
|
+
.action(async (role, cid, opts) => {
|
|
831
|
+
try {
|
|
832
|
+
assertContactCid(cid);
|
|
833
|
+
const result = await manageOwnerChannel(role, opts.configuration, {
|
|
834
|
+
action: 'owner_revoke', cid,
|
|
835
|
+
});
|
|
836
|
+
if (result.action !== 'owner_revoke')
|
|
837
|
+
throw new Error('unexpected owner-channel response');
|
|
838
|
+
console.log(`Revoked owner ${result.owner.cid} (${result.owner.source}).`);
|
|
839
|
+
}
|
|
840
|
+
catch (e) {
|
|
841
|
+
die(e);
|
|
842
|
+
}
|
|
436
843
|
});
|
|
437
844
|
/**
|
|
438
845
|
* A watchdog is addressable if it's still configured, or its store dir survives
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ConfigDiagnostic, type YamlMode } from './config-yaml.js';
|
|
2
2
|
import type { IsolationConfig, WrapContext } from './isolation/types.js';
|
|
3
3
|
import type { ResolvedWatchdog } from './watchdog/config.js';
|
|
4
|
+
import type { ResolvedLoop, ResolvedRoleLoop } from './loops/config.js';
|
|
4
5
|
export interface OverseeEntry {
|
|
5
6
|
role: string;
|
|
6
7
|
interval: string;
|
|
@@ -58,6 +59,30 @@ export interface MonitorConfig {
|
|
|
58
59
|
*/
|
|
59
60
|
turn_fail_threshold?: number;
|
|
60
61
|
}
|
|
62
|
+
/** A trusted, fleet-owned ours mailbox which is never bound inside the agent. */
|
|
63
|
+
export interface OwnerChannelConfig {
|
|
64
|
+
/** Existing ours identity exclusively bound by the fleet supervisor. */
|
|
65
|
+
identity: string;
|
|
66
|
+
/** Authenticated ours contact IDs allowed to issue owner instructions. */
|
|
67
|
+
owners: string[];
|
|
68
|
+
/** Cancel active work before each owner request instead of queueing it. */
|
|
69
|
+
interrupt: boolean;
|
|
70
|
+
/** Deterministic in-progress notice interval; 0 disables progress notices. */
|
|
71
|
+
progress_interval_ms: number;
|
|
72
|
+
attachments: OwnerAttachmentConfig;
|
|
73
|
+
}
|
|
74
|
+
export interface OwnerAttachmentConfig {
|
|
75
|
+
enabled: boolean;
|
|
76
|
+
max_files_per_request: number;
|
|
77
|
+
max_file_bytes: number;
|
|
78
|
+
max_request_bytes: number;
|
|
79
|
+
retention_ms: number;
|
|
80
|
+
allowed_mime: string[];
|
|
81
|
+
}
|
|
82
|
+
export type OwnerChannelConfigInput = Omit<Partial<OwnerChannelConfig>, 'attachments'> & {
|
|
83
|
+
attachments?: Partial<OwnerAttachmentConfig>;
|
|
84
|
+
};
|
|
85
|
+
export declare const DEFAULT_OWNER_ATTACHMENT_MIME: readonly ["application/pdf", "application/json", "text/plain", "image/png", "image/jpeg", "image/gif", "image/webp", "audio/ogg", "audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/webm", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation"];
|
|
61
86
|
/** Default wake sources when a role does not list its own (design §2). */
|
|
62
87
|
export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
|
|
63
88
|
/** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
|
|
@@ -84,10 +109,11 @@ export interface RoleConfig {
|
|
|
84
109
|
harness_options?: Record<string, unknown>;
|
|
85
110
|
isolation?: IsolationConfig;
|
|
86
111
|
monitor?: Partial<MonitorConfig>;
|
|
112
|
+
owner_channel?: OwnerChannelConfigInput;
|
|
87
113
|
worklog?: WorklogPolicy;
|
|
88
114
|
auth_proxy?: Partial<AuthProxyConfig>;
|
|
89
115
|
}
|
|
90
|
-
export interface ResolvedRole extends Omit<RoleConfig, 'model'> {
|
|
116
|
+
export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel'> {
|
|
91
117
|
name: string;
|
|
92
118
|
harness: string;
|
|
93
119
|
session: SessionBackendId;
|
|
@@ -103,8 +129,10 @@ export interface ResolvedRole extends Omit<RoleConfig, 'model'> {
|
|
|
103
129
|
model?: string;
|
|
104
130
|
sourceFile: string;
|
|
105
131
|
monitor: MonitorConfig;
|
|
132
|
+
owner_channel?: OwnerChannelConfig;
|
|
106
133
|
worklog?: WorklogPolicy;
|
|
107
134
|
auth_proxy?: AuthProxyConfig;
|
|
135
|
+
loops?: ResolvedRoleLoop[];
|
|
108
136
|
}
|
|
109
137
|
export interface FleetConfig {
|
|
110
138
|
roles: ResolvedRole[];
|
|
@@ -116,6 +144,7 @@ export interface FleetConfig {
|
|
|
116
144
|
/** Warning-first non-plain YAML migration diagnostics, in source order. */
|
|
117
145
|
diagnostics: ConfigDiagnostic[];
|
|
118
146
|
watchdogs: ResolvedWatchdog[];
|
|
147
|
+
loops: ResolvedLoop[];
|
|
119
148
|
}
|
|
120
149
|
export declare class ConfigError extends Error {
|
|
121
150
|
}
|
|
@@ -133,6 +162,7 @@ export declare const ROLE_NAME_RE: RegExp;
|
|
|
133
162
|
export declare function loadConfig(configPath?: string, options?: {
|
|
134
163
|
yamlMode?: YamlMode;
|
|
135
164
|
}): FleetConfig;
|
|
165
|
+
export declare function resolveOwnerChannelConfig(defaults: unknown, role: OwnerChannelConfigInput | undefined, session: SessionBackendId, file?: string, name?: string): OwnerChannelConfig | undefined;
|
|
136
166
|
export declare function resolveModelChain(model: string | undefined, chain: string[] | undefined, file?: string, name?: string): string[] | undefined;
|
|
137
167
|
export declare function resolveAuthProxy(defaults: unknown, role: Partial<AuthProxyConfig> | undefined, file?: string, name?: string): AuthProxyConfig | undefined;
|
|
138
168
|
export declare function resolveWorklogPolicy(defaults: unknown, role: WorklogPolicy | undefined, file?: string, name?: string): WorklogPolicy | undefined;
|