@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.
@@ -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.authorizations.integrity();
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.authorizations.integrity().ok && this.tasks.integrity().ok)
69
- this.tasks.cleanup(Date.now(), this.authorizations.effective());
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.authorizations.integrity(),
142
- owners: this.authorizations.entries(),
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.authorizations.effective().has(active.contact))
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.authorizations.effective().has(task.contact)) {
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.authorizations.effective().has(this.sender(message).id)
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.authorizations.effective().has(sender.id)) {
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 (!this.authorizations.effective().has(sender.id)) {
575
- // Do not answer an unauthorized sender and thereby disclose that this is
576
- // a privileged control address. Authenticated CID, never display name or
577
- // message wording, is the authority boundary.
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, requestId, outbox), {
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
- const accepted = this.options.config.interrupt
623
- ? ownerNotices.receivedInterrupting()
624
- : queued.queuedBehind > 0
625
- ? ownerNotices.receivedQueued(queued.queuedBehind)
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 phase = queued.queuedBehind > 0
653
- ? 'waiting behind earlier requests' : 'starting request';
654
- const timer = progressMs > 0 ? setInterval(() => {
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
- const activityUpdates = activity.filter(event => event.kind !== 'tool_call'
662
- && !(event.kind === 'tool_update' && event.status === 'completed')
663
- && event.kind !== 'turn_stop').length;
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
- }, progressMs) : undefined;
670
- timer?.unref();
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, requestId, outbox) {
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
- 'Do not call ours send_message or send_file for this exchange: fleet routes the response reliably.',
732
- 'You may send a concise, high-level intermediate update through the bounded local primitive:',
733
- `ours-fleet owner-channel update ${this.options.role} ${requestId} --phase <working|approval|blocked> --message-stdin`,
734
- 'Write only the update body to stdin. Use one plain-text sentence; never include reasoning, secrets, logs, commands, or raw tool output.',
735
- 'Distinct updates are allowed at most once every 5 seconds. Fleet preserves receipt/update/final ordering and chooses the authenticated recipient.',
736
- 'If you delegate work that will finish after this turn, register it before finalizing:',
737
- `ours-fleet owner-channel task open ${this.options.role} ${requestId}`,
738
- 'Keep the returned opaque task ID. Finalize normally; do not hold this turn open or poll.',
739
- 'After a later fleet-mail wake, verify the result and report through:',
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
- return String(message.wire_id ?? message.msg_id ?? '');
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) {