@ours.network/fleet 0.13.2 → 0.14.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 +93 -60
- package/dist/briefing.js +15 -12
- package/dist/config.d.ts +10 -0
- package/dist/config.js +21 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +35 -45
- package/dist/loops/manager.d.ts +1 -0
- package/dist/loops/manager.js +23 -1
- package/dist/owner-channel/channel.d.ts +52 -0
- package/dist/owner-channel/channel.js +403 -62
- package/dist/owner-channel/commands.d.ts +79 -0
- package/dist/owner-channel/commands.js +183 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +20 -0
- package/dist/owner-channel/state.d.ts +45 -0
- package/dist/owner-channel/state.js +229 -19
- package/dist/owner-channel/tasks.js +7 -4
- package/dist/runner.js +2 -0
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +24 -1
- package/package.json +1 -1
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { mkdir, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { DEFAULT_OWNER_ATTACHMENT_MIME, } from '../config.js';
|
|
6
|
+
import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
|
|
7
|
+
import { VERSION } from '../version.js';
|
|
8
|
+
import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
|
|
7
9
|
import { OursMcpClient } from './mcp.js';
|
|
8
10
|
import { ownerNotices } from './notices.js';
|
|
9
|
-
import { OwnerAuthorizationState, OwnerChannelState } from './state.js';
|
|
11
|
+
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
10
12
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
11
13
|
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, } from './attachments.js';
|
|
12
14
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
13
15
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
14
16
|
const OWNER_UPDATE_MAX_CHARS = 280;
|
|
15
17
|
const OWNER_UPDATE_MAX_BYTES = 1_024;
|
|
18
|
+
const PROACTIVE_MESSAGE_MAX_CHARS = 4_000;
|
|
19
|
+
const PROACTIVE_MESSAGE_MAX_BYTES = 16_384;
|
|
20
|
+
const RELAY_NACK_MEMORY = 512;
|
|
21
|
+
/** A relay attempt that failed only because no owner route exists yet. */
|
|
22
|
+
class RelayUnroutableError extends Error {
|
|
23
|
+
}
|
|
16
24
|
/**
|
|
17
25
|
* Fleet-owned trusted ingress. The agent never binds this identity and never
|
|
18
26
|
* chooses its reply recipient; both are fixed from authenticated message data.
|
|
@@ -22,6 +30,7 @@ export class OwnerChannel {
|
|
|
22
30
|
client;
|
|
23
31
|
state;
|
|
24
32
|
authorizations;
|
|
33
|
+
conversations;
|
|
25
34
|
tasks;
|
|
26
35
|
attachmentRecovery;
|
|
27
36
|
attachmentConfig;
|
|
@@ -31,6 +40,8 @@ export class OwnerChannel {
|
|
|
31
40
|
* (a crash must replay them) but must not be queued twice while live.
|
|
32
41
|
*/
|
|
33
42
|
inFlight = new Set();
|
|
43
|
+
/** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
|
|
44
|
+
relayNacks = new Set();
|
|
34
45
|
stopping = false;
|
|
35
46
|
watchProcess;
|
|
36
47
|
watchTask;
|
|
@@ -40,11 +51,14 @@ export class OwnerChannel {
|
|
|
40
51
|
activeRequests = new Map();
|
|
41
52
|
managementTail = Promise.resolve();
|
|
42
53
|
ready = false;
|
|
54
|
+
fleetOps;
|
|
43
55
|
constructor(options) {
|
|
44
56
|
this.options = options;
|
|
45
57
|
this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
58
|
+
this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
|
|
46
59
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
47
60
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
61
|
+
this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
|
|
48
62
|
this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
|
|
49
63
|
this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
|
|
50
64
|
this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
|
|
@@ -53,9 +67,11 @@ export class OwnerChannel {
|
|
|
53
67
|
max_request_bytes: 20 * 1024 * 1024, retention_ms: 24 * 60 * 60 * 1_000,
|
|
54
68
|
allowed_mime: [...DEFAULT_OWNER_ATTACHMENT_MIME],
|
|
55
69
|
};
|
|
56
|
-
const integrity = this.
|
|
70
|
+
const integrity = this.authorizationIntegrity();
|
|
57
71
|
if (!integrity.ok)
|
|
58
72
|
options.log(`[${options.role}] owner authorization state corrupt; all owner mail disabled`);
|
|
73
|
+
if (!this.conversations.integrity().ok)
|
|
74
|
+
options.log(`[${options.role}] owner conversation state corrupt; proactive messages disabled`);
|
|
59
75
|
if (!this.tasks.integrity().ok)
|
|
60
76
|
options.log(`[${options.role}] owner task state corrupt; proactive reports disabled`);
|
|
61
77
|
if (!this.attachmentRecovery.integrity())
|
|
@@ -65,8 +81,8 @@ export class OwnerChannel {
|
|
|
65
81
|
this.stopping = false;
|
|
66
82
|
await this.client.start();
|
|
67
83
|
await this.client.callTool('choose_identity', { name: this.options.config.identity });
|
|
68
|
-
if (this.
|
|
69
|
-
this.tasks.cleanup(Date.now(), this.
|
|
84
|
+
if (this.authorizationIntegrity().ok && this.tasks.integrity().ok)
|
|
85
|
+
this.tasks.cleanup(Date.now(), this.effectiveOwners());
|
|
70
86
|
if (this.attachmentRecovery.integrity()) {
|
|
71
87
|
this.attachmentRecovery.cleanup(Date.now(), this.attachmentConfig.retention_ms);
|
|
72
88
|
void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
|
|
@@ -138,23 +154,37 @@ export class OwnerChannel {
|
|
|
138
154
|
}
|
|
139
155
|
case 'owner_list':
|
|
140
156
|
return {
|
|
141
|
-
action: request.action, integrity: this.
|
|
142
|
-
owners: this.
|
|
157
|
+
action: request.action, integrity: this.authorizationIntegrity(),
|
|
158
|
+
owners: this.options.config.agent
|
|
159
|
+
? this.options.config.owners.map(cid => ({ cid, source: 'baseline', effective: true }))
|
|
160
|
+
: this.authorizations.entries(),
|
|
143
161
|
};
|
|
144
162
|
case 'owner_authorize': {
|
|
163
|
+
if (this.options.config.agent)
|
|
164
|
+
throw new Error('live owner authorization is disabled when managed-agent CID gating is configured; edit fleet configuration instead');
|
|
145
165
|
this.assertCid(request.cid);
|
|
166
|
+
if (request.cid === this.options.config.agent)
|
|
167
|
+
throw new Error('managed agent CID cannot also be authorized as an owner');
|
|
146
168
|
if (this.authorizations.effective().has(request.cid))
|
|
147
169
|
throw new Error(`owner '${request.cid}' is already authorized`);
|
|
148
170
|
const contacts = await this.contacts();
|
|
149
|
-
if (!contacts.some(contact => contact.cid === request.cid
|
|
171
|
+
if (!contacts.some(contact => canonicalCid(contact.cid) === canonicalCid(request.cid)
|
|
150
172
|
&& ['established', 'active', 'connected'].includes(contact.status.toLowerCase())))
|
|
151
173
|
throw new Error(`cannot authorize unknown or pending contact CID '${request.cid}'`);
|
|
152
174
|
return { action: request.action, owner: this.authorizations.authorize(request.cid) };
|
|
153
175
|
}
|
|
154
176
|
case 'owner_revoke':
|
|
177
|
+
if (this.options.config.agent)
|
|
178
|
+
throw new Error('live owner revocation is disabled when managed-agent CID gating is configured; edit fleet configuration instead');
|
|
155
179
|
this.assertCid(request.cid);
|
|
156
180
|
{
|
|
157
181
|
const owner = this.authorizations.revoke(request.cid);
|
|
182
|
+
try {
|
|
183
|
+
this.conversations.remove(request.cid);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.logError('owner conversation revocation cleanup failed', error);
|
|
187
|
+
}
|
|
158
188
|
let revokedTasks = 0;
|
|
159
189
|
try {
|
|
160
190
|
revokedTasks = this.tasks.revoke(request.cid);
|
|
@@ -167,10 +197,16 @@ export class OwnerChannel {
|
|
|
167
197
|
return { action: request.action, owner };
|
|
168
198
|
}
|
|
169
199
|
case 'request_update':
|
|
200
|
+
if (this.options.config.agent)
|
|
201
|
+
throw new Error('direct owner updates are disabled; the managed agent must message its owner-channel identity');
|
|
170
202
|
return this.sendOwnerUpdate(request);
|
|
171
203
|
case 'task_open':
|
|
204
|
+
if (this.options.config.agent)
|
|
205
|
+
throw new Error('owner task routes are disabled; the managed agent must message its owner-channel identity');
|
|
172
206
|
return this.openOwnerTask(request.requestId);
|
|
173
207
|
case 'task_report':
|
|
208
|
+
if (this.options.config.agent)
|
|
209
|
+
throw new Error('direct task reports are disabled; the managed agent must message its owner-channel identity');
|
|
174
210
|
return this.sendOwnerTaskReport(request);
|
|
175
211
|
default:
|
|
176
212
|
throw new Error('unknown owner-channel management action');
|
|
@@ -251,13 +287,39 @@ export class OwnerChannel {
|
|
|
251
287
|
await send;
|
|
252
288
|
return { action: request.action, requestId: request.requestId, sequence };
|
|
253
289
|
}
|
|
290
|
+
async sendProactiveMessage(messageValue) {
|
|
291
|
+
if (!this.authorizationIntegrity().ok)
|
|
292
|
+
throw new Error('owner authorization state is corrupt; proactive messages are disabled');
|
|
293
|
+
const message = this.safeProactiveMessage(messageValue);
|
|
294
|
+
const route = this.conversations.route(this.effectiveOwners());
|
|
295
|
+
const digest = createHash('sha256').update(message).digest('hex');
|
|
296
|
+
const sending = this.conversations.beginSend(route.contact, digest);
|
|
297
|
+
try {
|
|
298
|
+
if (!this.isEffectiveOwner(route.contact))
|
|
299
|
+
throw new Error('selected proactive owner is no longer authorized');
|
|
300
|
+
await this.send(await this.routableContact(route), message);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
try {
|
|
304
|
+
this.conversations.finishSend(sending.id, 'uncertain');
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
this.logError('proactive owner uncertainty persist failed', error);
|
|
308
|
+
}
|
|
309
|
+
throw new Error('proactive owner message delivery outcome is uncertain; it was not retried');
|
|
310
|
+
}
|
|
311
|
+
this.conversations.finishSend(sending.id, 'delivered');
|
|
312
|
+
this.options.log(`[${this.options.role}] proactive owner message `
|
|
313
|
+
+ `${createHash('sha256').update(sending.id).digest('hex').slice(0, 12)} `
|
|
314
|
+
+ `basis=${route.basis} chars=${Array.from(message).length} bytes=${Buffer.byteLength(message)} delivered`);
|
|
315
|
+
}
|
|
254
316
|
openOwnerTask(requestId) {
|
|
255
317
|
if (!/^[a-f0-9]{64}$/.test(requestId))
|
|
256
318
|
throw new Error('owner task request ID must be exactly 64 lowercase hexadecimal characters');
|
|
257
319
|
const active = this.activeRequests.get(requestId);
|
|
258
320
|
if (!active || active.finalizing)
|
|
259
321
|
throw new Error('owner task can be opened only for a currently active owner request');
|
|
260
|
-
if (!this.
|
|
322
|
+
if (!this.isEffectiveOwner(active.contact))
|
|
261
323
|
throw new Error('originating owner is no longer authorized');
|
|
262
324
|
const task = this.tasks.open({
|
|
263
325
|
requestId, contact: active.contact, wireId: active.wireId,
|
|
@@ -276,7 +338,7 @@ export class OwnerChannel {
|
|
|
276
338
|
throw new Error('owner task reports are allowed only after the originating request has finalized');
|
|
277
339
|
if (!this.authorizations.integrity().ok)
|
|
278
340
|
throw new Error('owner authorization state is corrupt; proactive reports are disabled');
|
|
279
|
-
if (!this.
|
|
341
|
+
if (!this.isEffectiveOwner(task.contact)) {
|
|
280
342
|
this.tasks.revoke(task.contact, now);
|
|
281
343
|
throw new Error('originating owner is no longer authorized; task revoked');
|
|
282
344
|
}
|
|
@@ -339,6 +401,22 @@ export class OwnerChannel {
|
|
|
339
401
|
throw new Error('owner task report must contain exactly one plain-text sentence');
|
|
340
402
|
return message;
|
|
341
403
|
}
|
|
404
|
+
safeProactiveMessage(value) {
|
|
405
|
+
if (typeof value !== 'string')
|
|
406
|
+
throw new Error('proactive owner message must be text');
|
|
407
|
+
const message = value.trim().normalize('NFC');
|
|
408
|
+
if (!message)
|
|
409
|
+
throw new Error('proactive owner message must not be empty');
|
|
410
|
+
if (Array.from(message).length > PROACTIVE_MESSAGE_MAX_CHARS
|
|
411
|
+
|| Buffer.byteLength(message) > PROACTIVE_MESSAGE_MAX_BYTES)
|
|
412
|
+
throw new Error(`proactive owner message exceeds ${PROACTIVE_MESSAGE_MAX_CHARS} characters or `
|
|
413
|
+
+ `${PROACTIVE_MESSAGE_MAX_BYTES} bytes`);
|
|
414
|
+
if (/\u0000|[\u202a-\u202e\u2066-\u2069]/u.test(message))
|
|
415
|
+
throw new Error('proactive owner message contains unsafe control characters');
|
|
416
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[_ -]?key|access[_ -]?token|authorization|password|secret)\s*[:=]|(?:chain of thought|private reasoning|internal reasoning)|^(?:stdout|stderr|tool (?:output|result)|command):/imu.test(message))
|
|
417
|
+
throw new Error('proactive owner message appears to contain unsafe reasoning, secret, or raw tool content');
|
|
418
|
+
return message;
|
|
419
|
+
}
|
|
342
420
|
async drainAll() {
|
|
343
421
|
// A finite cap protects the supervisor if a broken daemon repeats unread
|
|
344
422
|
// messages forever. A watch notification will resume draining later.
|
|
@@ -372,7 +450,7 @@ export class OwnerChannel {
|
|
|
372
450
|
const deferred = messages.filter(message => {
|
|
373
451
|
const wireId = this.wireId(message);
|
|
374
452
|
return wireId && !this.state.has(wireId)
|
|
375
|
-
&& this.
|
|
453
|
+
&& this.acceptedSender(this.sender(message).id)
|
|
376
454
|
&& Number.isInteger(message.msg_id);
|
|
377
455
|
}).map(message => message.msg_id);
|
|
378
456
|
if (deferred.length)
|
|
@@ -451,7 +529,7 @@ export class OwnerChannel {
|
|
|
451
529
|
return false;
|
|
452
530
|
const sender = { id: group.files[0].senderId, name: group.files[0].senderName };
|
|
453
531
|
if (group.files.some(file => file.senderId !== sender.id)
|
|
454
|
-
|| !this.
|
|
532
|
+
|| !this.isEffectiveOwner(sender.id)) {
|
|
455
533
|
this.options.log(`[${this.options.role}] owner channel ignored unauthorized attachment sender ${sender.id}`);
|
|
456
534
|
for (const wire of handledWireIds)
|
|
457
535
|
this.state.remember(wire);
|
|
@@ -571,33 +649,55 @@ export class OwnerChannel {
|
|
|
571
649
|
if (!wireId || this.state.has(wireId) || this.inFlight.has(wireId))
|
|
572
650
|
return false;
|
|
573
651
|
const sender = this.sender(message);
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
652
|
+
if (this.isAgentSender(sender.id)) {
|
|
653
|
+
try {
|
|
654
|
+
await this.relayManagedAgentMessage(message, wireId);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (error instanceof DuplicateSendError) {
|
|
658
|
+
// A crash replay of a wire that already reached an owner. Consuming
|
|
659
|
+
// it silently IS the correct outcome; delivering again is the bug.
|
|
660
|
+
this.options.log(`[${this.options.role}] managed-agent relay replay of a delivered wire consumed`);
|
|
661
|
+
}
|
|
662
|
+
else if (error instanceof RelayUnroutableError) {
|
|
663
|
+
// No owner route exists yet. Leave the wire deferred and unconsumed
|
|
664
|
+
// so the daemon replays it after the first owner contact, and tell
|
|
665
|
+
// the authenticated agent once so the wait is never silent.
|
|
666
|
+
this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
|
|
667
|
+
+ `route yet; message stays queued: ${this.errorText(error)}`);
|
|
668
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
this.logError('managed-agent message relay refused', error);
|
|
673
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
579
676
|
this.state.remember(wireId);
|
|
580
677
|
return true;
|
|
581
678
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
679
|
+
if (!this.isEffectiveOwner(sender.id)) {
|
|
680
|
+
// Never answer the sender or reflect its body. Notify an owner through the
|
|
681
|
+
// bounded proactive route, then consume the attempt so it cannot replay.
|
|
682
|
+
this.options.log(`[${this.options.role}] owner channel ignored unauthorized sender ${sender.id || '<unknown>'}`);
|
|
683
|
+
await this.warnOwnerOfUnauthorizedSender(sender.id);
|
|
586
684
|
this.state.remember(wireId);
|
|
587
685
|
return true;
|
|
588
686
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
687
|
+
// Accepted authenticated inbound traffic selects the destination for the
|
|
688
|
+
// next unscoped proactive message. Distinct devices/identities naturally
|
|
689
|
+
// hand off this route by being the most recent sender.
|
|
690
|
+
try {
|
|
691
|
+
this.conversations.recordInbound(sender.id, wireId);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
// Proactive routing state is auxiliary. A corrupt/unwritable route file must
|
|
695
|
+
// never prevent an authenticated owner from using the ordinary channel.
|
|
696
|
+
this.logError('owner conversation route update failed', error);
|
|
697
|
+
}
|
|
698
|
+
const text = String(message.text ?? '').trim();
|
|
699
|
+
if (isOwnerCommandText(text)) {
|
|
700
|
+
await this.handleCommand(sender, text, wireId);
|
|
601
701
|
return true;
|
|
602
702
|
}
|
|
603
703
|
const requestId = this.requestId(wireId);
|
|
@@ -606,7 +706,7 @@ export class OwnerChannel {
|
|
|
606
706
|
let queued;
|
|
607
707
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
608
708
|
try {
|
|
609
|
-
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId,
|
|
709
|
+
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
|
|
610
710
|
interrupt: this.options.config.interrupt,
|
|
611
711
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
612
712
|
origin: { kind: 'owner', requestId },
|
|
@@ -619,10 +719,13 @@ export class OwnerChannel {
|
|
|
619
719
|
this.state.remember(wireId);
|
|
620
720
|
return true;
|
|
621
721
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
722
|
+
// Interrupting the live turn does not remove prompts which were already
|
|
723
|
+
// accepted into the ACP queue. Never claim this request is running while
|
|
724
|
+
// the session itself says earlier work remains ahead of it.
|
|
725
|
+
const accepted = queued.queuedBehind > 0
|
|
726
|
+
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
727
|
+
: this.options.config.interrupt
|
|
728
|
+
? ownerNotices.receivedInterrupting()
|
|
626
729
|
: ownerNotices.receivedStarted();
|
|
627
730
|
this.inFlight.add(wireId);
|
|
628
731
|
const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
|
|
@@ -645,34 +748,271 @@ export class OwnerChannel {
|
|
|
645
748
|
this.completionTasks.add(task);
|
|
646
749
|
return true;
|
|
647
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* Deterministic command path: the message never becomes an agent prompt.
|
|
753
|
+
* Authorization already happened — the managed-agent relay branch and the
|
|
754
|
+
* owner-CID check in handle() both run before dispatch, so only an
|
|
755
|
+
* authenticated owner reaches this: neither ordinary peers nor the managed
|
|
756
|
+
* agent itself can execute /force-restart, /model, or any other command.
|
|
757
|
+
*/
|
|
758
|
+
async handleCommand(sender, text, wireId) {
|
|
759
|
+
const ctx = {
|
|
760
|
+
role: this.options.role,
|
|
761
|
+
harness: this.options.harness,
|
|
762
|
+
version: VERSION,
|
|
763
|
+
snapshot: () => this.options.session.snapshot(),
|
|
764
|
+
interrupt: () => this.options.session.interrupt('owner'),
|
|
765
|
+
runHarnessCommand: command => this.runHarnessCommand(sender, command, wireId),
|
|
766
|
+
restart: mode => this.restartSelf(sender, mode, wireId),
|
|
767
|
+
fleetList: () => this.fleetOps.list(),
|
|
768
|
+
recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
|
|
769
|
+
readWorklogTail: maxChars => this.readWorklogTail(maxChars),
|
|
770
|
+
reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
|
|
771
|
+
};
|
|
772
|
+
try {
|
|
773
|
+
await dispatchOwnerCommand(text, ctx);
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
this.logError(`owner command failed (${text.split(/\s+/, 1)[0]})`, error);
|
|
777
|
+
}
|
|
778
|
+
// Harness commands own their wire until the queued turn settles; everything
|
|
779
|
+
// else is complete now and must never replay.
|
|
780
|
+
if (!this.inFlight.has(wireId))
|
|
781
|
+
this.state.remember(wireId);
|
|
782
|
+
}
|
|
783
|
+
/** Queue raw slash text to the harness and report the turn's outcome. */
|
|
784
|
+
async runHarnessCommand(sender, command, wireId) {
|
|
785
|
+
const requestId = this.requestId(wireId);
|
|
786
|
+
const queued = await this.options.session.queuePrompt(command, {
|
|
787
|
+
origin: { kind: 'owner', requestId },
|
|
788
|
+
});
|
|
789
|
+
this.inFlight.add(wireId);
|
|
790
|
+
const receipt = this.send(sender.id, ownerNotices.commandStarted(command), wireId)
|
|
791
|
+
.then(() => undefined)
|
|
792
|
+
.catch(error => this.logError(`command ${command} acceptance notice failed`, error));
|
|
793
|
+
const task = queued.completion.then(async (result) => {
|
|
794
|
+
await receipt;
|
|
795
|
+
const output = result.succeeded ? this.commandOutput(result.output) : undefined;
|
|
796
|
+
await this.send(sender.id, ownerNotices.commandOutcome(command, result.outcome, output), wireId);
|
|
797
|
+
this.state.remember(wireId);
|
|
798
|
+
}).catch(error => this.logError(`command ${command} completion failed`, error))
|
|
799
|
+
.finally(() => {
|
|
800
|
+
this.inFlight.delete(wireId);
|
|
801
|
+
this.completionTasks.delete(task);
|
|
802
|
+
if (!this.stopping)
|
|
803
|
+
void this.drain().catch(error => this.logError('completion drain failed', error));
|
|
804
|
+
});
|
|
805
|
+
this.completionTasks.add(task);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Confirmation and the durable wire record must both land BEFORE the fleet
|
|
809
|
+
* CLI is asked to bounce this very process; neither can happen afterwards.
|
|
810
|
+
*/
|
|
811
|
+
async restartSelf(sender, mode, wireId) {
|
|
812
|
+
const command = mode === 'fresh' ? '/force-restart' : '/restart';
|
|
813
|
+
await this.send(sender.id, ownerNotices.restarting(this.options.role, command, mode), wireId);
|
|
814
|
+
this.state.remember(wireId);
|
|
815
|
+
this.options.log(`[${this.options.role}] owner requested ${command}`);
|
|
816
|
+
await this.fleetOps.restart(mode);
|
|
817
|
+
}
|
|
818
|
+
/** Code-point-safe tail of the worklog, or undefined when there is none. */
|
|
819
|
+
async readWorklogTail(maxChars) {
|
|
820
|
+
try {
|
|
821
|
+
const content = (await readFile(join(this.options.stateDir, 'WORKLOG.md'), 'utf8')).trim();
|
|
822
|
+
if (!content)
|
|
823
|
+
return undefined;
|
|
824
|
+
const points = Array.from(content);
|
|
825
|
+
return points.length <= maxChars ? content : `…${points.slice(-maxChars).join('')}`;
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
return undefined;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** Bound harness-command output to a single outbound message. */
|
|
832
|
+
commandOutput(output) {
|
|
833
|
+
const trimmed = output?.trim();
|
|
834
|
+
if (!trimmed)
|
|
835
|
+
return undefined;
|
|
836
|
+
const points = Array.from(trimmed);
|
|
837
|
+
return points.length <= 7_000 ? trimmed : `${points.slice(0, 7_000).join('')}…`;
|
|
838
|
+
}
|
|
839
|
+
acceptedSender(cid) {
|
|
840
|
+
return this.isAgentSender(cid) || this.isEffectiveOwner(cid);
|
|
841
|
+
}
|
|
842
|
+
isAgentSender(cid) {
|
|
843
|
+
const agent = this.options.config.agent;
|
|
844
|
+
return agent !== undefined && cid !== '' && canonicalCid(cid) === canonicalCid(agent);
|
|
845
|
+
}
|
|
846
|
+
isEffectiveOwner(cid) {
|
|
847
|
+
const canonical = canonicalCid(cid);
|
|
848
|
+
for (const owner of this.effectiveOwners())
|
|
849
|
+
if (canonicalCid(owner) === canonical)
|
|
850
|
+
return true;
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
async relayManagedAgentMessage(message, wireId) {
|
|
854
|
+
if (!this.authorizationIntegrity().ok)
|
|
855
|
+
throw new Error('owner authorization state is corrupt; managed-agent relay is disabled');
|
|
856
|
+
const text = this.safeRelayMessage(message.text);
|
|
857
|
+
let route;
|
|
858
|
+
try {
|
|
859
|
+
route = this.conversations.route(this.effectiveOwners());
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
throw new RelayUnroutableError(this.errorText(error));
|
|
863
|
+
}
|
|
864
|
+
// The inbound wire—not its body—is the idempotency key. Repeating the same
|
|
865
|
+
// wording in two deliberate messages remains valid, while crash replay of
|
|
866
|
+
// one message cannot produce two owner deliveries — to ANY owner, which is
|
|
867
|
+
// why the wire digest is checked across every recorded send.
|
|
868
|
+
const digest = createHash('sha256').update(`managed-agent-relay\0${wireId}`).digest('hex');
|
|
869
|
+
const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
|
|
870
|
+
try {
|
|
871
|
+
if (!this.isEffectiveOwner(route.contact))
|
|
872
|
+
throw new Error('selected relay owner is no longer authorized');
|
|
873
|
+
await this.send(await this.routableContact(route), text);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
try {
|
|
877
|
+
this.conversations.finishSend(sending.id, 'uncertain');
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
this.logError('managed-agent relay uncertainty persist failed', error);
|
|
881
|
+
}
|
|
882
|
+
throw new Error('managed-agent relay delivery outcome is uncertain; it was not retried');
|
|
883
|
+
}
|
|
884
|
+
this.conversations.finishSend(sending.id, 'delivered');
|
|
885
|
+
this.options.log(`[${this.options.role}] managed-agent message relayed `
|
|
886
|
+
+ `wire=${createHash('sha256').update(wireId).digest('hex').slice(0, 12)} `
|
|
887
|
+
+ `basis=${route.basis} chars=${Array.from(text).length} bytes=${Buffer.byteLength(text)}`);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
891
|
+
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
892
|
+
* delivery is best-effort — it must never make the failure worse.
|
|
893
|
+
*/
|
|
894
|
+
async nackManagedAgent(contact, message, wireId, notice) {
|
|
895
|
+
if (this.relayNacks.has(wireId))
|
|
896
|
+
return;
|
|
897
|
+
this.relayNacks.add(wireId);
|
|
898
|
+
if (this.relayNacks.size > RELAY_NACK_MEMORY)
|
|
899
|
+
this.relayNacks.delete(this.relayNacks.values().next().value);
|
|
900
|
+
try {
|
|
901
|
+
await this.send(contact, notice, message.wire_id ? wireId : undefined);
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
this.logError('managed-agent relay NACK delivery failed', error);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Daemon contact resolution is case-exact, so a canonical config CID picked
|
|
909
|
+
* by the sole-owner fallback is translated to the daemon-known contact form
|
|
910
|
+
* when one exists. Last-inbound routes already carry the daemon form.
|
|
911
|
+
*/
|
|
912
|
+
async routableContact(route) {
|
|
913
|
+
if (route.basis !== 'sole-owner')
|
|
914
|
+
return route.contact;
|
|
915
|
+
try {
|
|
916
|
+
const canonical = canonicalCid(route.contact);
|
|
917
|
+
const match = (await this.contacts()).find(entry => canonicalCid(entry.cid) === canonical);
|
|
918
|
+
return match?.cid ?? route.contact;
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
return route.contact;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
safeRelayMessage(value) {
|
|
925
|
+
if (typeof value !== 'string')
|
|
926
|
+
throw new Error('managed-agent relay must be text');
|
|
927
|
+
const message = value.normalize('NFC');
|
|
928
|
+
if (!message.trim())
|
|
929
|
+
throw new Error('managed-agent relay must not be empty');
|
|
930
|
+
if (Array.from(message).length > PROACTIVE_MESSAGE_MAX_CHARS
|
|
931
|
+
|| Buffer.byteLength(message) > PROACTIVE_MESSAGE_MAX_BYTES)
|
|
932
|
+
throw new Error(`managed-agent relay exceeds ${PROACTIVE_MESSAGE_MAX_CHARS} characters or `
|
|
933
|
+
+ `${PROACTIVE_MESSAGE_MAX_BYTES} bytes`);
|
|
934
|
+
if (/\u0000|[\u202a-\u202e\u2066-\u2069]/u.test(message))
|
|
935
|
+
throw new Error('managed-agent relay contains unsafe control characters');
|
|
936
|
+
return message;
|
|
937
|
+
}
|
|
938
|
+
async warnOwnerOfUnauthorizedSender(cid) {
|
|
939
|
+
const source = /^[A-Fa-f0-9]{64}$/.test(cid)
|
|
940
|
+
? cid
|
|
941
|
+
: `invalid-${createHash('sha256').update(cid).digest('hex').slice(0, 12)}`;
|
|
942
|
+
try {
|
|
943
|
+
await this.sendProactiveMessage(`⚠️ Owner-channel security warning: rejected a message from unauthorized `
|
|
944
|
+
+ `sender CID ${source}. Its body was not forwarded.`);
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
// Warning delivery must not make hostile input replayable or disclose
|
|
948
|
+
// anything to its sender. Rate/dedupe/no-route failures remain local.
|
|
949
|
+
this.logError('unauthorized sender warning suppressed', error);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
effectiveOwners() {
|
|
953
|
+
// In managed-agent mode the checked-in fleet configuration is the complete
|
|
954
|
+
// authority boundary. Ignore any legacy dynamic overlay left on disk.
|
|
955
|
+
return this.options.config.agent
|
|
956
|
+
? new Set(this.options.config.owners)
|
|
957
|
+
: this.authorizations.effective();
|
|
958
|
+
}
|
|
959
|
+
authorizationIntegrity() {
|
|
960
|
+
return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
|
|
961
|
+
}
|
|
648
962
|
async complete(active, outbox, queued, activityCursor) {
|
|
649
963
|
const progressMs = this.options.config.progress_interval_ms;
|
|
650
|
-
const startedAt = Date.now();
|
|
651
964
|
let lastSeq = activityCursor;
|
|
652
|
-
let
|
|
653
|
-
|
|
654
|
-
|
|
965
|
+
let startedAt;
|
|
966
|
+
let phase = 'starting request';
|
|
967
|
+
let timer;
|
|
968
|
+
const reportProgress = () => {
|
|
655
969
|
const events = this.options.session.eventsSince(lastSeq);
|
|
656
970
|
lastSeq = Math.max(lastSeq, this.latestEventSeq(events));
|
|
657
971
|
const activity = events.filter(event => event.turnId === queued.promptId);
|
|
972
|
+
if (!activity.length)
|
|
973
|
+
return;
|
|
974
|
+
startedAt ??= Date.parse(activity[0].at) || Date.now();
|
|
658
975
|
phase = this.progressPhase(activity) ?? phase;
|
|
659
976
|
const started = activity.filter(event => event.kind === 'tool_call').length;
|
|
660
977
|
const completed = activity.filter(event => event.kind === 'tool_update' && event.status === 'completed').length;
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
978
|
+
// Token/thought chunks are transport activity, not evidence of progress.
|
|
979
|
+
// Permission transitions and errors are material even without a tool.
|
|
980
|
+
const activityUpdates = activity.filter(event => event.kind === 'permission' || event.kind === 'error').length;
|
|
981
|
+
if (!started && !completed && !activityUpdates)
|
|
982
|
+
return;
|
|
664
983
|
const notice = ownerNotices.progress(Date.now() - startedAt, phase, started, completed, activityUpdates);
|
|
665
984
|
// Preserve wire ordering if a progress send overlaps turn completion.
|
|
666
985
|
active.outboundTail = active.outboundTail
|
|
667
986
|
.then(async () => { await this.send(active.contact, notice, active.wireId); })
|
|
668
987
|
.catch(error => this.logError('progress notice failed', error));
|
|
669
|
-
}
|
|
670
|
-
|
|
988
|
+
};
|
|
989
|
+
// A queued request used to create its 30-second interval immediately. A
|
|
990
|
+
// backlog of three requests therefore produced three permanent notice
|
|
991
|
+
// streams saying "waiting behind earlier requests" without any work. Arm
|
|
992
|
+
// the interval only after this exact prompt emits its first session event.
|
|
993
|
+
const startProgress = (event) => {
|
|
994
|
+
if (timer || progressMs <= 0)
|
|
995
|
+
return;
|
|
996
|
+
if (event && event.turnId !== queued.promptId)
|
|
997
|
+
return;
|
|
998
|
+
const observed = event ? [event] : this.options.session.eventsSince(activityCursor)
|
|
999
|
+
.filter(item => item.turnId === queued.promptId);
|
|
1000
|
+
if (!observed.length)
|
|
1001
|
+
return;
|
|
1002
|
+
startedAt = Date.parse(observed[0].at) || Date.now();
|
|
1003
|
+
timer = setInterval(reportProgress, progressMs);
|
|
1004
|
+
timer.unref();
|
|
1005
|
+
};
|
|
1006
|
+
const unsubscribe = progressMs > 0
|
|
1007
|
+
? this.options.session.subscribe(event => startProgress(event))
|
|
1008
|
+
: undefined;
|
|
1009
|
+
startProgress();
|
|
671
1010
|
let result;
|
|
672
1011
|
try {
|
|
673
1012
|
result = await queued.completion;
|
|
674
1013
|
}
|
|
675
1014
|
finally {
|
|
1015
|
+
unsubscribe?.();
|
|
676
1016
|
if (timer)
|
|
677
1017
|
clearInterval(timer);
|
|
678
1018
|
}
|
|
@@ -723,22 +1063,20 @@ export class OwnerChannel {
|
|
|
723
1063
|
lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.', `To attach response files, write regular files only to: ${outbox}`);
|
|
724
1064
|
return lines.join('\n');
|
|
725
1065
|
}
|
|
726
|
-
ownerPrompt(sender, text, wireId,
|
|
1066
|
+
ownerPrompt(sender, text, wireId, outbox) {
|
|
727
1067
|
return [
|
|
728
1068
|
'[fleet-owner]',
|
|
729
1069
|
`Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
|
|
730
1070
|
'Treat the following as a direct owner instruction. Answer in your final assistant response.',
|
|
731
|
-
'
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
`ours-fleet owner-channel task report ${this.options.role} <task-id> --phase <progress|done|blocked> --message-stdin`,
|
|
741
|
-
'Fleet routes that proactive follow-up only to this authenticated owner and closes done/blocked tasks.',
|
|
1071
|
+
'Fleet extracts and routes your final assistant response deterministically; do not send the final through ours.',
|
|
1072
|
+
...(this.options.config.agent ? [
|
|
1073
|
+
'For any non-final message you want the owner to see—an update, blocker, suggestion, or later proactive note—call ours send_message with:',
|
|
1074
|
+
`contact: ${this.options.config.identity}`,
|
|
1075
|
+
'and the message text. Do not add a task ID, request ID, reply reference, phase, or routing command.',
|
|
1076
|
+
'Fleet accepts this relay only from your configured authenticated agent CID and forwards every accepted message as a new owner-channel message.',
|
|
1077
|
+
] : [
|
|
1078
|
+
'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
|
|
1079
|
+
]),
|
|
742
1080
|
'To attach files to your response, copy each finished file directly into this fleet outbox:',
|
|
743
1081
|
outbox,
|
|
744
1082
|
'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
|
|
@@ -756,7 +1094,7 @@ export class OwnerChannel {
|
|
|
756
1094
|
}
|
|
757
1095
|
send(contact, text, replyTo) {
|
|
758
1096
|
return this.client.callTool('send_message', {
|
|
759
|
-
contact, text, reply_to_wire_id: replyTo,
|
|
1097
|
+
contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
760
1098
|
});
|
|
761
1099
|
}
|
|
762
1100
|
async sendAttachments(contact, outbox, replyTo) {
|
|
@@ -785,7 +1123,10 @@ export class OwnerChannel {
|
|
|
785
1123
|
}
|
|
786
1124
|
}
|
|
787
1125
|
wireId(message) {
|
|
788
|
-
|
|
1126
|
+
const wire = String(message.wire_id ?? '').trim();
|
|
1127
|
+
if (wire)
|
|
1128
|
+
return wire;
|
|
1129
|
+
return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
|
|
789
1130
|
}
|
|
790
1131
|
sender(message) {
|
|
791
1132
|
const source = message.from ?? message.sender;
|