@ours.network/fleet 1.1.0-nightly.5 → 1.1.0-nightly.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-recovery-gate.d.ts +17 -0
- package/dist/agent-recovery-gate.js +135 -0
- package/dist/application/fleet-query-service.js +13 -1
- package/dist/application/role-creation-service.js +11 -0
- package/dist/briefing.js +6 -2
- package/dist/build-info.json +4 -4
- package/dist/cli.js +95 -8
- package/dist/daemon-recovery.d.ts +93 -0
- package/dist/daemon-recovery.js +328 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +15 -5
- package/dist/doctor.js +14 -0
- package/dist/fleet-command-audit.d.ts +131 -0
- package/dist/fleet-command-audit.js +375 -0
- package/dist/fleet-proxy.d.ts +2 -0
- package/dist/owner-channel/channel.d.ts +56 -0
- package/dist/owner-channel/channel.js +251 -17
- package/dist/rooms-tasks/cli.js +121 -18
- package/dist/rooms-tasks/external-worker.d.ts +1 -0
- package/dist/rooms-tasks/external-worker.js +14 -9
- package/dist/runner.d.ts +9 -2
- package/dist/runner.js +81 -29
- package/dist/session/control.d.ts +14 -1
- package/dist/session/control.js +35 -0
- package/dist/temp-lifecycle.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ConversationEventV1 } from './session/conversation-types.js';
|
|
2
|
+
import type { AgentSession } from './session/types.js';
|
|
3
|
+
export interface AgentRecoveryEvidence {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
reason: 'RECOVERY_TOOLS_VERIFIED' | 'RECOVERY_PROMPT_MISSING' | 'RECOVERY_TURN_INCOMPLETE' | 'RECOVERY_TURN_FAILED' | 'RECOVERY_CHOOSE_MISSING' | 'RECOVERY_CURRENT_MISSING' | 'RECOVERY_GET_MESSAGES_MISSING' | 'RECOVERY_TOOL_ORDER_INVALID';
|
|
6
|
+
chooseIdentity: boolean;
|
|
7
|
+
currentIdentity: boolean;
|
|
8
|
+
getMessages: boolean;
|
|
9
|
+
turnCompleted: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Verify one exact recovery turn from its durable conversation ledger. Tool
|
|
13
|
+
* arguments/results are inspected only to derive these booleans and are never
|
|
14
|
+
* returned or persisted by this gate.
|
|
15
|
+
*/
|
|
16
|
+
export declare function evaluateAgentRecovery(events: readonly ConversationEventV1[], promptId: string, identity: string): AgentRecoveryEvidence;
|
|
17
|
+
export declare function recoverAgentIdentity(session: AgentSession, identity: string): Promise<AgentRecoveryEvidence>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const TOOL_NAMES = {
|
|
2
|
+
choose: new Set(['choose_identity', 'ours.choose_identity', 'mcp__ours__choose_identity']),
|
|
3
|
+
current: new Set(['current_identity', 'ours.current_identity', 'mcp__ours__current_identity']),
|
|
4
|
+
messages: new Set(['get_messages', 'ours.get_messages', 'mcp__ours__get_messages']),
|
|
5
|
+
};
|
|
6
|
+
function recordFor(records, event) {
|
|
7
|
+
if (event.kind !== 'tool.upsert' || !event.toolCallId)
|
|
8
|
+
return undefined;
|
|
9
|
+
const payload = event.payload;
|
|
10
|
+
const previous = records.get(event.toolCallId) ?? {};
|
|
11
|
+
const next = {
|
|
12
|
+
...previous,
|
|
13
|
+
...(payload.title !== undefined ? { title: payload.title } : {}),
|
|
14
|
+
...(payload.status !== undefined ? { status: payload.status } : {}),
|
|
15
|
+
...(payload.rawInput !== undefined ? { rawInput: payload.rawInput } : {}),
|
|
16
|
+
...(payload.status === 'completed' ? { completedSeq: event.seq } : {}),
|
|
17
|
+
};
|
|
18
|
+
records.set(event.toolCallId, next);
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
function objectInput(input) {
|
|
22
|
+
if (!input || input.truncated || input.redacted || !input.json
|
|
23
|
+
|| typeof input.json !== 'object' || Array.isArray(input.json))
|
|
24
|
+
return undefined;
|
|
25
|
+
return input.json;
|
|
26
|
+
}
|
|
27
|
+
function safeChoose(record, identity) {
|
|
28
|
+
if (!record.title || !TOOL_NAMES.choose.has(record.title) || record.status !== 'completed')
|
|
29
|
+
return false;
|
|
30
|
+
const input = objectInput(record.rawInput);
|
|
31
|
+
if (!input)
|
|
32
|
+
return false;
|
|
33
|
+
const keys = Object.keys(input).sort();
|
|
34
|
+
if (keys.some(key => key !== 'force' && key !== 'name'))
|
|
35
|
+
return false;
|
|
36
|
+
return input.name === identity && (input.force === undefined || input.force === false);
|
|
37
|
+
}
|
|
38
|
+
function safeNoOrBoundedInput(record, names, allowed) {
|
|
39
|
+
if (!record.title || !names.has(record.title) || record.status !== 'completed')
|
|
40
|
+
return false;
|
|
41
|
+
if (!record.rawInput)
|
|
42
|
+
return allowed({});
|
|
43
|
+
const input = objectInput(record.rawInput);
|
|
44
|
+
return input !== undefined && allowed(input);
|
|
45
|
+
}
|
|
46
|
+
function safeCurrent(record) {
|
|
47
|
+
return safeNoOrBoundedInput(record, TOOL_NAMES.current, input => Object.keys(input).length === 0);
|
|
48
|
+
}
|
|
49
|
+
function safeMessages(record) {
|
|
50
|
+
return safeNoOrBoundedInput(record, TOOL_NAMES.messages, input => {
|
|
51
|
+
const keys = Object.keys(input);
|
|
52
|
+
if (keys.some(key => key !== 'limit'))
|
|
53
|
+
return false;
|
|
54
|
+
return input.limit === undefined
|
|
55
|
+
|| (Number.isSafeInteger(input.limit) && input.limit >= 1 && input.limit <= 200);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Verify one exact recovery turn from its durable conversation ledger. Tool
|
|
60
|
+
* arguments/results are inspected only to derive these booleans and are never
|
|
61
|
+
* returned or persisted by this gate.
|
|
62
|
+
*/
|
|
63
|
+
export function evaluateAgentRecovery(events, promptId, identity) {
|
|
64
|
+
const ordered = [...events].sort((a, b) => a.seq - b.seq);
|
|
65
|
+
const admitted = ordered.find(event => event.kind === 'prompt.admitted' && event.promptId === promptId);
|
|
66
|
+
const empty = (reason) => ({
|
|
67
|
+
ok: false, reason, chooseIdentity: false, currentIdentity: false,
|
|
68
|
+
getMessages: false, turnCompleted: false,
|
|
69
|
+
});
|
|
70
|
+
if (!admitted)
|
|
71
|
+
return empty('RECOVERY_PROMPT_MISSING');
|
|
72
|
+
const terminal = ordered.find(event => event.seq > admitted.seq
|
|
73
|
+
&& event.kind === 'turn.completed'
|
|
74
|
+
&& event.promptId === promptId
|
|
75
|
+
&& event.sessionGeneration === admitted.sessionGeneration);
|
|
76
|
+
if (!terminal)
|
|
77
|
+
return empty('RECOVERY_TURN_INCOMPLETE');
|
|
78
|
+
const outcome = terminal.payload.outcome;
|
|
79
|
+
if (outcome !== 'completed')
|
|
80
|
+
return empty('RECOVERY_TURN_FAILED');
|
|
81
|
+
const records = new Map();
|
|
82
|
+
for (const event of ordered) {
|
|
83
|
+
if (event.seq <= admitted.seq || event.seq >= terminal.seq
|
|
84
|
+
|| event.promptId !== promptId
|
|
85
|
+
|| event.sessionGeneration !== admitted.sessionGeneration)
|
|
86
|
+
continue;
|
|
87
|
+
recordFor(records, event);
|
|
88
|
+
}
|
|
89
|
+
const values = [...records.values()];
|
|
90
|
+
const chooses = values.filter(record => safeChoose(record, identity));
|
|
91
|
+
const currents = values.filter(safeCurrent);
|
|
92
|
+
const messages = values.filter(safeMessages);
|
|
93
|
+
const chooseIdentity = chooses.length > 0;
|
|
94
|
+
const currentIdentity = currents.length > 0;
|
|
95
|
+
const getMessages = messages.length > 0;
|
|
96
|
+
const base = { chooseIdentity, currentIdentity, getMessages, turnCompleted: true };
|
|
97
|
+
if (!chooseIdentity)
|
|
98
|
+
return { ok: false, reason: 'RECOVERY_CHOOSE_MISSING', ...base };
|
|
99
|
+
if (!currentIdentity)
|
|
100
|
+
return { ok: false, reason: 'RECOVERY_CURRENT_MISSING', ...base };
|
|
101
|
+
if (!getMessages)
|
|
102
|
+
return { ok: false, reason: 'RECOVERY_GET_MESSAGES_MISSING', ...base };
|
|
103
|
+
const orderedChain = chooses.some(choose => currents.some(current => messages.some(message => choose.completedSeq !== undefined && current.completedSeq !== undefined
|
|
104
|
+
&& message.completedSeq !== undefined
|
|
105
|
+
&& choose.completedSeq < current.completedSeq
|
|
106
|
+
&& current.completedSeq < message.completedSeq)));
|
|
107
|
+
if (!orderedChain)
|
|
108
|
+
return { ok: false, reason: 'RECOVERY_TOOL_ORDER_INVALID', ...base };
|
|
109
|
+
return { ok: true, reason: 'RECOVERY_TOOLS_VERIFIED', ...base };
|
|
110
|
+
}
|
|
111
|
+
export async function recoverAgentIdentity(session, identity) {
|
|
112
|
+
if (!session.subscribeConversation)
|
|
113
|
+
return {
|
|
114
|
+
ok: false, reason: 'RECOVERY_PROMPT_MISSING', chooseIdentity: false,
|
|
115
|
+
currentIdentity: false, getMessages: false, turnCompleted: false,
|
|
116
|
+
};
|
|
117
|
+
const events = [];
|
|
118
|
+
const unsubscribe = session.subscribeConversation(event => events.push(event));
|
|
119
|
+
try {
|
|
120
|
+
const queued = await session.queuePrompt([
|
|
121
|
+
'[fleet-recovery] The shared ours daemon restarted.',
|
|
122
|
+
`Call ours choose_identity with name ${JSON.stringify(identity)} and force false.`,
|
|
123
|
+
'Then call current_identity, then get_messages. Complete all three in that order.',
|
|
124
|
+
'Do not create/delete identities, force-bind, interrupt, or restart any service/session.',
|
|
125
|
+
].join('\n'), { origin: { kind: 'fleet-monitor' } });
|
|
126
|
+
await queued.completion;
|
|
127
|
+
// Conversation publication is synchronous with terminal settlement in the
|
|
128
|
+
// in-tree ACP store; filtering by exact promptId/sessionGeneration remains
|
|
129
|
+
// the authority even if unrelated events arrived concurrently.
|
|
130
|
+
return evaluateAgentRecovery(events, queued.promptId, identity);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
unsubscribe();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -4,6 +4,7 @@ import { classifyActivity } from '../session/activity.js';
|
|
|
4
4
|
import { controlRequest } from '../session/control.js';
|
|
5
5
|
import { SessionControlError } from '../session/types.js';
|
|
6
6
|
import { readExitRecord, readRestartLedger } from '../runner.js';
|
|
7
|
+
import { readDaemonRecoveryStatus } from '../daemon-recovery.js';
|
|
7
8
|
import { roleCapabilities } from './capabilities.js';
|
|
8
9
|
import { FleetError } from './errors.js';
|
|
9
10
|
const clean = (value, max = 512) => value.replace(/[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').trim().slice(0, max);
|
|
@@ -46,7 +47,8 @@ function readIsolation(dir) {
|
|
|
46
47
|
function sessionOverall(supervisor, session, restart, monitor, isolation, problems) {
|
|
47
48
|
if (restart.circuit === 'open' || monitor.health === 'failed' || monitor.health === 'degraded'
|
|
48
49
|
|| isolation.degraded
|
|
49
|
-
|| problems.some(problem => problem.severity === 'error'
|
|
50
|
+
|| problems.some(problem => problem.severity === 'error'
|
|
51
|
+
|| problem.source === 'watchdog' || problem.source === 'daemon-recovery')
|
|
50
52
|
|| session.readiness === 'failed')
|
|
51
53
|
return 'attention';
|
|
52
54
|
// A reachable agent session is the user's live interaction surface. Its
|
|
@@ -103,6 +105,16 @@ export class FleetQueryService {
|
|
|
103
105
|
const lastExit = dir ? readExitRecord(join(dir, '.exit-status')) ?? undefined : undefined;
|
|
104
106
|
const session = await this.session(role, dir, live.state);
|
|
105
107
|
const problems = [...role.problems];
|
|
108
|
+
const recovery = dir ? readDaemonRecoveryStatus(dir) : undefined;
|
|
109
|
+
if (recovery && recovery.state !== 'recovered') {
|
|
110
|
+
const paths = ['agent', 'owner']
|
|
111
|
+
.filter(name => recovery.paths[name].state !== 'recovered')
|
|
112
|
+
.map(name => `${name}:${recovery.paths[name].state}`);
|
|
113
|
+
problems.push({
|
|
114
|
+
code: 'daemon_recovery', severity: 'warning', source: 'daemon-recovery',
|
|
115
|
+
detail: `${recovery.state}; ${paths.join(', ') || 'no degraded path'}; epoch ${recovery.epoch || 'unavailable'}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
106
118
|
if (live.state === 'running' && session.reachability !== 'online')
|
|
107
119
|
problems.push({
|
|
108
120
|
code: 'supervisor_session_disagreement', severity: 'warning',
|
|
@@ -61,6 +61,16 @@ export class RoleCreationService {
|
|
|
61
61
|
const creationActionId = randomUUID();
|
|
62
62
|
plan.options.creationActionId = creationActionId;
|
|
63
63
|
const statePath = await this.launchSync(plan.options);
|
|
64
|
+
const selectionSummary = (kind) => {
|
|
65
|
+
const value = plan.options[kind];
|
|
66
|
+
const provenance = plan.inherited.includes(kind) ? 'inherited' : 'explicit';
|
|
67
|
+
if (!value)
|
|
68
|
+
return `unresolved (${provenance})`;
|
|
69
|
+
if ('ref' in value)
|
|
70
|
+
return `ref:${value.ref} (${provenance})`;
|
|
71
|
+
const fingerprint = createHash('sha256').update(JSON.stringify(value.inline)).digest('hex').slice(0, 16);
|
|
72
|
+
return `inline:sha256:${fingerprint} (${provenance})`;
|
|
73
|
+
};
|
|
64
74
|
return {
|
|
65
75
|
caller: plan.caller, role: plan.options.name,
|
|
66
76
|
lifetime: plan.options.temp ? 'temporary' : 'permanent', statePath,
|
|
@@ -69,6 +79,7 @@ export class RoleCreationService {
|
|
|
69
79
|
monitor: { mode: plan.preview.resolvedRole.monitor.mode, interrupt: plan.preview.resolvedRole.monitor.interrupt },
|
|
70
80
|
permissionMode: effectivePermissionMode(plan.preview.resolvedRole), inherited: plan.inherited,
|
|
71
81
|
creationActionId,
|
|
82
|
+
brainSummary: selectionSummary('brain'), roleSummary: selectionSummary('role'),
|
|
72
83
|
};
|
|
73
84
|
}
|
|
74
85
|
launchSync(options, creation) {
|
package/dist/briefing.js
CHANGED
|
@@ -172,12 +172,16 @@ export function generateBriefing(role, v, opts) {
|
|
|
172
172
|
if (role.session === 'acp') {
|
|
173
173
|
L.push('', '### Managed fleet commands');
|
|
174
174
|
L.push('This ACP role has a supervisor-scoped ours-fleet proxy. Use the ordinary');
|
|
175
|
-
L.push('`ours-fleet
|
|
176
|
-
L.push('
|
|
175
|
+
L.push('`ours-fleet` command; the CLI opens an authenticated audit attempt with your live');
|
|
176
|
+
L.push('supervisor before parsing or side effects. Your existing OS sandbox remains the executor.');
|
|
177
|
+
L.push('Every allowed attempt writes a redacted raw invocation and correlated outcome to your');
|
|
178
|
+
L.push('Owner-visible channel; denied, invalid, failed, and read-only attempts are visible too.');
|
|
177
179
|
L.push('A minimal call is `ours-fleet spawn DeveloperName --temp`.');
|
|
178
180
|
L.push('For omitted settings, the supervisor inherits your canonical Brain and Role selections,');
|
|
179
181
|
L.push('working directory, neutral permissions, coordinator, and fleet monitor policy. Every');
|
|
180
182
|
L.push('explicit option wins; identity/session-local and secret material never inherit.');
|
|
183
|
+
L.push('Operator lifecycle/control and hidden worker commands are denied through this route.');
|
|
184
|
+
L.push('Invocation delivery failure denies execution; outcome uncertainty is reported without retrying effects.');
|
|
181
185
|
L.push('This proxy is attribution and convenience, not a security boundary for unisolated roles.');
|
|
182
186
|
}
|
|
183
187
|
if (role.coordinator) {
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.1.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.1.0-nightly.7",
|
|
3
|
+
"buildId": "a09aa98e92aa",
|
|
4
|
+
"commit": "d79160ae66847305a109bed339d8c8830a20bba7",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-08-
|
|
6
|
+
"builtAt": "2026-08-31T07:32:26.852Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"monitor.interrupt.after_tool"
|
|
9
9
|
]
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn as spawnChild } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
5
|
import { realpathSync } from 'node:fs';
|
|
5
6
|
import { join as joinPath, resolve as resolvePath } from 'node:path';
|
|
@@ -37,6 +38,7 @@ import { requestWebControl } from './web/control.js';
|
|
|
37
38
|
import { WebServiceManager } from './web/service.js';
|
|
38
39
|
import { WebAccessStore, passwordAccess, validatePublicOrigin } from './web/access.js';
|
|
39
40
|
import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-proxy.js';
|
|
41
|
+
import { beginFleetAuditCollection, consumeFleetAuditCollection, FleetCliExit, recordFleetAuditPresentation, recordFleetAuditResource, } from './fleet-command-audit.js';
|
|
40
42
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
41
43
|
import './harness/codex.js'; // registers the codex adapter
|
|
42
44
|
import { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './rooms-tasks/cli.js';
|
|
@@ -66,7 +68,10 @@ const roleLifecycle = (configPath, operationDeps) => {
|
|
|
66
68
|
return new RoleLifecycleService({ repository, ops: operationDeps, configPath,
|
|
67
69
|
status: async (roleId) => (await query.detail(roleId)).status });
|
|
68
70
|
};
|
|
69
|
-
const die = (e) => {
|
|
71
|
+
const die = (e) => {
|
|
72
|
+
console.error(String(e instanceof Error ? e.message : e));
|
|
73
|
+
throw new FleetCliExit(1);
|
|
74
|
+
};
|
|
70
75
|
/** Exec a child with our stdio (logs/attach). */
|
|
71
76
|
const passthrough = (cmd, args) => new Promise(resolve => {
|
|
72
77
|
const c = spawnChild(cmd, args, { stdio: 'inherit' });
|
|
@@ -76,6 +81,7 @@ const program = new Command()
|
|
|
76
81
|
.name('ours-fleet')
|
|
77
82
|
.description('Fleet of persistent, identity-bound AI agents — canonical Brain + Role definitions over ACP sessions.')
|
|
78
83
|
.enablePositionalOptions()
|
|
84
|
+
.exitOverride()
|
|
79
85
|
.version(VERSION);
|
|
80
86
|
const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'manifest (default: ~/fleet.yaml; documents under ~/fleet/)');
|
|
81
87
|
const collect = (value, previous) => [...previous, value];
|
|
@@ -1192,6 +1198,13 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
|
|
|
1192
1198
|
if (!response.ok)
|
|
1193
1199
|
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'managed spawn failed');
|
|
1194
1200
|
const result = response.result;
|
|
1201
|
+
recordFleetAuditResource('agent', result.role);
|
|
1202
|
+
recordFleetAuditPresentation({ kind: 'agent_started', name: result.role,
|
|
1203
|
+
lifetime: result.lifetime, brain: result.brainSummary, role: result.roleSummary,
|
|
1204
|
+
harness: result.harness, session: result.session, model: result.model,
|
|
1205
|
+
permissions: result.permissionMode
|
|
1206
|
+
? `${result.permissionMode.fleetMode}/${result.permissionMode.nativeMode}` : undefined,
|
|
1207
|
+
parent: result.caller, actionId: result.creationActionId, inherited: result.inherited });
|
|
1195
1208
|
const expectedCaller = process.env[FLEET_PROXY_CALLER_ENV];
|
|
1196
1209
|
if (expectedCaller && result.caller !== expectedCaller)
|
|
1197
1210
|
throw new Error(`fleet proxy caller mismatch: expected '${expectedCaller}', got '${result.caller}'`);
|
|
@@ -1248,7 +1261,8 @@ cOpt(program.command('doctor').description('prerequisite report'))
|
|
|
1248
1261
|
});
|
|
1249
1262
|
for (const c of rep.checks)
|
|
1250
1263
|
console.log(`${c.ok ? 'ok ' : 'MISS'} ${c.name.padEnd(22)} ${c.detail}`);
|
|
1251
|
-
|
|
1264
|
+
if (!rep.ok)
|
|
1265
|
+
throw new FleetCliExit(1);
|
|
1252
1266
|
});
|
|
1253
1267
|
program.command('init').description('one-time host setup (units, dirs, linger)')
|
|
1254
1268
|
.action(async () => {
|
|
@@ -1497,11 +1511,84 @@ cOpt(program.command('_run-watchdogs', { hidden: true }))
|
|
|
1497
1511
|
die(e);
|
|
1498
1512
|
}
|
|
1499
1513
|
});
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1514
|
+
async function parseFleetCli() {
|
|
1515
|
+
const spawnIndex = process.argv.indexOf('spawn');
|
|
1516
|
+
if (spawnIndex >= 0 && process.argv.slice(spawnIndex + 1).some(arg => arg === '--harness' || arg.startsWith('--harness=') || arg === '--model' || arg.startsWith('--model='))) {
|
|
1517
|
+
console.error('error: --harness and --model were removed; select a Brain with --brain');
|
|
1518
|
+
throw new FleetCliExit(1);
|
|
1519
|
+
}
|
|
1520
|
+
try {
|
|
1521
|
+
await program.parseAsync(process.argv);
|
|
1522
|
+
}
|
|
1523
|
+
catch (error) {
|
|
1524
|
+
const commander = error;
|
|
1525
|
+
if (commander.exitCode === 0)
|
|
1526
|
+
return;
|
|
1527
|
+
if (typeof commander.exitCode === 'number')
|
|
1528
|
+
throw new FleetCliExit(commander.exitCode, 'validation', 'not_started');
|
|
1529
|
+
throw error;
|
|
1530
|
+
}
|
|
1504
1531
|
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1532
|
+
async function runFleetCli() {
|
|
1533
|
+
const stateDir = process.env[FLEET_PROXY_STATE_DIR_ENV];
|
|
1534
|
+
const caller = process.env[FLEET_PROXY_CALLER_ENV];
|
|
1535
|
+
if (!stateDir || !caller) {
|
|
1536
|
+
await parseFleetCli();
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const requestId = randomUUID();
|
|
1540
|
+
let attempt;
|
|
1541
|
+
const begun = await controlRequest(stateDir, {
|
|
1542
|
+
command: 'fleet_audit_begin', audit: { requestId, argv: process.argv.slice(2) },
|
|
1543
|
+
});
|
|
1544
|
+
if (!begun.ok)
|
|
1545
|
+
throw new SessionControlError(begun.kind ?? 'rejected', begun.error ?? 'fleet command audit refused');
|
|
1546
|
+
attempt = begun.result;
|
|
1547
|
+
if (attempt.caller !== caller)
|
|
1548
|
+
throw new SessionControlError('rejected', `fleet audit caller mismatch: expected '${caller}', got '${attempt.caller}'`);
|
|
1549
|
+
let exitCode = 0;
|
|
1550
|
+
let outcomeClass = 'success';
|
|
1551
|
+
let effect = 'completed';
|
|
1552
|
+
beginFleetAuditCollection();
|
|
1553
|
+
if (attempt.classification.decision !== 'allow') {
|
|
1554
|
+
exitCode = 1;
|
|
1555
|
+
outcomeClass = 'denied';
|
|
1556
|
+
effect = 'not_started';
|
|
1557
|
+
console.error(`fleet supervisor proxy ${attempt.classification.decision}: ${attempt.classification.command}`);
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
const originalExit = process.exit;
|
|
1561
|
+
process.exit = ((code) => { throw new FleetCliExit(code ?? 0); });
|
|
1562
|
+
try {
|
|
1563
|
+
await parseFleetCli();
|
|
1564
|
+
}
|
|
1565
|
+
catch (error) {
|
|
1566
|
+
exitCode = error instanceof FleetCliExit ? error.exitCode : 1;
|
|
1567
|
+
outcomeClass = error instanceof FleetCliExit ? error.outcomeClass : 'runtime';
|
|
1568
|
+
effect = error instanceof FleetCliExit ? error.effect : 'unknown';
|
|
1569
|
+
if (!(error instanceof FleetCliExit))
|
|
1570
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1571
|
+
}
|
|
1572
|
+
finally {
|
|
1573
|
+
process.exit = originalExit;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
const metadata = consumeFleetAuditCollection();
|
|
1577
|
+
if (metadata.failure) {
|
|
1578
|
+
outcomeClass = metadata.failure.class;
|
|
1579
|
+
effect = metadata.failure.effect;
|
|
1580
|
+
}
|
|
1581
|
+
const finished = await controlRequest(stateDir, { command: 'fleet_audit_finish', audit: {
|
|
1582
|
+
correlationId: attempt.correlationId, class: outcomeClass, exitCode, effect,
|
|
1583
|
+
...(metadata.resourceIds ? { resourceIds: metadata.resourceIds } : {}),
|
|
1584
|
+
...(metadata.presentation ? { presentation: metadata.presentation } : {}),
|
|
1585
|
+
} });
|
|
1586
|
+
if (!finished.ok)
|
|
1587
|
+
throw new SessionControlError(finished.kind ?? 'backend', finished.error ?? `audit delivery failed after execution; effect status=${effect}`);
|
|
1588
|
+
process.exitCode = exitCode;
|
|
1507
1589
|
}
|
|
1590
|
+
void runFleetCli().catch(error => {
|
|
1591
|
+
if (!(error instanceof FleetCliExit))
|
|
1592
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1593
|
+
process.exitCode = error instanceof FleetCliExit ? error.exitCode : 1;
|
|
1594
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type FetchLike } from './monitor.js';
|
|
2
|
+
export declare const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
|
|
3
|
+
export declare const DAEMON_RECOVERY_INITIAL_BACKOFF_MS = 1000;
|
|
4
|
+
export declare const DAEMON_RECOVERY_MAX_BACKOFF_MS = 5000;
|
|
5
|
+
export declare const DAEMON_RECOVERY_DEADLINE_MS = 60000;
|
|
6
|
+
export interface DaemonGeneration {
|
|
7
|
+
bootId: string;
|
|
8
|
+
pid: number;
|
|
9
|
+
startedAt: number;
|
|
10
|
+
stateDir: string;
|
|
11
|
+
}
|
|
12
|
+
export type DaemonGenerationProbe = {
|
|
13
|
+
state: 'ready';
|
|
14
|
+
generation: DaemonGeneration;
|
|
15
|
+
} | {
|
|
16
|
+
state: 'unavailable';
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type DaemonGenerationObservation = {
|
|
20
|
+
kind: 'baseline' | 'stable' | 'available';
|
|
21
|
+
generation: DaemonGeneration;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'changed';
|
|
24
|
+
previous: DaemonGeneration;
|
|
25
|
+
generation: DaemonGeneration;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'lost' | 'unavailable';
|
|
28
|
+
previous?: DaemonGeneration;
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
interface GenerationProbeDeps {
|
|
32
|
+
readText?(path: string): string;
|
|
33
|
+
canonicalize?(path: string): string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Corroborate the loopback daemon's unauthenticated `/info`, its credentialed
|
|
37
|
+
* identity-index readiness route, and its local boot-generation record. The
|
|
38
|
+
* index enforces auth when the daemon visibility requires it; open visibility
|
|
39
|
+
* deliberately does not. No source is sufficient
|
|
40
|
+
* alone: `/info` has no boot id, the identity index has no generation, and a
|
|
41
|
+
* stale `ready` file can outlive the process that wrote it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function probeDaemonGeneration(fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: GenerationProbeDeps): Promise<DaemonGenerationProbe>;
|
|
44
|
+
export declare class DaemonGenerationObserver {
|
|
45
|
+
private current?;
|
|
46
|
+
private unavailable;
|
|
47
|
+
observe(probe: DaemonGenerationProbe): DaemonGenerationObservation;
|
|
48
|
+
}
|
|
49
|
+
export declare function daemonRecoveryBackoff(attempt: number): number;
|
|
50
|
+
export type RecoveryPath = 'agent' | 'owner';
|
|
51
|
+
export type RecoveryPathResult = {
|
|
52
|
+
ok: true;
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
reason: string;
|
|
56
|
+
};
|
|
57
|
+
export interface RoleRecoveryControllerOptions {
|
|
58
|
+
role: string;
|
|
59
|
+
identity: string;
|
|
60
|
+
stateDir: string;
|
|
61
|
+
now(): number;
|
|
62
|
+
sleep(ms: number): Promise<void>;
|
|
63
|
+
recoverAgent(epoch: string): Promise<RecoveryPathResult>;
|
|
64
|
+
recoverOwner(epoch: string): Promise<RecoveryPathResult>;
|
|
65
|
+
log(line: string): void;
|
|
66
|
+
}
|
|
67
|
+
export interface RecoveryStatus {
|
|
68
|
+
version: 1;
|
|
69
|
+
identity: string;
|
|
70
|
+
epoch: string;
|
|
71
|
+
state: 'recovering' | 'recovered' | 'degraded' | 'cancelled';
|
|
72
|
+
paths: Record<RecoveryPath, {
|
|
73
|
+
state: 'pending' | 'recovered' | 'degraded';
|
|
74
|
+
attempts: number;
|
|
75
|
+
reason?: string;
|
|
76
|
+
}>;
|
|
77
|
+
updatedAt: string;
|
|
78
|
+
}
|
|
79
|
+
export declare function readDaemonRecoveryStatus(dir: string): RecoveryStatus | undefined;
|
|
80
|
+
/** Per-role, per-generation bounded recovery with path-level fault isolation. */
|
|
81
|
+
export declare class RoleRecoveryController {
|
|
82
|
+
private readonly options;
|
|
83
|
+
private token;
|
|
84
|
+
private activeEpoch?;
|
|
85
|
+
private active?;
|
|
86
|
+
private status?;
|
|
87
|
+
constructor(options: RoleRecoveryControllerOptions);
|
|
88
|
+
recover(generation: DaemonGeneration): Promise<RecoveryStatus>;
|
|
89
|
+
cancel(): void;
|
|
90
|
+
noteLoss(reason: string): void;
|
|
91
|
+
private write;
|
|
92
|
+
}
|
|
93
|
+
export {};
|