@ours.network/cli 2.0.3 → 2.1.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.
@@ -4,19 +4,23 @@ import {
4
4
  CONFIG,
5
5
  FILE_SELECTION_CAP,
6
6
  GC_INTERVAL_MS,
7
+ HISTORY_MAX_BYTES,
7
8
  PORT,
8
9
  PROTOCOL_VERSION,
9
10
  STATE_DIR,
10
11
  VERSION,
12
+ adoptQuarantinedIdentities,
11
13
  appendNotifyLog,
12
14
  bindSession,
13
15
  bootWrapper,
14
16
  capabilityReconcileSweep,
15
17
  clearNotifyHook,
18
+ closeHistory,
16
19
  closeTemporaryIdentity,
17
20
  clusterSweep,
18
21
  consumeOutboundHistoryFailure,
19
22
  contactRestoreSweep,
23
+ createPacket,
20
24
  decodeWireBin,
21
25
  delegateRole,
22
26
  deleteIdentityCompletely,
@@ -26,13 +30,18 @@ import {
26
30
  envelopeDispatch,
27
31
  establishRoot,
28
32
  exportAdBlob,
33
+ exportSigningSecret,
29
34
  findIdentityFile,
30
35
  getFileHistoryItem,
36
+ getHistoryStorageStatus,
31
37
  getMessageHistoryItem,
32
38
  getMessageHistorySummary,
33
39
  hashLeaseToken,
34
40
  identities,
41
+ identityDir,
42
+ initializeHistoryRetention,
35
43
  isSelectableWireId,
44
+ keyPath,
36
45
  leaseByToken,
37
46
  leases,
38
47
  listFileHistory,
@@ -41,13 +50,19 @@ import {
41
50
  listMessageHistory,
42
51
  log,
43
52
  mutatingTx,
53
+ noteHistoryStorageMutation,
54
+ openHistory,
44
55
  outboundRemovalInFlight,
45
56
  persistBindings,
46
- pidAlive,
57
+ pidDefinitelyDead,
58
+ pinHistoryItems,
59
+ pinRegistrar,
47
60
  provisionIdentity,
48
61
  publishToBook,
62
+ quarantinedIdentities,
49
63
  readBook,
50
64
  readonlyTx,
65
+ reapStaleTemporaryIdentities,
51
66
  refreshUnread,
52
67
  registrar,
53
68
  renderContactRoots,
@@ -60,39 +75,189 @@ import {
60
75
  resolveBound,
61
76
  rootName,
62
77
  saveState,
78
+ saveStateFailClosed,
63
79
  scheduleCapabilityReconcile,
64
80
  serveNotifications,
65
81
  sessionHeaders,
82
+ sessionReaperIntervalMs,
66
83
  setNotifyHook,
67
84
  startupProgress,
85
+ stopHistoryRetention,
68
86
  structuredVoiceOutcome,
69
87
  sttStatus,
70
- sweepStaleTempIdentities,
71
88
  takeUnreadFiles,
72
89
  takeUnreadMessages,
73
90
  tombstones,
74
91
  transcribeVoice,
92
+ unpinHistoryItems,
75
93
  unpublishFromBook,
76
94
  unreadSummary,
77
95
  validateName,
78
96
  voiceDeliveryLine,
79
97
  withScope,
80
98
  withScopeAsync,
99
+ wrapper,
81
100
  writeTempMetaFile
82
- } from "./chunk-ZCCCTR27.js";
101
+ } from "./chunk-YPDZQ4XD.js";
83
102
  import {
84
103
  buildIdentityFile,
85
104
  writeIdentityFile
86
- } from "./chunk-FXKFSNKS.js";
105
+ } from "./chunk-DTWZF3ZG.js";
87
106
 
88
107
  // ../../src/http/server.ts
89
108
  import { createServer as createHttpServer } from "node:http";
90
109
  import { randomUUID } from "node:crypto";
91
- import * as fs2 from "node:fs";
110
+ import * as fs3 from "node:fs";
92
111
 
93
112
  // ../../src/protocol.ts
94
113
  var OURS_COMPAT_VERSION = 1;
95
114
 
115
+ // ../../src/errors.ts
116
+ var OursError = class extends Error {
117
+ // `details` is PURELY ADDITIVE and optional: every existing construction and
118
+ // every `e.code` check keeps working untouched. It exists because a refusal a
119
+ // caller is expected to ACT on (trim N bytes, shorten the filename) cannot be
120
+ // delivered as prose alone — see errFileTooLarge.
121
+ constructor(code, message, details) {
122
+ super(message);
123
+ this.code = code;
124
+ this.details = details;
125
+ this.name = "OursError";
126
+ }
127
+ };
128
+ var errNameTaken = (tool, name) => new OursError("NAME_TAKEN", `${tool} failed: an identity named "${name}" already exists.`);
129
+ var errNoLeaseHeaders = () => new OursError(
130
+ "NO_LEASE_HEADERS",
131
+ "create_temporary_identity failed: this client is not connected through the ours connector (no lease token / client pid headers), so there is no session lease to own the identity. Launch ours via the connector (`ours-mcp proxy`)."
132
+ );
133
+ var errRandomNameExhausted = () => new OursError(
134
+ "RANDOM_NAME_EXHAUSTED",
135
+ "create_temporary_identity failed: could not pick a free random name after 5 attempts \u2014 retry."
136
+ );
137
+ var errRootExists = (rootName2) => new OursError(
138
+ "ROOT_EXISTS",
139
+ `create_root_identity failed: a root identity already exists ("${rootName2}") \u2014 one root per host. Nothing to do.`
140
+ );
141
+ var errNotBoundNoName = () => new OursError("NOT_BOUND_NO_NAME", "close_temporary_identity failed: no identity is bound to this session and no name was given.");
142
+ var errNotTemporary = (name) => new OursError(
143
+ "NOT_TEMPORARY",
144
+ `close_temporary_identity failed: "${name}" is a permanent identity. Use remove_identity if you really mean to delete it.`
145
+ );
146
+ var errTempOwnedElsewhereClose = (name, pid) => new OursError(
147
+ "TEMP_OWNED_ELSEWHERE",
148
+ `close_temporary_identity failed: "${name}" is owned by another LIVE session (owner pid ${pid}) \u2014 one session cannot delete another's temporary identity.`
149
+ );
150
+ var errTempClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}. ${remote}`);
151
+ var errPathNotAbsolute = (path) => new OursError("PATH_NOT_ABSOLUTE", `define_local_identity_file failed: path must be absolute (got "${path}").`);
152
+ var errNoSuchIdentity = (name) => new OursError("NO_SUCH_IDENTITY", `choose_identity failed: no identity named "${name}". Create it with create_identity.`);
153
+ var errNoLeaseToken = () => new OursError(
154
+ "NO_LEASE_TOKEN",
155
+ "choose_identity failed: this client is not connected through the ours connector (no lease token header). Launch ours via the connector (`ours-mcp proxy`)."
156
+ );
157
+ var errTempClosingChoose = (name) => new OursError("TEMP_CLOSING", `choose_identity failed: temporary identity "${name}" is closing \u2014 its state is being deleted.`);
158
+ var errTempOwnedLive = (name, pid) => new OursError(
159
+ "TEMP_OWNED_ELSEWHERE",
160
+ `choose_identity failed: "${name}" is a TEMPORARY identity owned by another live session (owner pid ${pid}). Ownership is exclusive and cannot be overridden \u2014 not even with force. Create your own with create_temporary_identity.`
161
+ );
162
+ var errTempStale = (name, pid) => new OursError(
163
+ "TEMP_STALE",
164
+ `choose_identity failed: temporary identity "${name}" is STALE \u2014 its owning session (pid ${pid}) is gone and it is pending automatic cleanup. It cannot be adopted by another session; use close_temporary_identity to reclaim (clean it up) now.`
165
+ );
166
+ var errBoundElsewhere = (name) => new OursError(
167
+ "BOUND_ELSEWHERE",
168
+ `choose_identity declined: "${name}" is currently bound to another live session. Do not retry with force=true on your own \u2014 tell the user it is in use elsewhere and ask whether to forcibly rebind it here; only retry with force=true after they explicitly confirm.`
169
+ );
170
+ var errTempOwnedElsewhereRemove = (name, pid) => new OursError(
171
+ "TEMP_OWNED_ELSEWHERE",
172
+ `remove_identity failed: "${name}" is a TEMPORARY identity owned by another LIVE session (owner pid ${pid}) \u2014 one session cannot delete another's temporary identity.`
173
+ );
174
+ var errIdentityClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}.${remote}`);
175
+ var errRootHasRoles = (name, roles) => new OursError(
176
+ "ROOT_HAS_ROLES",
177
+ `remove_identity failed: "${name}" is the root identity and still has ${roles.length} role(s): ${roles.join(", ")}. Remove the roles first.`
178
+ );
179
+ var errIdentityPartiallyRemoved = (name, fail) => new OursError("DELETE_PARTIAL", `Identity "${name}" removed from memory, but ${fail}`);
180
+ var errPublicInviteNamed = () => new OursError(
181
+ "PUBLIC_INVITE_NAMED",
182
+ "generate_invite failed: a public invite cannot pre-assign a contact name \u2014 every redeemer would be registered under it. Omit `name` for a public invite."
183
+ );
184
+ var errNoPolicyArgs = () => new OursError("NO_POLICY_ARGS", "set_local_book_policy: pass expose and/or auto_accept.");
185
+ var errSendFileArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path` or `data_base64`.");
186
+ var errSendFileFilenameRequired = () => new OursError("SEND_ARGS", "send_file: `filename` is required with `data_base64`.");
187
+ var errFileUnreadable = (detail) => new OursError("FILE_UNREADABLE", `send_file: cannot read file: ${detail}`);
188
+ var errSendFileUploadArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path`, `data_base64` or `upload_id`.");
189
+ var errUploadNotFound = (uploadId) => new OursError(
190
+ "FILE_UNREADABLE",
191
+ `send_file: no staged upload "${uploadId}" for the bound identity. Upload the bytes with POST /files/upload first. A staged upload is consumed by the first send that names it \u2014 including a send that failed \u2014 so a retry needs a fresh upload.`
192
+ );
193
+ var errFileTooLarge = (i) => new OursError(
194
+ "FILE_TOO_LARGE",
195
+ `send_file: "${i.filename}" is ${i.bytes} bytes. This send's envelope would serialize to ${i.envelopeBytes} bytes, over the transport's ${i.limit}-byte budget \u2014 the largest payload it can carry with this filename, MIME and reply reference is ${i.maxPayloadBytes} bytes. Nothing was sent.`,
196
+ i
197
+ );
198
+ var errNoRoot = () => new OursError("NO_ROOT", "No root identity exists on this host \u2014 create one with create_root_identity first.");
199
+ var errInvalidSelection = (cap) => new OursError("INVALID_SELECTION", `get_files failed: wire_ids must contain 1-${cap} items.`);
200
+ var errMalformedId = () => new OursError("MALFORMED_ID", "get_files failed: every selected wire_id must be exactly 64 hexadecimal characters.");
201
+ var errDuplicateId = () => new OursError("DUPLICATE_ID", "get_files failed: wire_ids must not contain duplicates.");
202
+ var errUnknownOrStaleId = () => new OursError(
203
+ "UNKNOWN_OR_STALE_ID",
204
+ "get_files failed: one or more selected wire_ids is unknown, stale, or no longer unread; no files were retrieved."
205
+ );
206
+ var errTool = (tool, detail, code = "TX_FAILED") => new OursError(code, `${tool} failed: ${detail}`);
207
+
208
+ // ../../src/identity/release.ts
209
+ async function releaseLeaseByToken(token) {
210
+ const tokenHash = hashLeaseToken(token);
211
+ const released = [];
212
+ for (const [name, lease] of [...leases]) {
213
+ if (lease.token !== token) continue;
214
+ leases.delete(name);
215
+ released.push(name);
216
+ }
217
+ const candidates = [
218
+ ...identities.values(),
219
+ ...[...quarantinedIdentities.values()].map((held) => held.identity)
220
+ ];
221
+ const owned = candidates.filter(
222
+ (id) => id.temp?.owner.tokenHash === tokenHash
223
+ );
224
+ const settled = await Promise.allSettled(
225
+ owned.map((id) => closeTemporaryIdentity(id, "owning session released its lease"))
226
+ );
227
+ const closed = [];
228
+ let attempted = 0;
229
+ let notified = 0;
230
+ let failed = 0;
231
+ const localFailures = [];
232
+ for (let i = 0; i < settled.length; i++) {
233
+ const item = settled[i];
234
+ const name = owned[i].name;
235
+ if (item.status === "rejected") {
236
+ localFailures.push({ name, error: String(item.reason) });
237
+ continue;
238
+ }
239
+ const result2 = item.value;
240
+ quarantinedIdentities.delete(name);
241
+ closed.push(name);
242
+ attempted += result2.attempted;
243
+ notified += result2.notified;
244
+ failed += result2.failed;
245
+ if (result2.deleteError) localFailures.push({ name, error: result2.deleteError });
246
+ }
247
+ tombstones.delete(token);
248
+ persistBindings();
249
+ log(`lease released by token \u2026${token.slice(-6)} (${closed.length} temporary identity close(s) awaited)`);
250
+ const result = { released, closed, attempted, notified, failed };
251
+ if (localFailures.length > 0) {
252
+ throw new OursError(
253
+ "DELETE_PARTIAL",
254
+ `Lease release completed with ${localFailures.length} local cleanup failure(s).`,
255
+ { result, localFailures }
256
+ );
257
+ }
258
+ return result;
259
+ }
260
+
96
261
  // ../../src/gc.ts
97
262
  var gcTimer = null;
98
263
  var gcRunning = false;
@@ -350,101 +515,86 @@ function checkFileEnvelope(i) {
350
515
  };
351
516
  }
352
517
 
353
- // ../../src/errors.ts
354
- var OursError = class extends Error {
355
- // `details` is PURELY ADDITIVE and optional: every existing construction and
356
- // every `e.code` check keeps working untouched. It exists because a refusal a
357
- // caller is expected to ACT on (trim N bytes, shorten the filename) cannot be
358
- // delivered as prose alone — see errFileTooLarge.
359
- constructor(code, message, details) {
360
- super(message);
361
- this.code = code;
362
- this.details = details;
363
- this.name = "OursError";
518
+ // ../../src/api/identity.ts
519
+ import { randomBytes as randomBytes2 } from "node:crypto";
520
+ import { isAbsolute } from "node:path";
521
+
522
+ // ../../src/identity/staged.ts
523
+ import * as fs2 from "node:fs";
524
+ import { randomBytes } from "node:crypto";
525
+ async function provisionIdentityUnexposed(name, opts) {
526
+ if (reservedNames.has(name) || identities.has(name)) {
527
+ throw new Error(`identity name "${name}" is reserved or already present`);
364
528
  }
365
- };
366
- var errNameTaken = (tool, name) => new OursError("NAME_TAKEN", `${tool} failed: an identity named "${name}" already exists.`);
367
- var errNoLeaseHeaders = () => new OursError(
368
- "NO_LEASE_HEADERS",
369
- "create_temporary_identity failed: this client is not connected through the ours connector (no lease token / client pid headers), so there is no session lease to own the identity. Launch ours via the connector (`ours-mcp proxy`)."
370
- );
371
- var errRandomNameExhausted = () => new OursError(
372
- "RANDOM_NAME_EXHAUSTED",
373
- "create_temporary_identity failed: could not pick a free random name after 5 attempts \u2014 retry."
374
- );
375
- var errRootExists = (rootName2) => new OursError(
376
- "ROOT_EXISTS",
377
- `create_root_identity failed: a root identity already exists ("${rootName2}") \u2014 one root per host. Nothing to do.`
378
- );
379
- var errNotBoundNoName = () => new OursError("NOT_BOUND_NO_NAME", "close_temporary_identity failed: no identity is bound to this session and no name was given.");
380
- var errNotTemporary = (name) => new OursError(
381
- "NOT_TEMPORARY",
382
- `close_temporary_identity failed: "${name}" is a permanent identity. Use remove_identity if you really mean to delete it.`
383
- );
384
- var errTempOwnedElsewhereClose = (name, pid) => new OursError(
385
- "TEMP_OWNED_ELSEWHERE",
386
- `close_temporary_identity failed: "${name}" is owned by another LIVE session (owner pid ${pid}) \u2014 one session cannot delete another's temporary identity.`
387
- );
388
- var errTempClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}. ${remote}`);
389
- var errPathNotAbsolute = (path) => new OursError("PATH_NOT_ABSOLUTE", `define_local_identity_file failed: path must be absolute (got "${path}").`);
390
- var errNoSuchIdentity = (name) => new OursError("NO_SUCH_IDENTITY", `choose_identity failed: no identity named "${name}". Create it with create_identity.`);
391
- var errNoLeaseToken = () => new OursError(
392
- "NO_LEASE_TOKEN",
393
- "choose_identity failed: this client is not connected through the ours connector (no lease token header). Launch ours via the connector (`ours-mcp proxy`)."
394
- );
395
- var errTempClosingChoose = (name) => new OursError("TEMP_CLOSING", `choose_identity failed: temporary identity "${name}" is closing \u2014 its state is being deleted.`);
396
- var errTempOwnedLive = (name, pid) => new OursError(
397
- "TEMP_OWNED_ELSEWHERE",
398
- `choose_identity failed: "${name}" is a TEMPORARY identity owned by another live session (owner pid ${pid}). Ownership is exclusive and cannot be overridden \u2014 not even with force. Create your own with create_temporary_identity.`
399
- );
400
- var errTempStale = (name, pid) => new OursError(
401
- "TEMP_STALE",
402
- `choose_identity failed: temporary identity "${name}" is STALE \u2014 its owning session (pid ${pid}) is gone and it is pending automatic cleanup. It cannot be adopted by another session; use close_temporary_identity to reclaim (clean it up) now.`
403
- );
404
- var errBoundElsewhere = (name) => new OursError(
405
- "BOUND_ELSEWHERE",
406
- `choose_identity declined: "${name}" is currently bound to another live session. Do not retry with force=true on your own \u2014 tell the user it is in use elsewhere and ask whether to forcibly rebind it here; only retry with force=true after they explicitly confirm.`
407
- );
408
- var errTempOwnedElsewhereRemove = (name, pid) => new OursError(
409
- "TEMP_OWNED_ELSEWHERE",
410
- `remove_identity failed: "${name}" is a TEMPORARY identity owned by another LIVE session (owner pid ${pid}) \u2014 one session cannot delete another's temporary identity.`
411
- );
412
- var errIdentityClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}.${remote}`);
413
- var errRootHasRoles = (name, roles) => new OursError(
414
- "ROOT_HAS_ROLES",
415
- `remove_identity failed: "${name}" is the root identity and still has ${roles.length} role(s): ${roles.join(", ")}. Remove the roles first.`
416
- );
417
- var errIdentityPartiallyRemoved = (name, fail) => new OursError("DELETE_PARTIAL", `Identity "${name}" removed from memory, but ${fail}`);
418
- var errPublicInviteNamed = () => new OursError(
419
- "PUBLIC_INVITE_NAMED",
420
- "generate_invite failed: a public invite cannot pre-assign a contact name \u2014 every redeemer would be registered under it. Omit `name` for a public invite."
421
- );
422
- var errNoPolicyArgs = () => new OursError("NO_POLICY_ARGS", "set_local_book_policy: pass expose and/or auto_accept.");
423
- var errSendFileArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path` or `data_base64`.");
424
- var errSendFileFilenameRequired = () => new OursError("SEND_ARGS", "send_file: `filename` is required with `data_base64`.");
425
- var errFileUnreadable = (detail) => new OursError("FILE_UNREADABLE", `send_file: cannot read file: ${detail}`);
426
- var errSendFileUploadArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path`, `data_base64` or `upload_id`.");
427
- var errUploadNotFound = (uploadId) => new OursError(
428
- "FILE_UNREADABLE",
429
- `send_file: no staged upload "${uploadId}" for the bound identity. Upload the bytes with POST /files/upload first. A staged upload is consumed by the first send that names it \u2014 including a send that failed \u2014 so a retry needs a fresh upload.`
430
- );
431
- var errFileTooLarge = (i) => new OursError(
432
- "FILE_TOO_LARGE",
433
- `send_file: "${i.filename}" is ${i.bytes} bytes. This send's envelope would serialize to ${i.envelopeBytes} bytes, over the transport's ${i.limit}-byte budget \u2014 the largest payload it can carry with this filename, MIME and reply reference is ${i.maxPayloadBytes} bytes. Nothing was sent.`,
434
- i
435
- );
436
- var errInvalidSelection = (cap) => new OursError("INVALID_SELECTION", `get_files failed: wire_ids must contain 1-${cap} items.`);
437
- var errMalformedId = () => new OursError("MALFORMED_ID", "get_files failed: every selected wire_id must be exactly 64 hexadecimal characters.");
438
- var errDuplicateId = () => new OursError("DUPLICATE_ID", "get_files failed: wire_ids must not contain duplicates.");
439
- var errUnknownOrStaleId = () => new OursError(
440
- "UNKNOWN_OR_STALE_ID",
441
- "get_files failed: one or more selected wire_ids is unknown, stale, or no longer unread; no files were retrieved."
442
- );
443
- var errTool = (tool, detail, code = "TX_FAILED") => new OursError(code, `${tool} failed: ${detail}`);
529
+ reservedNames.add(name);
530
+ const dir = identityDir(name);
531
+ let id;
532
+ try {
533
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
534
+ let tempMeta;
535
+ if (opts.temp) {
536
+ tempMeta = {
537
+ owner: { tokenHash: opts.temp.tokenHash, pid: opts.temp.pid },
538
+ createdAt: Date.now()
539
+ };
540
+ writeTempMetaFile(dir, tempMeta);
541
+ }
542
+ id = await createPacket(name, randomBytes(24).toString("hex"), dir, false, void 0, true);
543
+ openHistory(id);
544
+ if (tempMeta) id.temp = tempMeta;
545
+ fs2.writeFileSync(keyPath(dir), exportSigningSecret(id), { mode: 384 });
546
+ await withScopeAsync(async (lt) => {
547
+ await mutatingTx(id, "::a2a_messaging::set_my_name", { name }, lt);
548
+ });
549
+ await pinRegistrar(id);
550
+ if (!opts.localAutoAccept) {
551
+ await withScopeAsync(async (lt) => {
552
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept: false }, lt);
553
+ });
554
+ }
555
+ saveStateFailClosed(id);
556
+ log(`[${name}] provisioned UNEXPOSED pending hierarchy reconciliation`);
557
+ return id;
558
+ } catch (err) {
559
+ rollbackUnexposedIdentity(id, name, dir);
560
+ throw err;
561
+ }
562
+ }
563
+ async function activateIdentity(id, exposeLocal) {
564
+ wrapper.expose_packet(id.cid);
565
+ identities.set(id.name, id);
566
+ try {
567
+ if (exposeLocal) await publishToBook(id);
568
+ log(`[${id.name}] EXPOSED (routing + broker registration) \u2014 hierarchy reconciled`);
569
+ } catch (err) {
570
+ rollbackUnexposedIdentity(id, id.name, id.dir);
571
+ throw err;
572
+ }
573
+ }
574
+ function rollbackUnexposedIdentity(id, name, dir) {
575
+ if (id) {
576
+ try {
577
+ closeHistory(id);
578
+ } catch {
579
+ }
580
+ try {
581
+ unpublishFromBook(id);
582
+ } catch {
583
+ }
584
+ try {
585
+ wrapper.remove_packet(id.cid);
586
+ } catch {
587
+ }
588
+ }
589
+ identities.delete(name);
590
+ try {
591
+ fs2.rmSync(dir, { recursive: true, force: true });
592
+ } catch {
593
+ }
594
+ reservedNames.delete(name);
595
+ }
444
596
 
445
597
  // ../../src/api/identity.ts
446
- import { randomBytes } from "node:crypto";
447
- import { isAbsolute } from "node:path";
448
598
  function describeOrNull(id) {
449
599
  try {
450
600
  return describeIdentity(id);
@@ -479,7 +629,7 @@ async function createIdentity(ctx, a) {
479
629
  const { name, bio, exposeLocal, localAutoAccept } = a;
480
630
  const bad = validateName(name);
481
631
  if (bad) throw errTool("create_identity", bad, "NAME_INVALID");
482
- if (identities.has(name)) throw errNameTaken("create_identity", name);
632
+ if (identities.has(name) || quarantinedIdentities.has(name)) throw errNameTaken("create_identity", name);
483
633
  try {
484
634
  const id = await provisionIdentity(name, { exposeLocal, localAutoAccept });
485
635
  await setBio(id, bio);
@@ -496,8 +646,9 @@ async function createIdentity(ctx, a) {
496
646
  } else {
497
647
  hierarchy = "root";
498
648
  const res = await establishRoot(id);
499
- adopted = res.adopted;
500
- failed = res.failed;
649
+ const held = await adoptQuarantinedIdentities(id);
650
+ adopted = [...res.adopted, ...held.adopted];
651
+ failed = [...res.failed, ...held.failed];
501
652
  }
502
653
  bindSession(ctx.sessionId(), name);
503
654
  return { info: identityInfo(id), hierarchy, underRoot, adopted, failed, exposedLocal: exposeLocal, localAutoAccept };
@@ -519,22 +670,34 @@ async function createTemporaryIdentity(ctx, a) {
519
670
  }
520
671
  } else {
521
672
  for (let i = 0; i < 5 && chosen === void 0; i++) {
522
- const cand = `tmp-${randomBytes(5).toString("hex")}`;
673
+ const cand = `tmp-${randomBytes2(5).toString("hex")}`;
523
674
  if (!identities.has(cand) && !reservedNames.has(cand)) chosen = cand;
524
675
  }
525
676
  if (chosen === void 0) throw errRandomNameExhausted();
526
677
  }
527
678
  const name = chosen;
679
+ const root = rootName ? identities.get(rootName) : void 0;
680
+ if (!root) throw errNoRoot();
681
+ let id;
528
682
  try {
529
- const id = await provisionIdentity(name, {
530
- exposeLocal,
683
+ id = await provisionIdentityUnexposed(name, {
531
684
  localAutoAccept,
532
685
  temp: { tokenHash: hashLeaseToken(token), pid }
533
686
  });
534
687
  await setBio(id, bio);
688
+ await delegateRole(root, id);
689
+ await activateIdentity(id, exposeLocal);
535
690
  bindSession(ctx.sessionId(), name);
536
- return { info: identityInfo(id), ownerPid: pid, exposedLocal: exposeLocal, localAutoAccept };
691
+ return {
692
+ info: identityInfo(id),
693
+ ownerPid: pid,
694
+ hierarchy: "role",
695
+ underRoot: root.name,
696
+ exposedLocal: exposeLocal,
697
+ localAutoAccept
698
+ };
537
699
  } catch (err) {
700
+ if (id) rollbackUnexposedIdentity(id, name, id.dir);
538
701
  throw errTool("create_temporary_identity", String(err), "PROVISION_FAILED");
539
702
  }
540
703
  }
@@ -552,7 +715,7 @@ async function closeTemporaryIdentityOp(ctx, a) {
552
715
  if (!id.temp) throw errNotTemporary(id.name);
553
716
  const owner = id.temp.owner;
554
717
  const isOwner = token !== void 0 && owner.tokenHash === hashLeaseToken(token);
555
- if (!isOwner && !id.temp.closing && pidAlive(owner.pid)) {
718
+ if (!isOwner && !id.temp.closing && !pidDefinitelyDead(owner.pid)) {
556
719
  throw errTempOwnedElsewhereClose(id.name, owner.pid);
557
720
  }
558
721
  try {
@@ -578,7 +741,7 @@ async function createRootIdentity(ctx, a) {
578
741
  if (skipIfRootExists && existingRootName && identities.has(existingRootName)) {
579
742
  throw errRootExists(existingRootName);
580
743
  }
581
- if (identities.has(name)) throw errNameTaken("create_root_identity", name);
744
+ if (identities.has(name) || quarantinedIdentities.has(name)) throw errNameTaken("create_root_identity", name);
582
745
  try {
583
746
  const id = await provisionIdentity(name, { exposeLocal, localAutoAccept });
584
747
  await setBio(id, bio);
@@ -598,13 +761,14 @@ async function createRootIdentity(ctx, a) {
598
761
  };
599
762
  }
600
763
  const { adopted, failed } = await establishRoot(id);
764
+ const held = await adoptQuarantinedIdentities(id);
601
765
  bindSession(ctx.sessionId(), name);
602
766
  return {
603
767
  info: identityInfo(id),
604
768
  hierarchy: "root",
605
769
  underRoot: null,
606
- adopted,
607
- failed,
770
+ adopted: [...adopted, ...held.adopted],
771
+ failed: [...failed, ...held.failed],
608
772
  exposedLocal: exposeLocal,
609
773
  localAutoAccept
610
774
  };
@@ -633,7 +797,7 @@ async function chooseIdentity(ctx, a) {
633
797
  if (target.temp.closing) throw errTempClosingChoose(name);
634
798
  const owner = target.temp.owner;
635
799
  if (owner.tokenHash !== hashLeaseToken(token)) {
636
- if (pidAlive(owner.pid)) throw errTempOwnedLive(name, owner.pid);
800
+ if (!pidDefinitelyDead(owner.pid)) throw errTempOwnedLive(name, owner.pid);
637
801
  throw errTempStale(name, owner.pid);
638
802
  }
639
803
  const hdrPid = ctx.clientPid();
@@ -648,7 +812,7 @@ async function chooseIdentity(ctx, a) {
648
812
  }
649
813
  const existing = leases.get(name);
650
814
  if (existing && existing.token !== token) {
651
- if (!pidAlive(existing.pid)) {
815
+ if (pidDefinitelyDead(existing.pid)) {
652
816
  log(`auto-reclaiming "${name}" from dead client pid ${existing.pid}`);
653
817
  leases.delete(name);
654
818
  } else if (!force) {
@@ -664,13 +828,13 @@ async function chooseIdentity(ctx, a) {
664
828
  return { name, cid: identities.get(name).cid, switchedFrom };
665
829
  }
666
830
  async function listIdentities(ctx) {
667
- if (identities.size === 0) return [];
831
+ if (identities.size === 0 && quarantinedIdentities.size === 0) return [];
668
832
  const myToken = ctx.leaseToken();
669
833
  const sessionOf = (name) => {
670
834
  const lease = leases.get(name);
671
835
  if (!lease) return null;
672
836
  if (lease.token === myToken) return "mine";
673
- if (!pidAlive(lease.pid)) return null;
837
+ if (pidDefinitelyDead(lease.pid)) return null;
674
838
  return "other-live";
675
839
  };
676
840
  const tempOf = (id) => {
@@ -680,7 +844,7 @@ async function listIdentities(ctx) {
680
844
  if (myToken !== void 0 && id.temp.owner.tokenHash === hashLeaseToken(myToken)) {
681
845
  return { state: "mine", ownerPid };
682
846
  }
683
- return pidAlive(ownerPid) ? { state: "other-live", ownerPid } : { state: "stale", ownerPid };
847
+ return pidDefinitelyDead(ownerPid) ? { state: "stale", ownerPid } : { state: "other-live", ownerPid };
684
848
  };
685
849
  const row = (id, kind) => ({
686
850
  name: id.name,
@@ -697,13 +861,10 @@ async function listIdentities(ctx) {
697
861
  if (id.name === root.name) continue;
698
862
  if (describeIdentity(id).roleId !== "") rows.push(row(id, "role"));
699
863
  }
700
- for (const id of identities.values()) {
701
- if (id.name === root.name || describeIdentity(id).roleId !== "") continue;
702
- rows.push(row(id, "flat"));
703
- }
704
864
  } else {
705
- for (const id of identities.values()) rows.push(row(id, "flat"));
865
+ for (const id of identities.values()) rows.push(row(id, "role"));
706
866
  }
867
+ for (const [name, held] of quarantinedIdentities) rows.push({ name, status: held.status });
707
868
  return rows;
708
869
  }
709
870
  async function currentIdentity(ctx) {
@@ -724,16 +885,18 @@ async function currentIdentity(ctx) {
724
885
  }
725
886
  async function removeIdentity(ctx, a) {
726
887
  const { name } = a;
727
- const id = identities.get(name);
888
+ const held = quarantinedIdentities.get(name);
889
+ const id = identities.get(name) ?? held?.identity;
728
890
  if (!id) throw errTool("remove_identity", `no identity named "${name}".`, "NO_SUCH_IDENTITY");
729
891
  if (id.temp) {
730
892
  const token = ctx.leaseToken();
731
893
  const owner = id.temp.owner;
732
- if ((token === void 0 || owner.tokenHash !== hashLeaseToken(token)) && !id.temp.closing && pidAlive(owner.pid)) {
894
+ if ((token === void 0 || owner.tokenHash !== hashLeaseToken(token)) && !id.temp.closing && !pidDefinitelyDead(owner.pid)) {
733
895
  throw errTempOwnedElsewhereRemove(name, owner.pid);
734
896
  }
735
897
  try {
736
898
  const res = await closeTemporaryIdentity(id, "remove_identity");
899
+ quarantinedIdentities.delete(name);
737
900
  if (res.deleteError) {
738
901
  const remote = res.attempted === 0 ? "" : ` Remove-me notices: ${res.notified}/${res.attempted} queued, ${res.failed} not sent (best effort).`;
739
902
  throw errIdentityClosedWithError(name, res.deleteError, remote);
@@ -752,27 +915,13 @@ async function removeIdentity(ctx, a) {
752
915
  }
753
916
  const fail = deleteIdentityCompletely(id);
754
917
  if (fail) throw errIdentityPartiallyRemoved(name, fail);
918
+ quarantinedIdentities.delete(name);
755
919
  return { name, kind: "permanent" };
756
920
  }
757
921
  async function releaseLease(ctx) {
758
922
  const token = ctx.leaseToken();
759
923
  if (!token) throw errNoLeaseToken();
760
- const released = [];
761
- for (const [n, l] of [...leases]) {
762
- if (l.token !== token) continue;
763
- leases.delete(n);
764
- released.push(n);
765
- const rid = identities.get(n);
766
- if (rid?.temp && rid.temp.owner.tokenHash === hashLeaseToken(token) && !rid.temp.closing) {
767
- void closeTemporaryIdentity(rid, "owning session released its lease").catch(
768
- (err) => log(`[${n}] close on lease release failed:`, String(err))
769
- );
770
- }
771
- }
772
- tombstones.delete(token);
773
- persistBindings();
774
- log(`lease released by token \u2026${token.slice(-6)}`);
775
- return { released };
924
+ return releaseLeaseByToken(token);
776
925
  }
777
926
 
778
927
  // ../../src/api/contacts.ts
@@ -1325,8 +1474,11 @@ async function getFiles(ctx, a = {}) {
1325
1474
  if (wire_ids.some((wire) => !isSelectableWireId(wire))) throw errMalformedId();
1326
1475
  if (new Set(wire_ids).size !== wire_ids.length) throw errDuplicateId();
1327
1476
  }
1477
+ let pinned = [];
1328
1478
  try {
1329
1479
  const selected = takeUnreadFiles(id, { wire_ids, limit: a.limit });
1480
+ pinned = selected.files.map((file) => file.wire_id);
1481
+ pinHistoryItems(id.dir, "file", pinned);
1330
1482
  const files = [];
1331
1483
  const lines = [];
1332
1484
  for (const file of selected.files) {
@@ -1393,6 +1545,11 @@ ${lines.join("\n")}`,
1393
1545
  throw errUnknownOrStaleId();
1394
1546
  }
1395
1547
  throw errTool("get_files", message);
1548
+ } finally {
1549
+ if (pinned.length > 0) {
1550
+ unpinHistoryItems(id.dir, "file", pinned);
1551
+ noteHistoryStorageMutation(id.dir);
1552
+ }
1396
1553
  }
1397
1554
  }
1398
1555
  function saveFileFallbackNotice(ctx, a) {
@@ -1621,7 +1778,13 @@ async function serveApiOperation(req, res, pathname, readBody2) {
1621
1778
  sendJson(res, 200, await handler(session.ctx, body));
1622
1779
  } catch (err) {
1623
1780
  if (err instanceof OursError) {
1624
- sendJson(res, 400, { error: { code: err.code, message: err.message } });
1781
+ sendJson(res, 400, {
1782
+ error: {
1783
+ code: err.code,
1784
+ message: err.message,
1785
+ ...err.details ? { details: err.details } : {}
1786
+ }
1787
+ });
1625
1788
  return;
1626
1789
  }
1627
1790
  log("api handler error:", name, String(err));
@@ -1700,7 +1863,14 @@ function looksLikeInitializeRequest(body) {
1700
1863
  const m = body;
1701
1864
  return m.jsonrpc === "2.0" && m.method === "initialize";
1702
1865
  }
1703
- var LOAD_TIME_CONFIG_FIELDS = ["brokerUrl", "port", "stateDir", "gcIntervalMs", "apiVisibility"];
1866
+ var LOAD_TIME_CONFIG_FIELDS = [
1867
+ "brokerUrl",
1868
+ "port",
1869
+ "stateDir",
1870
+ "gcIntervalMs",
1871
+ "historyMaxBytes",
1872
+ "apiVisibility"
1873
+ ];
1704
1874
  function readBody(req) {
1705
1875
  return new Promise((resolve2, reject) => {
1706
1876
  let data = "";
@@ -1750,6 +1920,10 @@ async function startHttpDaemon(opts) {
1750
1920
  log("booting wrapper\u2026");
1751
1921
  try {
1752
1922
  await bootWrapper();
1923
+ const historyStorage = initializeHistoryRetention(STATE_DIR, HISTORY_MAX_BYTES);
1924
+ log(
1925
+ `history storage ${historyStorage.state} (physical=${historyStorage.physical_bytes}, cap=${historyStorage.max_bytes}, overflow=${historyStorage.overflow_bytes})`
1926
+ );
1753
1927
  startGcTimer();
1754
1928
  } catch (err) {
1755
1929
  if (installedHook) clearNotifyHook(installedHook);
@@ -1776,7 +1950,7 @@ async function startHttpDaemon(opts) {
1776
1950
  for (const sid of [...serversBySession.keys()]) {
1777
1951
  if (sid === "stdio") continue;
1778
1952
  const pid = sessionHeaders.get(sid)?.pid;
1779
- if (pid === void 0 || pid <= 1 || pidAlive(pid)) continue;
1953
+ if (pid === void 0 || !pidDefinitelyDead(pid)) continue;
1780
1954
  const inf = inflight.get(sid);
1781
1955
  if (inf && inf.n > 0 && Date.now() - inf.since < STUCK_MS) continue;
1782
1956
  const srv = serversBySession.get(sid);
@@ -1792,11 +1966,16 @@ async function startHttpDaemon(opts) {
1792
1966
  }
1793
1967
  return reaped;
1794
1968
  };
1969
+ let reaperRunning = false;
1795
1970
  const sessionReaper = setInterval(() => {
1971
+ if (reaperRunning) return;
1972
+ reaperRunning = true;
1796
1973
  reapDeadSessions();
1797
- sweepStaleTempIdentities();
1798
1974
  sweepStaleUploads();
1799
- }, 6e4);
1975
+ void reapStaleTemporaryIdentities().finally(() => {
1976
+ reaperRunning = false;
1977
+ });
1978
+ }, sessionReaperIntervalMs());
1800
1979
  sessionReaper.unref?.();
1801
1980
  const REQ_META_MAX = 1e3;
1802
1981
  const REQ_META_TTL_MS = 10 * 6e4;
@@ -1863,8 +2042,8 @@ async function startHttpDaemon(opts) {
1863
2042
  res.end(JSON.stringify({
1864
2043
  identities: [...identities.values()].map((i) => ({
1865
2044
  name: i.name,
1866
- ...i.temp ? { temporary: true, stale: !pidAlive(i.temp.owner.pid) } : {}
1867
- }))
2045
+ ...i.temp ? { temporary: true, stale: pidDefinitelyDead(i.temp.owner.pid) } : {}
2046
+ })).concat([...quarantinedIdentities.entries()].map(([name, held]) => ({ name, status: held.status })))
1868
2047
  }));
1869
2048
  return;
1870
2049
  }
@@ -1874,6 +2053,12 @@ async function startHttpDaemon(opts) {
1874
2053
  res.end(JSON.stringify(unreadSummary()));
1875
2054
  return;
1876
2055
  }
2056
+ if (req.method === "GET" && url.pathname === "/history-storage") {
2057
+ if (!requireAuth(req, res)) return;
2058
+ res.writeHead(200, { "Content-Type": "application/json" });
2059
+ res.end(JSON.stringify(getHistoryStorageStatus()));
2060
+ return;
2061
+ }
1877
2062
  {
1878
2063
  const m = /^\/identities\/([^/]+)\/notifications$/.exec(url.pathname);
1879
2064
  if (req.method === "GET" && m) {
@@ -1884,6 +2069,11 @@ async function startHttpDaemon(opts) {
1884
2069
  res.end(JSON.stringify({ error: "invalid identity name" }));
1885
2070
  return;
1886
2071
  }
2072
+ if (!identities.has(name)) {
2073
+ res.writeHead(404, { "Content-Type": "application/json" });
2074
+ res.end(JSON.stringify({ error: "no active identity with that name" }));
2075
+ return;
2076
+ }
1887
2077
  await serveNotifications(req, res, name, url.searchParams.get("since"), url.searchParams.get("kinds"));
1888
2078
  return;
1889
2079
  }
@@ -1939,9 +2129,9 @@ async function startHttpDaemon(opts) {
1939
2129
  return;
1940
2130
  }
1941
2131
  try {
1942
- const stat = fs2.statSync(filePath);
2132
+ const stat = fs3.statSync(filePath);
1943
2133
  res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": String(stat.size) });
1944
- const stream = fs2.createReadStream(filePath);
2134
+ const stream = fs3.createReadStream(filePath);
1945
2135
  stream.on("error", () => {
1946
2136
  try {
1947
2137
  res.destroy();
@@ -2043,19 +2233,7 @@ async function startHttpDaemon(opts) {
2043
2233
  }
2044
2234
  const token = sessionHeaders.get(sessionId)?.token ?? req.headers["x-ours-lease-token"];
2045
2235
  if (token) {
2046
- for (const [n, l] of [...leases]) {
2047
- if (l.token !== token) continue;
2048
- leases.delete(n);
2049
- const rid = identities.get(n);
2050
- if (rid?.temp && rid.temp.owner.tokenHash === hashLeaseToken(token) && !rid.temp.closing) {
2051
- void closeTemporaryIdentity(rid, "owning session released its lease").catch(
2052
- (err) => log(`[${n}] close on lease release failed:`, String(err))
2053
- );
2054
- }
2055
- }
2056
- tombstones.delete(token);
2057
- persistBindings();
2058
- log(`lease released by token \u2026${token.slice(-6)}`);
2236
+ await releaseLeaseByToken(token);
2059
2237
  }
2060
2238
  await transports[sessionId].handleRequest(req, res);
2061
2239
  } else {
@@ -2077,6 +2255,7 @@ async function startHttpDaemon(opts) {
2077
2255
  torn = true;
2078
2256
  clearInterval(sessionReaper);
2079
2257
  stopGcTimer();
2258
+ stopHistoryRetention();
2080
2259
  if (installedHook) clearNotifyHook(installedHook);
2081
2260
  for (const sid of Object.keys(transports)) {
2082
2261
  try {