@ours.network/fleet 0.13.2 → 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.
@@ -1,6 +1,11 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { dirname } from 'node:path';
3
4
  import { replaceFileAtomically } from '../atomic-file.js';
5
+ import { canonicalCid } from '../config.js';
6
+ /** A send whose (dedupe-scoped) digest was already recorded: it must not repeat. */
7
+ export class DuplicateSendError extends Error {
8
+ }
4
9
  /** Durable bounded dedupe containing wire IDs only — never message or reply plaintext. */
5
10
  export class OwnerChannelState {
6
11
  path;
@@ -36,6 +41,181 @@ export class OwnerChannelState {
36
41
  renameSync(tmp, this.path);
37
42
  }
38
43
  }
44
+ const CONVERSATION_LIMIT = 64;
45
+ const PROACTIVE_SEND_LIMIT = 256;
46
+ const PROACTIVE_MIN_INTERVAL_MS = 30_000;
47
+ const HEX_64_LOWER = /^[a-f0-9]{64}$/;
48
+ const CID = /^[A-Fa-f0-9]{64}$/;
49
+ /**
50
+ * Durable destination history for unscoped owner messages. It stores only
51
+ * authenticated CIDs, wire IDs, timestamps and content digests; never bodies,
52
+ * filenames or display names. A pre-send marker prevents blind replay after a
53
+ * crash or transport ambiguity.
54
+ */
55
+ export class OwnerConversationState {
56
+ path;
57
+ conversations = [];
58
+ sends = [];
59
+ corruptReason;
60
+ constructor(path) {
61
+ this.path = path;
62
+ if (!existsSync(path))
63
+ return;
64
+ try {
65
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
66
+ if (raw.version !== 1 || !Array.isArray(raw.conversations) || !Array.isArray(raw.sends)
67
+ || raw.conversations.length > CONVERSATION_LIMIT
68
+ || raw.sends.length > PROACTIVE_SEND_LIMIT
69
+ || !raw.conversations.every(record => this.validConversation(record))
70
+ || !raw.sends.every(send => this.validSend(send)))
71
+ throw new Error('invalid or unbounded conversation state');
72
+ if (new Set(raw.conversations.map(record => record.contact)).size !== raw.conversations.length
73
+ || new Set(raw.sends.map(send => send.id)).size !== raw.sends.length)
74
+ throw new Error('duplicate conversation state entry');
75
+ this.conversations = raw.conversations.map(record => ({ ...record }));
76
+ this.sends = raw.sends.map(send => ({ ...send }));
77
+ let recovered = false;
78
+ for (const send of this.sends) {
79
+ if (send.status === 'sending') {
80
+ send.status = 'uncertain';
81
+ recovered = true;
82
+ }
83
+ }
84
+ chmodSync(path, 0o600);
85
+ if (recovered)
86
+ this.persist();
87
+ }
88
+ catch {
89
+ this.corruptReason = 'invalid persisted owner conversation state';
90
+ this.conversations = [];
91
+ this.sends = [];
92
+ try {
93
+ chmodSync(path, 0o600);
94
+ }
95
+ catch { /* remain fail-closed */ }
96
+ }
97
+ }
98
+ integrity() {
99
+ return this.corruptReason ? { ok: false, error: this.corruptReason } : { ok: true };
100
+ }
101
+ recordInbound(contact, wireId, now = Date.now()) {
102
+ this.assertHealthy();
103
+ if (!CID.test(contact) || !wireId || wireId.length > 1_024)
104
+ throw new Error('owner conversation route is invalid');
105
+ const current = this.conversations.find(record => record.contact === contact);
106
+ const latest = this.conversations.reduce((maximum, record) => Math.max(maximum, record.lastInboundAt), 0);
107
+ // Date.now() can repeat (or move backwards). Preserve the actual accepted
108
+ // inbound order so two devices messaging in the same millisecond still
109
+ // produce one deterministic "last conversation" route.
110
+ const acceptedAt = Math.max(now, latest + 1);
111
+ this.mutate(() => {
112
+ if (current) {
113
+ current.lastInboundAt = acceptedAt;
114
+ current.lastInboundWireId = wireId;
115
+ }
116
+ else {
117
+ if (this.conversations.length >= CONVERSATION_LIMIT)
118
+ throw new Error(`owner conversations are limited to ${CONVERSATION_LIMIT}`);
119
+ this.conversations.push({ contact, lastInboundAt: acceptedAt, lastInboundWireId: wireId });
120
+ }
121
+ });
122
+ }
123
+ remove(contact) {
124
+ this.assertHealthy();
125
+ const canonical = canonicalCid(contact);
126
+ if (!this.conversations.some(record => canonicalCid(record.contact) === canonical))
127
+ return;
128
+ this.mutate(() => {
129
+ this.conversations = this.conversations.filter(record => canonicalCid(record.contact) !== canonical);
130
+ });
131
+ }
132
+ route(effective) {
133
+ this.assertHealthy();
134
+ // Membership is decided canonically (hex case is not identity); the
135
+ // returned contact keeps its stored form, which the daemon can route.
136
+ const canonical = new Set([...effective].map(canonicalCid));
137
+ const candidates = this.conversations
138
+ .filter(record => canonical.has(canonicalCid(record.contact)))
139
+ .sort((a, b) => b.lastInboundAt - a.lastInboundAt);
140
+ if (candidates.length) {
141
+ if (candidates[1]?.lastInboundAt === candidates[0].lastInboundAt)
142
+ throw new Error('proactive owner route is ambiguous');
143
+ return { contact: candidates[0].contact, basis: 'last-inbound' };
144
+ }
145
+ if (canonical.size === 1)
146
+ return { contact: [...effective][0], basis: 'sole-owner' };
147
+ throw new Error('no authenticated owner conversation route is available yet');
148
+ }
149
+ beginSend(contact, digest, now = Date.now(), minIntervalMs = PROACTIVE_MIN_INTERVAL_MS, dedupe = 'contact') {
150
+ this.assertHealthy();
151
+ if (!CID.test(contact) || !HEX_64_LOWER.test(digest))
152
+ throw new Error('proactive owner send metadata is invalid');
153
+ const canonical = canonicalCid(contact);
154
+ const recent = this.sends.filter(send => canonicalCid(send.contact) === canonical).slice(-128);
155
+ // 'all' scope serves wire-keyed idempotency: a crash replay must not
156
+ // deliver the same wire to a second owner after the route moved.
157
+ const scope = dedupe === 'all' ? this.sends.slice(-128) : recent;
158
+ if (scope.some(send => send.digest === digest))
159
+ throw new DuplicateSendError('duplicate proactive owner message refused');
160
+ const last = recent.at(-1);
161
+ if (last && now - last.at < minIntervalMs)
162
+ throw new Error(`proactive owner messages are rate-limited to one every ${minIntervalMs}ms`);
163
+ const send = {
164
+ id: randomBytes(32).toString('hex'), contact, digest, at: now, status: 'sending',
165
+ };
166
+ this.mutate(() => {
167
+ this.sends.push(send);
168
+ this.sends = this.sends.slice(-PROACTIVE_SEND_LIMIT);
169
+ });
170
+ return { ...send };
171
+ }
172
+ finishSend(id, status) {
173
+ this.assertHealthy();
174
+ const send = this.sends.find(item => item.id === id);
175
+ if (!send || send.status !== 'sending')
176
+ throw new Error('proactive owner send state changed unexpectedly');
177
+ this.mutate(() => { send.status = status; });
178
+ }
179
+ mutate(change) {
180
+ const snapshot = JSON.stringify({ conversations: this.conversations, sends: this.sends });
181
+ change();
182
+ try {
183
+ this.persist();
184
+ }
185
+ catch (error) {
186
+ const old = JSON.parse(snapshot);
187
+ this.conversations = old.conversations;
188
+ this.sends = old.sends;
189
+ throw error;
190
+ }
191
+ }
192
+ persist() {
193
+ replaceFileAtomically(this.path, JSON.stringify({
194
+ version: 1, conversations: this.conversations, sends: this.sends,
195
+ }) + '\n', 0o600);
196
+ chmodSync(this.path, 0o600);
197
+ }
198
+ assertHealthy() {
199
+ if (this.corruptReason)
200
+ throw new Error(`owner conversation state is corrupt; refusing operation: ${this.corruptReason}`);
201
+ }
202
+ validConversation(value) {
203
+ if (!value || typeof value !== 'object')
204
+ return false;
205
+ const record = value;
206
+ return CID.test(record.contact) && Number.isSafeInteger(record.lastInboundAt)
207
+ && record.lastInboundAt >= 0 && typeof record.lastInboundWireId === 'string'
208
+ && record.lastInboundWireId.length > 0 && record.lastInboundWireId.length <= 1_024;
209
+ }
210
+ validSend(value) {
211
+ if (!value || typeof value !== 'object')
212
+ return false;
213
+ const send = value;
214
+ return HEX_64_LOWER.test(send.id) && CID.test(send.contact) && HEX_64_LOWER.test(send.digest)
215
+ && Number.isSafeInteger(send.at) && send.at >= 0
216
+ && ['sending', 'delivered', 'uncertain'].includes(send.status);
217
+ }
218
+ }
39
219
  const MAX_OVERLAY_CIDS = 1_000;
40
220
  const MAX_AUDIT_ENTRIES = 500;
41
221
  /**
@@ -73,9 +253,11 @@ export class OwnerAuthorizationState {
73
253
  this.added = new Set(raw.added);
74
254
  this.revoked = new Set(raw.revoked);
75
255
  this.audit = raw.audit;
76
- if (this.added.size !== raw.added.length || this.revoked.size !== raw.revoked.length)
256
+ if (new Set(raw.added.map(canonicalCid)).size !== raw.added.length
257
+ || new Set(raw.revoked.map(canonicalCid)).size !== raw.revoked.length)
77
258
  throw new Error('duplicate overlay CID');
78
- if ([...this.added].some(cid => this.revoked.has(cid)))
259
+ const revokedCanonical = new Set([...this.revoked].map(canonicalCid));
260
+ if ([...this.added].some(cid => revokedCanonical.has(canonicalCid(cid))))
79
261
  throw new Error('CID appears in both added and revoked overlays');
80
262
  chmodSync(path, 0o600);
81
263
  }
@@ -96,32 +278,44 @@ export class OwnerAuthorizationState {
96
278
  effective() {
97
279
  if (this.corruptReason)
98
280
  return new Set();
281
+ // Stored forms stay verbatim (the daemon routes them case-exactly); the
282
+ // revocation check is canonical so a casing change can never resurrect a
283
+ // revoked owner.
284
+ const revoked = new Set([...this.revoked].map(canonicalCid));
99
285
  const effective = new Set([...this.baseline, ...this.added]);
100
- for (const cid of this.revoked)
101
- effective.delete(cid);
286
+ for (const cid of [...effective])
287
+ if (revoked.has(canonicalCid(cid)))
288
+ effective.delete(cid);
102
289
  return effective;
103
290
  }
104
291
  entries() {
105
- const effective = this.effective();
106
- return [...new Set([...this.baseline, ...this.added, ...this.revoked])]
107
- .sort().map(cid => ({
292
+ const effective = new Set([...this.effective()].map(canonicalCid));
293
+ const seen = new Set();
294
+ const all = [];
295
+ for (const cid of [...this.baseline, ...this.added, ...this.revoked]) {
296
+ if (seen.has(canonicalCid(cid)))
297
+ continue;
298
+ seen.add(canonicalCid(cid));
299
+ all.push(cid);
300
+ }
301
+ return all.sort().map(cid => ({
108
302
  cid,
109
- source: this.baseline.has(cid) ? 'baseline' : 'dynamic',
110
- effective: effective.has(cid),
303
+ source: this.inBaseline(cid) ? 'baseline' : 'dynamic',
304
+ effective: effective.has(canonicalCid(cid)),
111
305
  }));
112
306
  }
113
307
  authorize(cid) {
114
308
  this.assertHealthy();
115
- if (this.effective().has(cid))
309
+ if (this.hasCanonical(this.effective(), cid))
116
310
  throw new Error(`owner '${cid}' is already authorized`);
117
311
  const rollback = this.snapshot();
118
- if (this.baseline.has(cid))
119
- this.revoked.delete(cid);
312
+ if (this.inBaseline(cid))
313
+ this.deleteCanonical(this.revoked, cid);
120
314
  else {
121
315
  if (this.added.size + this.revoked.size >= MAX_OVERLAY_CIDS)
122
316
  throw new Error(`owner authorization overlay is limited to ${MAX_OVERLAY_CIDS} CIDs`);
123
317
  this.added.add(cid);
124
- this.revoked.delete(cid);
318
+ this.deleteCanonical(this.revoked, cid);
125
319
  }
126
320
  this.record('authorize', cid);
127
321
  try {
@@ -131,20 +325,20 @@ export class OwnerAuthorizationState {
131
325
  this.restore(rollback);
132
326
  throw error;
133
327
  }
134
- return { cid, source: this.baseline.has(cid) ? 'baseline' : 'dynamic', effective: true };
328
+ return { cid, source: this.inBaseline(cid) ? 'baseline' : 'dynamic', effective: true };
135
329
  }
136
330
  revoke(cid) {
137
331
  this.assertHealthy();
138
332
  const effective = this.effective();
139
- if (!effective.has(cid))
333
+ if (!this.hasCanonical(effective, cid))
140
334
  throw new Error(`owner '${cid}' is not authorized`);
141
- if (effective.size === 1)
335
+ if (new Set([...effective].map(canonicalCid)).size === 1)
142
336
  throw new Error('refusing to revoke the last effective owner');
143
337
  const rollback = this.snapshot();
144
- if (this.baseline.has(cid))
338
+ if (this.inBaseline(cid))
145
339
  this.revoked.add(cid);
146
340
  else
147
- this.added.delete(cid);
341
+ this.deleteCanonical(this.added, cid);
148
342
  this.record('revoke', cid);
149
343
  try {
150
344
  this.persist();
@@ -153,7 +347,23 @@ export class OwnerAuthorizationState {
153
347
  this.restore(rollback);
154
348
  throw error;
155
349
  }
156
- return { cid, source: this.baseline.has(cid) ? 'baseline' : 'dynamic', effective: false };
350
+ return { cid, source: this.inBaseline(cid) ? 'baseline' : 'dynamic', effective: false };
351
+ }
352
+ inBaseline(cid) {
353
+ return this.hasCanonical(this.baseline, cid);
354
+ }
355
+ hasCanonical(set, cid) {
356
+ const canonical = canonicalCid(cid);
357
+ for (const member of set)
358
+ if (canonicalCid(member) === canonical)
359
+ return true;
360
+ return false;
361
+ }
362
+ deleteCanonical(set, cid) {
363
+ const canonical = canonicalCid(cid);
364
+ for (const member of [...set])
365
+ if (canonicalCid(member) === canonical)
366
+ set.delete(member);
157
367
  }
158
368
  assertHealthy() {
159
369
  if (this.corruptReason)
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { chmodSync, existsSync, readFileSync } from 'node:fs';
3
3
  import { replaceFileAtomically } from '../atomic-file.js';
4
+ import { canonicalCid } from '../config.js';
4
5
  export const OWNER_TASK_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
5
6
  export const OWNER_TASK_MAX_OPEN = 32;
6
7
  export const OWNER_TASK_MAX_PER_OWNER = 8;
@@ -69,7 +70,8 @@ export class OwnerTaskState {
69
70
  this.cleanup(now);
70
71
  if (this.tasks.length >= OWNER_TASK_MAX_OPEN)
71
72
  throw new Error(`owner channel is limited to ${OWNER_TASK_MAX_OPEN} open tasks`);
72
- if (this.tasks.filter(task => task.contact === route.contact).length >= OWNER_TASK_MAX_PER_OWNER)
73
+ if (this.tasks.filter(task => canonicalCid(task.contact) === canonicalCid(route.contact))
74
+ .length >= OWNER_TASK_MAX_PER_OWNER)
73
75
  throw new Error(`an owner is limited to ${OWNER_TASK_MAX_PER_OWNER} open tasks per role`);
74
76
  let id;
75
77
  do {
@@ -139,7 +141,7 @@ export class OwnerTaskState {
139
141
  }
140
142
  revoke(contact, now = Date.now()) {
141
143
  this.assertHealthy();
142
- const revoked = this.tasks.filter(task => task.contact === contact);
144
+ const revoked = this.tasks.filter(task => canonicalCid(task.contact) === canonicalCid(contact));
143
145
  if (!revoked.length)
144
146
  return 0;
145
147
  this.mutate(() => { for (const task of revoked)
@@ -149,8 +151,9 @@ export class OwnerTaskState {
149
151
  cleanup(now = Date.now(), effectiveOwners) {
150
152
  this.assertHealthy();
151
153
  const expired = this.tasks.filter(task => task.expiresAt <= now);
152
- const revoked = effectiveOwners
153
- ? this.tasks.filter(task => !effectiveOwners.has(task.contact) && !expired.includes(task)) : [];
154
+ const canonicalOwners = effectiveOwners && new Set([...effectiveOwners].map(canonicalCid));
155
+ const revoked = canonicalOwners
156
+ ? this.tasks.filter(task => !canonicalOwners.has(canonicalCid(task.contact)) && !expired.includes(task)) : [];
154
157
  const oldTombstones = this.tombstones.filter(item => now - item.at > TOMBSTONE_TTL_MS);
155
158
  if (!expired.length && !revoked.length && !oldTombstones.length)
156
159
  return 0;
@@ -11,6 +11,8 @@ export interface AcpSessionOptions {
11
11
  /** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
12
12
  modeId?: string;
13
13
  log(line: string): void;
14
+ /** Test seam for the cancel-escalation grace period; production uses the default. */
15
+ cancelGraceMs?: number;
14
16
  }
15
17
  /**
16
18
  * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
@@ -40,6 +42,7 @@ export declare class AcpSession implements SessionHandle {
40
42
  private steeringSupported;
41
43
  private capabilities?;
42
44
  private controllerCount;
45
+ private cancelEscalation?;
43
46
  private activeTurn?;
44
47
  private constructor();
45
48
  static start(options: AcpSessionOptions): Promise<AcpSession>;
@@ -6,6 +6,7 @@ import { Readable, Writable } from 'node:stream';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
7
  import { SessionEvents } from './events.js';
8
8
  import { SessionControlError, classifyChildExit, turnResult } from './types.js';
9
+ const CANCEL_SETTLE_GRACE_MS = 15_000;
9
10
  /**
10
11
  * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
11
12
  * cancellation are the two ways a delivered prompt ends without being carried
@@ -40,6 +41,7 @@ export class AcpSession {
40
41
  steeringSupported = false;
41
42
  capabilities;
42
43
  controllerCount = 0;
44
+ cancelEscalation;
43
45
  activeTurn;
44
46
  constructor(options, child, connection) {
45
47
  this.options = options;
@@ -165,6 +167,20 @@ export class AcpSession {
165
167
  active.cancellationSource = previousSource;
166
168
  throw error;
167
169
  }
170
+ if (active) {
171
+ if (this.cancelEscalation)
172
+ clearTimeout(this.cancelEscalation);
173
+ const turnId = active.id;
174
+ this.cancelEscalation = setTimeout(() => {
175
+ if (this.activeTurn?.id !== turnId || !this.isAlive())
176
+ return;
177
+ this.lastError = 'ACP turn ignored cancellation; restarting adapter';
178
+ this.options.log(`[${this.options.name}] ${this.lastError}`);
179
+ this.events.emit('error', { turnId, origin: active.origin, text: this.lastError });
180
+ this.child.kill('SIGTERM');
181
+ }, this.options.cancelGraceMs ?? CANCEL_SETTLE_GRACE_MS);
182
+ this.cancelEscalation.unref?.();
183
+ }
168
184
  for (const pending of this.pendingPermissions.values())
169
185
  pending.resolve({ outcome: { outcome: 'cancelled' } });
170
186
  this.pendingPermissions.clear();
@@ -202,6 +218,9 @@ export class AcpSession {
202
218
  return this.exit;
203
219
  }
204
220
  async close() {
221
+ if (this.cancelEscalation)
222
+ clearTimeout(this.cancelEscalation);
223
+ this.cancelEscalation = undefined;
205
224
  for (const pending of this.pendingPermissions.values())
206
225
  pending.resolve({ outcome: { outcome: 'cancelled' } });
207
226
  this.pendingPermissions.clear();
@@ -305,8 +324,12 @@ export class AcpSession {
305
324
  return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
306
325
  }
307
326
  finally {
308
- if (this.activeTurn?.id === turnId)
327
+ if (this.activeTurn?.id === turnId) {
328
+ if (this.cancelEscalation)
329
+ clearTimeout(this.cancelEscalation);
330
+ this.cancelEscalation = undefined;
309
331
  this.activeTurn = undefined;
332
+ }
310
333
  }
311
334
  }
312
335
  async steerPrompt(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",