@ours.network/fleet 0.13.1 → 0.13.3
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 +40 -57
- 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 +25 -44
- package/dist/loops/manager.d.ts +1 -0
- package/dist/loops/manager.js +23 -1
- package/dist/owner-channel/attachments.js +19 -5
- package/dist/owner-channel/channel.d.ts +25 -0
- package/dist/owner-channel/channel.js +309 -44
- package/dist/owner-channel/notices.d.ts +2 -0
- package/dist/owner-channel/notices.js +3 -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/session/acp.d.ts +3 -0
- package/dist/session/acp.js +24 -1
- package/package.json +1 -1
|
@@ -109,6 +109,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
109
109
|
private readonly client;
|
|
110
110
|
private readonly state;
|
|
111
111
|
private readonly authorizations;
|
|
112
|
+
private readonly conversations;
|
|
112
113
|
private readonly tasks;
|
|
113
114
|
private readonly attachmentRecovery;
|
|
114
115
|
private readonly attachmentConfig;
|
|
@@ -118,6 +119,8 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
118
119
|
* (a crash must replay them) but must not be queued twice while live.
|
|
119
120
|
*/
|
|
120
121
|
private readonly inFlight;
|
|
122
|
+
/** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
|
|
123
|
+
private readonly relayNacks;
|
|
121
124
|
private stopping;
|
|
122
125
|
private watchProcess?;
|
|
123
126
|
private watchTask?;
|
|
@@ -139,14 +142,36 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
139
142
|
private assertLabel;
|
|
140
143
|
private safeMetadata;
|
|
141
144
|
private sendOwnerUpdate;
|
|
145
|
+
private sendProactiveMessage;
|
|
142
146
|
private openOwnerTask;
|
|
143
147
|
private sendOwnerTaskReport;
|
|
144
148
|
private safeOwnerUpdate;
|
|
145
149
|
private safeTaskReport;
|
|
150
|
+
private safeProactiveMessage;
|
|
146
151
|
private drainAll;
|
|
147
152
|
private attachmentGroups;
|
|
148
153
|
private handleAttachmentGroup;
|
|
149
154
|
private handle;
|
|
155
|
+
private acceptedSender;
|
|
156
|
+
private isAgentSender;
|
|
157
|
+
private isEffectiveOwner;
|
|
158
|
+
private relayManagedAgentMessage;
|
|
159
|
+
/**
|
|
160
|
+
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
161
|
+
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
162
|
+
* delivery is best-effort — it must never make the failure worse.
|
|
163
|
+
*/
|
|
164
|
+
private nackManagedAgent;
|
|
165
|
+
/**
|
|
166
|
+
* Daemon contact resolution is case-exact, so a canonical config CID picked
|
|
167
|
+
* by the sole-owner fallback is translated to the daemon-known contact form
|
|
168
|
+
* when one exists. Last-inbound routes already carry the daemon form.
|
|
169
|
+
*/
|
|
170
|
+
private routableContact;
|
|
171
|
+
private safeRelayMessage;
|
|
172
|
+
private warnOwnerOfUnauthorizedSender;
|
|
173
|
+
private effectiveOwners;
|
|
174
|
+
private authorizationIntegrity;
|
|
150
175
|
private complete;
|
|
151
176
|
private ownerAttachmentPrompt;
|
|
152
177
|
private ownerPrompt;
|
|
@@ -3,16 +3,22 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { mkdir, 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
7
|
import { OursMcpClient } from './mcp.js';
|
|
8
8
|
import { ownerNotices } from './notices.js';
|
|
9
|
-
import { OwnerAuthorizationState, OwnerChannelState } from './state.js';
|
|
9
|
+
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
10
10
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
11
11
|
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, } from './attachments.js';
|
|
12
12
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
13
13
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
14
14
|
const OWNER_UPDATE_MAX_CHARS = 280;
|
|
15
15
|
const OWNER_UPDATE_MAX_BYTES = 1_024;
|
|
16
|
+
const PROACTIVE_MESSAGE_MAX_CHARS = 4_000;
|
|
17
|
+
const PROACTIVE_MESSAGE_MAX_BYTES = 16_384;
|
|
18
|
+
const RELAY_NACK_MEMORY = 512;
|
|
19
|
+
/** A relay attempt that failed only because no owner route exists yet. */
|
|
20
|
+
class RelayUnroutableError extends Error {
|
|
21
|
+
}
|
|
16
22
|
/**
|
|
17
23
|
* Fleet-owned trusted ingress. The agent never binds this identity and never
|
|
18
24
|
* chooses its reply recipient; both are fixed from authenticated message data.
|
|
@@ -22,6 +28,7 @@ export class OwnerChannel {
|
|
|
22
28
|
client;
|
|
23
29
|
state;
|
|
24
30
|
authorizations;
|
|
31
|
+
conversations;
|
|
25
32
|
tasks;
|
|
26
33
|
attachmentRecovery;
|
|
27
34
|
attachmentConfig;
|
|
@@ -31,6 +38,8 @@ export class OwnerChannel {
|
|
|
31
38
|
* (a crash must replay them) but must not be queued twice while live.
|
|
32
39
|
*/
|
|
33
40
|
inFlight = new Set();
|
|
41
|
+
/** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
|
|
42
|
+
relayNacks = new Set();
|
|
34
43
|
stopping = false;
|
|
35
44
|
watchProcess;
|
|
36
45
|
watchTask;
|
|
@@ -45,6 +54,7 @@ export class OwnerChannel {
|
|
|
45
54
|
this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
46
55
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
47
56
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
57
|
+
this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
|
|
48
58
|
this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
|
|
49
59
|
this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
|
|
50
60
|
this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
|
|
@@ -53,9 +63,11 @@ export class OwnerChannel {
|
|
|
53
63
|
max_request_bytes: 20 * 1024 * 1024, retention_ms: 24 * 60 * 60 * 1_000,
|
|
54
64
|
allowed_mime: [...DEFAULT_OWNER_ATTACHMENT_MIME],
|
|
55
65
|
};
|
|
56
|
-
const integrity = this.
|
|
66
|
+
const integrity = this.authorizationIntegrity();
|
|
57
67
|
if (!integrity.ok)
|
|
58
68
|
options.log(`[${options.role}] owner authorization state corrupt; all owner mail disabled`);
|
|
69
|
+
if (!this.conversations.integrity().ok)
|
|
70
|
+
options.log(`[${options.role}] owner conversation state corrupt; proactive messages disabled`);
|
|
59
71
|
if (!this.tasks.integrity().ok)
|
|
60
72
|
options.log(`[${options.role}] owner task state corrupt; proactive reports disabled`);
|
|
61
73
|
if (!this.attachmentRecovery.integrity())
|
|
@@ -65,8 +77,8 @@ export class OwnerChannel {
|
|
|
65
77
|
this.stopping = false;
|
|
66
78
|
await this.client.start();
|
|
67
79
|
await this.client.callTool('choose_identity', { name: this.options.config.identity });
|
|
68
|
-
if (this.
|
|
69
|
-
this.tasks.cleanup(Date.now(), this.
|
|
80
|
+
if (this.authorizationIntegrity().ok && this.tasks.integrity().ok)
|
|
81
|
+
this.tasks.cleanup(Date.now(), this.effectiveOwners());
|
|
70
82
|
if (this.attachmentRecovery.integrity()) {
|
|
71
83
|
this.attachmentRecovery.cleanup(Date.now(), this.attachmentConfig.retention_ms);
|
|
72
84
|
void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
|
|
@@ -138,23 +150,37 @@ export class OwnerChannel {
|
|
|
138
150
|
}
|
|
139
151
|
case 'owner_list':
|
|
140
152
|
return {
|
|
141
|
-
action: request.action, integrity: this.
|
|
142
|
-
owners: this.
|
|
153
|
+
action: request.action, integrity: this.authorizationIntegrity(),
|
|
154
|
+
owners: this.options.config.agent
|
|
155
|
+
? this.options.config.owners.map(cid => ({ cid, source: 'baseline', effective: true }))
|
|
156
|
+
: this.authorizations.entries(),
|
|
143
157
|
};
|
|
144
158
|
case 'owner_authorize': {
|
|
159
|
+
if (this.options.config.agent)
|
|
160
|
+
throw new Error('live owner authorization is disabled when managed-agent CID gating is configured; edit fleet configuration instead');
|
|
145
161
|
this.assertCid(request.cid);
|
|
162
|
+
if (request.cid === this.options.config.agent)
|
|
163
|
+
throw new Error('managed agent CID cannot also be authorized as an owner');
|
|
146
164
|
if (this.authorizations.effective().has(request.cid))
|
|
147
165
|
throw new Error(`owner '${request.cid}' is already authorized`);
|
|
148
166
|
const contacts = await this.contacts();
|
|
149
|
-
if (!contacts.some(contact => contact.cid === request.cid
|
|
167
|
+
if (!contacts.some(contact => canonicalCid(contact.cid) === canonicalCid(request.cid)
|
|
150
168
|
&& ['established', 'active', 'connected'].includes(contact.status.toLowerCase())))
|
|
151
169
|
throw new Error(`cannot authorize unknown or pending contact CID '${request.cid}'`);
|
|
152
170
|
return { action: request.action, owner: this.authorizations.authorize(request.cid) };
|
|
153
171
|
}
|
|
154
172
|
case 'owner_revoke':
|
|
173
|
+
if (this.options.config.agent)
|
|
174
|
+
throw new Error('live owner revocation is disabled when managed-agent CID gating is configured; edit fleet configuration instead');
|
|
155
175
|
this.assertCid(request.cid);
|
|
156
176
|
{
|
|
157
177
|
const owner = this.authorizations.revoke(request.cid);
|
|
178
|
+
try {
|
|
179
|
+
this.conversations.remove(request.cid);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
this.logError('owner conversation revocation cleanup failed', error);
|
|
183
|
+
}
|
|
158
184
|
let revokedTasks = 0;
|
|
159
185
|
try {
|
|
160
186
|
revokedTasks = this.tasks.revoke(request.cid);
|
|
@@ -167,10 +193,16 @@ export class OwnerChannel {
|
|
|
167
193
|
return { action: request.action, owner };
|
|
168
194
|
}
|
|
169
195
|
case 'request_update':
|
|
196
|
+
if (this.options.config.agent)
|
|
197
|
+
throw new Error('direct owner updates are disabled; the managed agent must message its owner-channel identity');
|
|
170
198
|
return this.sendOwnerUpdate(request);
|
|
171
199
|
case 'task_open':
|
|
200
|
+
if (this.options.config.agent)
|
|
201
|
+
throw new Error('owner task routes are disabled; the managed agent must message its owner-channel identity');
|
|
172
202
|
return this.openOwnerTask(request.requestId);
|
|
173
203
|
case 'task_report':
|
|
204
|
+
if (this.options.config.agent)
|
|
205
|
+
throw new Error('direct task reports are disabled; the managed agent must message its owner-channel identity');
|
|
174
206
|
return this.sendOwnerTaskReport(request);
|
|
175
207
|
default:
|
|
176
208
|
throw new Error('unknown owner-channel management action');
|
|
@@ -251,13 +283,39 @@ export class OwnerChannel {
|
|
|
251
283
|
await send;
|
|
252
284
|
return { action: request.action, requestId: request.requestId, sequence };
|
|
253
285
|
}
|
|
286
|
+
async sendProactiveMessage(messageValue) {
|
|
287
|
+
if (!this.authorizationIntegrity().ok)
|
|
288
|
+
throw new Error('owner authorization state is corrupt; proactive messages are disabled');
|
|
289
|
+
const message = this.safeProactiveMessage(messageValue);
|
|
290
|
+
const route = this.conversations.route(this.effectiveOwners());
|
|
291
|
+
const digest = createHash('sha256').update(message).digest('hex');
|
|
292
|
+
const sending = this.conversations.beginSend(route.contact, digest);
|
|
293
|
+
try {
|
|
294
|
+
if (!this.isEffectiveOwner(route.contact))
|
|
295
|
+
throw new Error('selected proactive owner is no longer authorized');
|
|
296
|
+
await this.send(await this.routableContact(route), message);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
try {
|
|
300
|
+
this.conversations.finishSend(sending.id, 'uncertain');
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
this.logError('proactive owner uncertainty persist failed', error);
|
|
304
|
+
}
|
|
305
|
+
throw new Error('proactive owner message delivery outcome is uncertain; it was not retried');
|
|
306
|
+
}
|
|
307
|
+
this.conversations.finishSend(sending.id, 'delivered');
|
|
308
|
+
this.options.log(`[${this.options.role}] proactive owner message `
|
|
309
|
+
+ `${createHash('sha256').update(sending.id).digest('hex').slice(0, 12)} `
|
|
310
|
+
+ `basis=${route.basis} chars=${Array.from(message).length} bytes=${Buffer.byteLength(message)} delivered`);
|
|
311
|
+
}
|
|
254
312
|
openOwnerTask(requestId) {
|
|
255
313
|
if (!/^[a-f0-9]{64}$/.test(requestId))
|
|
256
314
|
throw new Error('owner task request ID must be exactly 64 lowercase hexadecimal characters');
|
|
257
315
|
const active = this.activeRequests.get(requestId);
|
|
258
316
|
if (!active || active.finalizing)
|
|
259
317
|
throw new Error('owner task can be opened only for a currently active owner request');
|
|
260
|
-
if (!this.
|
|
318
|
+
if (!this.isEffectiveOwner(active.contact))
|
|
261
319
|
throw new Error('originating owner is no longer authorized');
|
|
262
320
|
const task = this.tasks.open({
|
|
263
321
|
requestId, contact: active.contact, wireId: active.wireId,
|
|
@@ -276,7 +334,7 @@ export class OwnerChannel {
|
|
|
276
334
|
throw new Error('owner task reports are allowed only after the originating request has finalized');
|
|
277
335
|
if (!this.authorizations.integrity().ok)
|
|
278
336
|
throw new Error('owner authorization state is corrupt; proactive reports are disabled');
|
|
279
|
-
if (!this.
|
|
337
|
+
if (!this.isEffectiveOwner(task.contact)) {
|
|
280
338
|
this.tasks.revoke(task.contact, now);
|
|
281
339
|
throw new Error('originating owner is no longer authorized; task revoked');
|
|
282
340
|
}
|
|
@@ -339,6 +397,22 @@ export class OwnerChannel {
|
|
|
339
397
|
throw new Error('owner task report must contain exactly one plain-text sentence');
|
|
340
398
|
return message;
|
|
341
399
|
}
|
|
400
|
+
safeProactiveMessage(value) {
|
|
401
|
+
if (typeof value !== 'string')
|
|
402
|
+
throw new Error('proactive owner message must be text');
|
|
403
|
+
const message = value.trim().normalize('NFC');
|
|
404
|
+
if (!message)
|
|
405
|
+
throw new Error('proactive owner message must not be empty');
|
|
406
|
+
if (Array.from(message).length > PROACTIVE_MESSAGE_MAX_CHARS
|
|
407
|
+
|| Buffer.byteLength(message) > PROACTIVE_MESSAGE_MAX_BYTES)
|
|
408
|
+
throw new Error(`proactive owner message exceeds ${PROACTIVE_MESSAGE_MAX_CHARS} characters or `
|
|
409
|
+
+ `${PROACTIVE_MESSAGE_MAX_BYTES} bytes`);
|
|
410
|
+
if (/\u0000|[\u202a-\u202e\u2066-\u2069]/u.test(message))
|
|
411
|
+
throw new Error('proactive owner message contains unsafe control characters');
|
|
412
|
+
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))
|
|
413
|
+
throw new Error('proactive owner message appears to contain unsafe reasoning, secret, or raw tool content');
|
|
414
|
+
return message;
|
|
415
|
+
}
|
|
342
416
|
async drainAll() {
|
|
343
417
|
// A finite cap protects the supervisor if a broken daemon repeats unread
|
|
344
418
|
// messages forever. A watch notification will resume draining later.
|
|
@@ -372,7 +446,7 @@ export class OwnerChannel {
|
|
|
372
446
|
const deferred = messages.filter(message => {
|
|
373
447
|
const wireId = this.wireId(message);
|
|
374
448
|
return wireId && !this.state.has(wireId)
|
|
375
|
-
&& this.
|
|
449
|
+
&& this.acceptedSender(this.sender(message).id)
|
|
376
450
|
&& Number.isInteger(message.msg_id);
|
|
377
451
|
}).map(message => message.msg_id);
|
|
378
452
|
if (deferred.length)
|
|
@@ -451,7 +525,7 @@ export class OwnerChannel {
|
|
|
451
525
|
return false;
|
|
452
526
|
const sender = { id: group.files[0].senderId, name: group.files[0].senderName };
|
|
453
527
|
if (group.files.some(file => file.senderId !== sender.id)
|
|
454
|
-
|| !this.
|
|
528
|
+
|| !this.isEffectiveOwner(sender.id)) {
|
|
455
529
|
this.options.log(`[${this.options.role}] owner channel ignored unauthorized attachment sender ${sender.id}`);
|
|
456
530
|
for (const wire of handledWireIds)
|
|
457
531
|
this.state.remember(wire);
|
|
@@ -571,14 +645,52 @@ export class OwnerChannel {
|
|
|
571
645
|
if (!wireId || this.state.has(wireId) || this.inFlight.has(wireId))
|
|
572
646
|
return false;
|
|
573
647
|
const sender = this.sender(message);
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
648
|
+
if (this.isAgentSender(sender.id)) {
|
|
649
|
+
try {
|
|
650
|
+
await this.relayManagedAgentMessage(message, wireId);
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
if (error instanceof DuplicateSendError) {
|
|
654
|
+
// A crash replay of a wire that already reached an owner. Consuming
|
|
655
|
+
// it silently IS the correct outcome; delivering again is the bug.
|
|
656
|
+
this.options.log(`[${this.options.role}] managed-agent relay replay of a delivered wire consumed`);
|
|
657
|
+
}
|
|
658
|
+
else if (error instanceof RelayUnroutableError) {
|
|
659
|
+
// No owner route exists yet. Leave the wire deferred and unconsumed
|
|
660
|
+
// so the daemon replays it after the first owner contact, and tell
|
|
661
|
+
// the authenticated agent once so the wait is never silent.
|
|
662
|
+
this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
|
|
663
|
+
+ `route yet; message stays queued: ${this.errorText(error)}`);
|
|
664
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
this.logError('managed-agent message relay refused', error);
|
|
669
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
this.state.remember(wireId);
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
if (!this.isEffectiveOwner(sender.id)) {
|
|
676
|
+
// Never answer the sender or reflect its body. Notify an owner through the
|
|
677
|
+
// bounded proactive route, then consume the attempt so it cannot replay.
|
|
578
678
|
this.options.log(`[${this.options.role}] owner channel ignored unauthorized sender ${sender.id || '<unknown>'}`);
|
|
679
|
+
await this.warnOwnerOfUnauthorizedSender(sender.id);
|
|
579
680
|
this.state.remember(wireId);
|
|
580
681
|
return true;
|
|
581
682
|
}
|
|
683
|
+
// Accepted authenticated inbound traffic selects the destination for the
|
|
684
|
+
// next unscoped proactive message. Distinct devices/identities naturally
|
|
685
|
+
// hand off this route by being the most recent sender.
|
|
686
|
+
try {
|
|
687
|
+
this.conversations.recordInbound(sender.id, wireId);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
// Proactive routing state is auxiliary. A corrupt/unwritable route file must
|
|
691
|
+
// never prevent an authenticated owner from using the ordinary channel.
|
|
692
|
+
this.logError('owner conversation route update failed', error);
|
|
693
|
+
}
|
|
582
694
|
const text = String(message.text ?? '').trim();
|
|
583
695
|
if (text.toLowerCase() === '/status') {
|
|
584
696
|
const snapshot = this.options.session.snapshot();
|
|
@@ -606,7 +718,7 @@ export class OwnerChannel {
|
|
|
606
718
|
let queued;
|
|
607
719
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
608
720
|
try {
|
|
609
|
-
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId,
|
|
721
|
+
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
|
|
610
722
|
interrupt: this.options.config.interrupt,
|
|
611
723
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
612
724
|
origin: { kind: 'owner', requestId },
|
|
@@ -619,10 +731,13 @@ export class OwnerChannel {
|
|
|
619
731
|
this.state.remember(wireId);
|
|
620
732
|
return true;
|
|
621
733
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
734
|
+
// Interrupting the live turn does not remove prompts which were already
|
|
735
|
+
// accepted into the ACP queue. Never claim this request is running while
|
|
736
|
+
// the session itself says earlier work remains ahead of it.
|
|
737
|
+
const accepted = queued.queuedBehind > 0
|
|
738
|
+
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
739
|
+
: this.options.config.interrupt
|
|
740
|
+
? ownerNotices.receivedInterrupting()
|
|
626
741
|
: ownerNotices.receivedStarted();
|
|
627
742
|
this.inFlight.add(wireId);
|
|
628
743
|
const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
|
|
@@ -645,34 +760,183 @@ export class OwnerChannel {
|
|
|
645
760
|
this.completionTasks.add(task);
|
|
646
761
|
return true;
|
|
647
762
|
}
|
|
763
|
+
acceptedSender(cid) {
|
|
764
|
+
return this.isAgentSender(cid) || this.isEffectiveOwner(cid);
|
|
765
|
+
}
|
|
766
|
+
isAgentSender(cid) {
|
|
767
|
+
const agent = this.options.config.agent;
|
|
768
|
+
return agent !== undefined && cid !== '' && canonicalCid(cid) === canonicalCid(agent);
|
|
769
|
+
}
|
|
770
|
+
isEffectiveOwner(cid) {
|
|
771
|
+
const canonical = canonicalCid(cid);
|
|
772
|
+
for (const owner of this.effectiveOwners())
|
|
773
|
+
if (canonicalCid(owner) === canonical)
|
|
774
|
+
return true;
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
async relayManagedAgentMessage(message, wireId) {
|
|
778
|
+
if (!this.authorizationIntegrity().ok)
|
|
779
|
+
throw new Error('owner authorization state is corrupt; managed-agent relay is disabled');
|
|
780
|
+
const text = this.safeRelayMessage(message.text);
|
|
781
|
+
let route;
|
|
782
|
+
try {
|
|
783
|
+
route = this.conversations.route(this.effectiveOwners());
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
throw new RelayUnroutableError(this.errorText(error));
|
|
787
|
+
}
|
|
788
|
+
// The inbound wire—not its body—is the idempotency key. Repeating the same
|
|
789
|
+
// wording in two deliberate messages remains valid, while crash replay of
|
|
790
|
+
// one message cannot produce two owner deliveries — to ANY owner, which is
|
|
791
|
+
// why the wire digest is checked across every recorded send.
|
|
792
|
+
const digest = createHash('sha256').update(`managed-agent-relay\0${wireId}`).digest('hex');
|
|
793
|
+
const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
|
|
794
|
+
try {
|
|
795
|
+
if (!this.isEffectiveOwner(route.contact))
|
|
796
|
+
throw new Error('selected relay owner is no longer authorized');
|
|
797
|
+
await this.send(await this.routableContact(route), text);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
try {
|
|
801
|
+
this.conversations.finishSend(sending.id, 'uncertain');
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
this.logError('managed-agent relay uncertainty persist failed', error);
|
|
805
|
+
}
|
|
806
|
+
throw new Error('managed-agent relay delivery outcome is uncertain; it was not retried');
|
|
807
|
+
}
|
|
808
|
+
this.conversations.finishSend(sending.id, 'delivered');
|
|
809
|
+
this.options.log(`[${this.options.role}] managed-agent message relayed `
|
|
810
|
+
+ `wire=${createHash('sha256').update(wireId).digest('hex').slice(0, 12)} `
|
|
811
|
+
+ `basis=${route.basis} chars=${Array.from(text).length} bytes=${Buffer.byteLength(text)}`);
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
815
|
+
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
816
|
+
* delivery is best-effort — it must never make the failure worse.
|
|
817
|
+
*/
|
|
818
|
+
async nackManagedAgent(contact, message, wireId, notice) {
|
|
819
|
+
if (this.relayNacks.has(wireId))
|
|
820
|
+
return;
|
|
821
|
+
this.relayNacks.add(wireId);
|
|
822
|
+
if (this.relayNacks.size > RELAY_NACK_MEMORY)
|
|
823
|
+
this.relayNacks.delete(this.relayNacks.values().next().value);
|
|
824
|
+
try {
|
|
825
|
+
await this.send(contact, notice, message.wire_id ? wireId : undefined);
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
this.logError('managed-agent relay NACK delivery failed', error);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Daemon contact resolution is case-exact, so a canonical config CID picked
|
|
833
|
+
* by the sole-owner fallback is translated to the daemon-known contact form
|
|
834
|
+
* when one exists. Last-inbound routes already carry the daemon form.
|
|
835
|
+
*/
|
|
836
|
+
async routableContact(route) {
|
|
837
|
+
if (route.basis !== 'sole-owner')
|
|
838
|
+
return route.contact;
|
|
839
|
+
try {
|
|
840
|
+
const canonical = canonicalCid(route.contact);
|
|
841
|
+
const match = (await this.contacts()).find(entry => canonicalCid(entry.cid) === canonical);
|
|
842
|
+
return match?.cid ?? route.contact;
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return route.contact;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
safeRelayMessage(value) {
|
|
849
|
+
if (typeof value !== 'string')
|
|
850
|
+
throw new Error('managed-agent relay must be text');
|
|
851
|
+
const message = value.normalize('NFC');
|
|
852
|
+
if (!message.trim())
|
|
853
|
+
throw new Error('managed-agent relay must not be empty');
|
|
854
|
+
if (Array.from(message).length > PROACTIVE_MESSAGE_MAX_CHARS
|
|
855
|
+
|| Buffer.byteLength(message) > PROACTIVE_MESSAGE_MAX_BYTES)
|
|
856
|
+
throw new Error(`managed-agent relay exceeds ${PROACTIVE_MESSAGE_MAX_CHARS} characters or `
|
|
857
|
+
+ `${PROACTIVE_MESSAGE_MAX_BYTES} bytes`);
|
|
858
|
+
if (/\u0000|[\u202a-\u202e\u2066-\u2069]/u.test(message))
|
|
859
|
+
throw new Error('managed-agent relay contains unsafe control characters');
|
|
860
|
+
return message;
|
|
861
|
+
}
|
|
862
|
+
async warnOwnerOfUnauthorizedSender(cid) {
|
|
863
|
+
const source = /^[A-Fa-f0-9]{64}$/.test(cid)
|
|
864
|
+
? cid
|
|
865
|
+
: `invalid-${createHash('sha256').update(cid).digest('hex').slice(0, 12)}`;
|
|
866
|
+
try {
|
|
867
|
+
await this.sendProactiveMessage(`⚠️ Owner-channel security warning: rejected a message from unauthorized `
|
|
868
|
+
+ `sender CID ${source}. Its body was not forwarded.`);
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
// Warning delivery must not make hostile input replayable or disclose
|
|
872
|
+
// anything to its sender. Rate/dedupe/no-route failures remain local.
|
|
873
|
+
this.logError('unauthorized sender warning suppressed', error);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
effectiveOwners() {
|
|
877
|
+
// In managed-agent mode the checked-in fleet configuration is the complete
|
|
878
|
+
// authority boundary. Ignore any legacy dynamic overlay left on disk.
|
|
879
|
+
return this.options.config.agent
|
|
880
|
+
? new Set(this.options.config.owners)
|
|
881
|
+
: this.authorizations.effective();
|
|
882
|
+
}
|
|
883
|
+
authorizationIntegrity() {
|
|
884
|
+
return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
|
|
885
|
+
}
|
|
648
886
|
async complete(active, outbox, queued, activityCursor) {
|
|
649
887
|
const progressMs = this.options.config.progress_interval_ms;
|
|
650
|
-
const startedAt = Date.now();
|
|
651
888
|
let lastSeq = activityCursor;
|
|
652
|
-
let
|
|
653
|
-
|
|
654
|
-
|
|
889
|
+
let startedAt;
|
|
890
|
+
let phase = 'starting request';
|
|
891
|
+
let timer;
|
|
892
|
+
const reportProgress = () => {
|
|
655
893
|
const events = this.options.session.eventsSince(lastSeq);
|
|
656
894
|
lastSeq = Math.max(lastSeq, this.latestEventSeq(events));
|
|
657
895
|
const activity = events.filter(event => event.turnId === queued.promptId);
|
|
896
|
+
if (!activity.length)
|
|
897
|
+
return;
|
|
898
|
+
startedAt ??= Date.parse(activity[0].at) || Date.now();
|
|
658
899
|
phase = this.progressPhase(activity) ?? phase;
|
|
659
900
|
const started = activity.filter(event => event.kind === 'tool_call').length;
|
|
660
901
|
const completed = activity.filter(event => event.kind === 'tool_update' && event.status === 'completed').length;
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
902
|
+
// Token/thought chunks are transport activity, not evidence of progress.
|
|
903
|
+
// Permission transitions and errors are material even without a tool.
|
|
904
|
+
const activityUpdates = activity.filter(event => event.kind === 'permission' || event.kind === 'error').length;
|
|
905
|
+
if (!started && !completed && !activityUpdates)
|
|
906
|
+
return;
|
|
664
907
|
const notice = ownerNotices.progress(Date.now() - startedAt, phase, started, completed, activityUpdates);
|
|
665
908
|
// Preserve wire ordering if a progress send overlaps turn completion.
|
|
666
909
|
active.outboundTail = active.outboundTail
|
|
667
910
|
.then(async () => { await this.send(active.contact, notice, active.wireId); })
|
|
668
911
|
.catch(error => this.logError('progress notice failed', error));
|
|
669
|
-
}
|
|
670
|
-
|
|
912
|
+
};
|
|
913
|
+
// A queued request used to create its 30-second interval immediately. A
|
|
914
|
+
// backlog of three requests therefore produced three permanent notice
|
|
915
|
+
// streams saying "waiting behind earlier requests" without any work. Arm
|
|
916
|
+
// the interval only after this exact prompt emits its first session event.
|
|
917
|
+
const startProgress = (event) => {
|
|
918
|
+
if (timer || progressMs <= 0)
|
|
919
|
+
return;
|
|
920
|
+
if (event && event.turnId !== queued.promptId)
|
|
921
|
+
return;
|
|
922
|
+
const observed = event ? [event] : this.options.session.eventsSince(activityCursor)
|
|
923
|
+
.filter(item => item.turnId === queued.promptId);
|
|
924
|
+
if (!observed.length)
|
|
925
|
+
return;
|
|
926
|
+
startedAt = Date.parse(observed[0].at) || Date.now();
|
|
927
|
+
timer = setInterval(reportProgress, progressMs);
|
|
928
|
+
timer.unref();
|
|
929
|
+
};
|
|
930
|
+
const unsubscribe = progressMs > 0
|
|
931
|
+
? this.options.session.subscribe(event => startProgress(event))
|
|
932
|
+
: undefined;
|
|
933
|
+
startProgress();
|
|
671
934
|
let result;
|
|
672
935
|
try {
|
|
673
936
|
result = await queued.completion;
|
|
674
937
|
}
|
|
675
938
|
finally {
|
|
939
|
+
unsubscribe?.();
|
|
676
940
|
if (timer)
|
|
677
941
|
clearInterval(timer);
|
|
678
942
|
}
|
|
@@ -723,22 +987,20 @@ export class OwnerChannel {
|
|
|
723
987
|
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
988
|
return lines.join('\n');
|
|
725
989
|
}
|
|
726
|
-
ownerPrompt(sender, text, wireId,
|
|
990
|
+
ownerPrompt(sender, text, wireId, outbox) {
|
|
727
991
|
return [
|
|
728
992
|
'[fleet-owner]',
|
|
729
993
|
`Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
|
|
730
994
|
'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.',
|
|
995
|
+
'Fleet extracts and routes your final assistant response deterministically; do not send the final through ours.',
|
|
996
|
+
...(this.options.config.agent ? [
|
|
997
|
+
'For any non-final message you want the owner to see—an update, blocker, suggestion, or later proactive note—call ours send_message with:',
|
|
998
|
+
`contact: ${this.options.config.identity}`,
|
|
999
|
+
'and the message text. Do not add a task ID, request ID, reply reference, phase, or routing command.',
|
|
1000
|
+
'Fleet accepts this relay only from your configured authenticated agent CID and forwards every accepted message as a new owner-channel message.',
|
|
1001
|
+
] : [
|
|
1002
|
+
'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
|
|
1003
|
+
]),
|
|
742
1004
|
'To attach files to your response, copy each finished file directly into this fleet outbox:',
|
|
743
1005
|
outbox,
|
|
744
1006
|
'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
|
|
@@ -756,7 +1018,7 @@ export class OwnerChannel {
|
|
|
756
1018
|
}
|
|
757
1019
|
send(contact, text, replyTo) {
|
|
758
1020
|
return this.client.callTool('send_message', {
|
|
759
|
-
contact, text, reply_to_wire_id: replyTo,
|
|
1021
|
+
contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
760
1022
|
});
|
|
761
1023
|
}
|
|
762
1024
|
async sendAttachments(contact, outbox, replyTo) {
|
|
@@ -785,7 +1047,10 @@ export class OwnerChannel {
|
|
|
785
1047
|
}
|
|
786
1048
|
}
|
|
787
1049
|
wireId(message) {
|
|
788
|
-
|
|
1050
|
+
const wire = String(message.wire_id ?? '').trim();
|
|
1051
|
+
if (wire)
|
|
1052
|
+
return wire;
|
|
1053
|
+
return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
|
|
789
1054
|
}
|
|
790
1055
|
sender(message) {
|
|
791
1056
|
const source = message.from ?? message.sender;
|
|
@@ -15,6 +15,8 @@ export declare const ownerNotices: {
|
|
|
15
15
|
progress: (elapsedMs: number, phase: OwnerProgressPhase, started: number, completed: number, activityUpdates?: number) => string;
|
|
16
16
|
authoredUpdate: (phase: OwnerUpdatePhase, message: string) => string;
|
|
17
17
|
taskReport: (phase: OwnerTaskPhase, message: string) => string;
|
|
18
|
+
relayQueued: () => string;
|
|
19
|
+
relayRefused: (reason: string) => string;
|
|
18
20
|
completedWithoutText: () => string;
|
|
19
21
|
terminal: (outcome: TurnOutcome) => "✅ Request completed." | "🛑 Request was cancelled before completion." | "⚠️ The agent declined this request." | "⚠️ Request failed before completion." | "⚠️ Request ended without a confirmed completion.";
|
|
20
22
|
chunk: (part: number, total: number) => string;
|
|
@@ -52,6 +52,9 @@ export const ownerNotices = {
|
|
|
52
52
|
case 'blocked': return `🚧 Follow-up blocked: ${message}`;
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
|
+
relayQueued: () => 'ℹ️ No owner has contacted this channel yet, so this message cannot be routed. '
|
|
56
|
+
+ 'It stays queued and will be relayed after the first owner message arrives.',
|
|
57
|
+
relayRefused: (reason) => `⚠️ This message was not relayed to an owner: ${reason}.`,
|
|
55
58
|
completedWithoutText: () => '✅ Request completed, but the agent returned no text.',
|
|
56
59
|
terminal: (outcome) => {
|
|
57
60
|
switch (outcome) {
|