@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.
@@ -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.authorizations.integrity();
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.authorizations.integrity().ok && this.tasks.integrity().ok)
69
- this.tasks.cleanup(Date.now(), this.authorizations.effective());
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.authorizations.integrity(),
142
- owners: this.authorizations.entries(),
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.authorizations.effective().has(active.contact))
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.authorizations.effective().has(task.contact)) {
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.authorizations.effective().has(this.sender(message).id)
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.authorizations.effective().has(sender.id)) {
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 (!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.
578
- this.options.log(`[${this.options.role}] owner channel ignored unauthorized sender ${sender.id || '<unknown>'}`);
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
- const text = String(message.text ?? '').trim();
583
- if (text.toLowerCase() === '/status') {
584
- const snapshot = this.options.session.snapshot();
585
- await this.send(sender.id, ownerNotices.status(this.options.role, snapshot), wireId);
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
- if (text.toLowerCase() === '/interrupt') {
590
- try {
591
- await this.options.session.interrupt('owner');
592
- }
593
- catch (error) {
594
- this.logError('interrupt failed', error);
595
- await this.send(sender.id, ownerNotices.interruptFailed(this.options.role), wireId);
596
- this.state.remember(wireId);
597
- return true;
598
- }
599
- await this.send(sender.id, ownerNotices.interrupted(this.options.role), wireId);
600
- this.state.remember(wireId);
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, requestId, outbox), {
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
- const accepted = this.options.config.interrupt
623
- ? ownerNotices.receivedInterrupting()
624
- : queued.queuedBehind > 0
625
- ? ownerNotices.receivedQueued(queued.queuedBehind)
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 phase = queued.queuedBehind > 0
653
- ? 'waiting behind earlier requests' : 'starting request';
654
- const timer = progressMs > 0 ? setInterval(() => {
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
- const activityUpdates = activity.filter(event => event.kind !== 'tool_call'
662
- && !(event.kind === 'tool_update' && event.status === 'completed')
663
- && event.kind !== 'turn_stop').length;
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
- }, progressMs) : undefined;
670
- timer?.unref();
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, requestId, outbox) {
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
- '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.',
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
- return String(message.wire_id ?? message.msg_id ?? '');
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;