@ours.network/fleet 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -0
- package/dist/briefing.js +10 -0
- package/dist/cli.js +404 -1
- package/dist/config.d.ts +18 -2
- package/dist/config.js +67 -3
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +111 -0
- package/dist/duration.js +7 -3
- package/dist/harness/claude-code.js +5 -0
- package/dist/harness/types.d.ts +6 -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 +114 -2
- package/dist/owner-channel/channel.js +622 -43
- package/dist/owner-channel/notices.d.ts +21 -0
- package/dist/owner-channel/notices.js +66 -0
- package/dist/owner-channel/state.d.ts +34 -0
- package/dist/owner-channel/state.js +148 -1
- package/dist/owner-channel/tasks.d.ts +62 -0
- package/dist/owner-channel/tasks.js +246 -0
- package/dist/resolved-plan.js +11 -0
- package/dist/runner.js +87 -7
- package/dist/session/acp.d.ts +5 -2
- package/dist/session/acp.js +83 -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 +26 -2
- package/dist/session/types.js +5 -2
- package/dist/spawn.js +1 -0
- 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':
|
|
@@ -205,6 +231,15 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
205
231
|
console.log(` isolation: ${JSON.stringify(w.isolation)}`);
|
|
206
232
|
}
|
|
207
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
|
+
}
|
|
208
243
|
}
|
|
209
244
|
catch (e) {
|
|
210
245
|
die(e);
|
|
@@ -437,6 +472,374 @@ program.command('status <name>').description('unit/agent state')
|
|
|
437
472
|
console.log('session: acp control unavailable');
|
|
438
473
|
}
|
|
439
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
|
+
}
|
|
440
843
|
});
|
|
441
844
|
/**
|
|
442
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;
|
|
@@ -68,7 +69,20 @@ export interface OwnerChannelConfig {
|
|
|
68
69
|
interrupt: boolean;
|
|
69
70
|
/** Deterministic in-progress notice interval; 0 disables progress notices. */
|
|
70
71
|
progress_interval_ms: number;
|
|
72
|
+
attachments: OwnerAttachmentConfig;
|
|
71
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"];
|
|
72
86
|
/** Default wake sources when a role does not list its own (design §2). */
|
|
73
87
|
export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
|
|
74
88
|
/** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
|
|
@@ -95,7 +109,7 @@ export interface RoleConfig {
|
|
|
95
109
|
harness_options?: Record<string, unknown>;
|
|
96
110
|
isolation?: IsolationConfig;
|
|
97
111
|
monitor?: Partial<MonitorConfig>;
|
|
98
|
-
owner_channel?:
|
|
112
|
+
owner_channel?: OwnerChannelConfigInput;
|
|
99
113
|
worklog?: WorklogPolicy;
|
|
100
114
|
auth_proxy?: Partial<AuthProxyConfig>;
|
|
101
115
|
}
|
|
@@ -118,6 +132,7 @@ export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel'
|
|
|
118
132
|
owner_channel?: OwnerChannelConfig;
|
|
119
133
|
worklog?: WorklogPolicy;
|
|
120
134
|
auth_proxy?: AuthProxyConfig;
|
|
135
|
+
loops?: ResolvedRoleLoop[];
|
|
121
136
|
}
|
|
122
137
|
export interface FleetConfig {
|
|
123
138
|
roles: ResolvedRole[];
|
|
@@ -129,6 +144,7 @@ export interface FleetConfig {
|
|
|
129
144
|
/** Warning-first non-plain YAML migration diagnostics, in source order. */
|
|
130
145
|
diagnostics: ConfigDiagnostic[];
|
|
131
146
|
watchdogs: ResolvedWatchdog[];
|
|
147
|
+
loops: ResolvedLoop[];
|
|
132
148
|
}
|
|
133
149
|
export declare class ConfigError extends Error {
|
|
134
150
|
}
|
|
@@ -146,7 +162,7 @@ export declare const ROLE_NAME_RE: RegExp;
|
|
|
146
162
|
export declare function loadConfig(configPath?: string, options?: {
|
|
147
163
|
yamlMode?: YamlMode;
|
|
148
164
|
}): FleetConfig;
|
|
149
|
-
export declare function resolveOwnerChannelConfig(defaults: unknown, role:
|
|
165
|
+
export declare function resolveOwnerChannelConfig(defaults: unknown, role: OwnerChannelConfigInput | undefined, session: SessionBackendId, file?: string, name?: string): OwnerChannelConfig | undefined;
|
|
150
166
|
export declare function resolveModelChain(model: string | undefined, chain: string[] | undefined, file?: string, name?: string): string[] | undefined;
|
|
151
167
|
export declare function resolveAuthProxy(defaults: unknown, role: Partial<AuthProxyConfig> | undefined, file?: string, name?: string): AuthProxyConfig | undefined;
|
|
152
168
|
export declare function resolveWorklogPolicy(defaults: unknown, role: WorklogPolicy | undefined, file?: string, name?: string): WorklogPolicy | undefined;
|
package/dist/config.js
CHANGED
|
@@ -5,11 +5,21 @@ import { parseFleetDocument, } from './config-yaml.js';
|
|
|
5
5
|
import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
|
|
6
6
|
import { getAdapter } from './harness/registry.js';
|
|
7
7
|
import { resolveWatchdogs } from './watchdog/config.js';
|
|
8
|
+
import { resolveLoops } from './loops/config.js';
|
|
8
9
|
/** The 8 content-free event types the ours daemon appends to notifications.log. */
|
|
9
10
|
export const NOTIFY_EVENT_TYPES = [
|
|
10
11
|
'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
|
|
11
12
|
'pending_message', 'contact_restored', 'inbound_error', 'state_import_failed',
|
|
12
13
|
];
|
|
14
|
+
export const DEFAULT_OWNER_ATTACHMENT_MIME = [
|
|
15
|
+
'application/pdf', 'application/json', 'text/plain',
|
|
16
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
|
|
17
|
+
'audio/ogg', 'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/mp4', 'audio/webm',
|
|
18
|
+
'application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint',
|
|
19
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
20
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
21
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
22
|
+
];
|
|
13
23
|
/** Default wake sources when a role does not list its own (design §2). */
|
|
14
24
|
export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
|
|
15
25
|
const MONITOR_KEYS = [
|
|
@@ -223,6 +233,7 @@ export function loadConfig(configPath, options = {}) {
|
|
|
223
233
|
worklog,
|
|
224
234
|
auth_proxy: authProxy,
|
|
225
235
|
env: Object.keys(env).length ? env : undefined,
|
|
236
|
+
loops: [],
|
|
226
237
|
});
|
|
227
238
|
// Forbidden-path enforcement (5.2): a mount that would breach the policy
|
|
228
239
|
// is a configuration error, caught by `config` rather than at launch.
|
|
@@ -239,7 +250,13 @@ export function loadConfig(configPath, options = {}) {
|
|
|
239
250
|
}
|
|
240
251
|
validateOwnerChannelIdentities(roles);
|
|
241
252
|
const watchdogs = resolveWatchdogs(baseDoc, base, roles, vars, defaults);
|
|
242
|
-
|
|
253
|
+
const resolvedLoops = resolveLoops(baseDoc.loops, base, roles, vars);
|
|
254
|
+
for (const role of roles)
|
|
255
|
+
role.loops = resolvedLoops.byRole.get(role.name) ?? [];
|
|
256
|
+
return {
|
|
257
|
+
roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
|
|
258
|
+
loops: resolvedLoops.loops,
|
|
259
|
+
};
|
|
243
260
|
}
|
|
244
261
|
export function resolveOwnerChannelConfig(defaults, role, session, file = 'config', name = 'role') {
|
|
245
262
|
if (defaults === undefined && role === undefined)
|
|
@@ -248,11 +265,12 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
|
|
|
248
265
|
throw new ConfigError(`${file}: defaults.owner_channel must be a map`);
|
|
249
266
|
if (role !== undefined && !isPlainObject(role))
|
|
250
267
|
throw new ConfigError(`${file}: role '${name}' owner_channel must be a map`);
|
|
268
|
+
const defaultInput = (defaults ?? {});
|
|
251
269
|
const merged = {
|
|
252
|
-
...
|
|
270
|
+
...defaultInput,
|
|
253
271
|
...(role ?? {}),
|
|
254
272
|
};
|
|
255
|
-
const allowed = ['identity', 'owners', 'interrupt', 'progress_interval_ms'];
|
|
273
|
+
const allowed = ['identity', 'owners', 'interrupt', 'progress_interval_ms', 'attachments'];
|
|
256
274
|
const bad = Object.keys(merged).filter(key => !allowed.includes(key));
|
|
257
275
|
if (bad.length)
|
|
258
276
|
throw new ConfigError(`${file}: role '${name}' owner_channel: unknown key(s) ${bad.join(', ')}`);
|
|
@@ -270,6 +288,44 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
|
|
|
270
288
|
&& (typeof merged.progress_interval_ms !== 'number'
|
|
271
289
|
|| !Number.isFinite(merged.progress_interval_ms) || merged.progress_interval_ms < 0))
|
|
272
290
|
throw new ConfigError(`${file}: role '${name}' owner_channel.progress_interval_ms must be a non-negative number`);
|
|
291
|
+
if (defaultInput.attachments !== undefined && !isPlainObject(defaultInput.attachments))
|
|
292
|
+
throw new ConfigError(`${file}: defaults.owner_channel.attachments must be a map`);
|
|
293
|
+
if (role?.attachments !== undefined && !isPlainObject(role.attachments))
|
|
294
|
+
throw new ConfigError(`${file}: role '${name}' owner_channel.attachments must be a map`);
|
|
295
|
+
const attachments = {
|
|
296
|
+
...(defaultInput.attachments ?? {}), ...(role?.attachments ?? {}),
|
|
297
|
+
};
|
|
298
|
+
const attachmentKeys = [
|
|
299
|
+
'enabled', 'max_files_per_request', 'max_file_bytes', 'max_request_bytes',
|
|
300
|
+
'retention_ms', 'allowed_mime',
|
|
301
|
+
];
|
|
302
|
+
const badAttachmentKeys = Object.keys(attachments)
|
|
303
|
+
.filter(key => !attachmentKeys.includes(key));
|
|
304
|
+
if (badAttachmentKeys.length)
|
|
305
|
+
throw new ConfigError(`${file}: role '${name}' owner_channel.attachments: unknown key(s) ${badAttachmentKeys.join(', ')}`);
|
|
306
|
+
if (attachments.enabled !== undefined && typeof attachments.enabled !== 'boolean')
|
|
307
|
+
throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.enabled must be true or false`);
|
|
308
|
+
const boundedInteger = (key, min, max) => {
|
|
309
|
+
const value = attachments[key];
|
|
310
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value)
|
|
311
|
+
|| value < min || value > max))
|
|
312
|
+
throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.${key} must be an integer from ${min} to ${max}`);
|
|
313
|
+
};
|
|
314
|
+
boundedInteger('max_files_per_request', 1, 32);
|
|
315
|
+
boundedInteger('max_file_bytes', 1, 100 * 1024 * 1024);
|
|
316
|
+
boundedInteger('max_request_bytes', 1, 256 * 1024 * 1024);
|
|
317
|
+
boundedInteger('retention_ms', 60_000, 30 * 24 * 60 * 60 * 1_000);
|
|
318
|
+
const maxFileBytes = attachments.max_file_bytes ?? 10 * 1024 * 1024;
|
|
319
|
+
const maxRequestBytes = attachments.max_request_bytes ?? 20 * 1024 * 1024;
|
|
320
|
+
if (maxRequestBytes < maxFileBytes)
|
|
321
|
+
throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.max_request_bytes must be at least max_file_bytes`);
|
|
322
|
+
const allowedMime = attachments.allowed_mime ?? [...DEFAULT_OWNER_ATTACHMENT_MIME];
|
|
323
|
+
if (!Array.isArray(allowedMime) || allowedMime.length < 1 || allowedMime.length > 64
|
|
324
|
+
|| allowedMime.some(mime => typeof mime !== 'string'
|
|
325
|
+
|| !/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(mime)))
|
|
326
|
+
throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.allowed_mime must contain 1-64 lowercase MIME types`);
|
|
327
|
+
if (new Set(allowedMime).size !== allowedMime.length)
|
|
328
|
+
throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.allowed_mime must not contain duplicates`);
|
|
273
329
|
if (session !== 'acp')
|
|
274
330
|
throw new ConfigError(`${file}: role '${name}' owner_channel requires session: acp for correlated final replies`);
|
|
275
331
|
return {
|
|
@@ -277,6 +333,14 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
|
|
|
277
333
|
owners,
|
|
278
334
|
interrupt: merged.interrupt ?? false,
|
|
279
335
|
progress_interval_ms: merged.progress_interval_ms ?? 30_000,
|
|
336
|
+
attachments: {
|
|
337
|
+
enabled: attachments.enabled ?? true,
|
|
338
|
+
max_files_per_request: attachments.max_files_per_request ?? 4,
|
|
339
|
+
max_file_bytes: maxFileBytes,
|
|
340
|
+
max_request_bytes: maxRequestBytes,
|
|
341
|
+
retention_ms: attachments.retention_ms ?? 24 * 60 * 60 * 1_000,
|
|
342
|
+
allowed_mime: [...allowedMime],
|
|
343
|
+
},
|
|
280
344
|
};
|
|
281
345
|
}
|
|
282
346
|
function validateOwnerChannelIdentities(roles) {
|