@ours.network/cli 2.0.2 → 2.0.4

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.
@@ -8,15 +8,18 @@ import {
8
8
  PROTOCOL_VERSION,
9
9
  STATE_DIR,
10
10
  VERSION,
11
+ adoptQuarantinedIdentities,
11
12
  appendNotifyLog,
12
13
  bindSession,
13
14
  bootWrapper,
14
15
  capabilityReconcileSweep,
15
16
  clearNotifyHook,
17
+ closeHistory,
16
18
  closeTemporaryIdentity,
17
19
  clusterSweep,
18
20
  consumeOutboundHistoryFailure,
19
21
  contactRestoreSweep,
22
+ createPacket,
20
23
  decodeWireBin,
21
24
  delegateRole,
22
25
  deleteIdentityCompletely,
@@ -26,13 +29,16 @@ import {
26
29
  envelopeDispatch,
27
30
  establishRoot,
28
31
  exportAdBlob,
32
+ exportSigningSecret,
29
33
  findIdentityFile,
30
34
  getFileHistoryItem,
31
35
  getMessageHistoryItem,
32
36
  getMessageHistorySummary,
33
37
  hashLeaseToken,
34
38
  identities,
39
+ identityDir,
35
40
  isSelectableWireId,
41
+ keyPath,
36
42
  leaseByToken,
37
43
  leases,
38
44
  listFileHistory,
@@ -41,13 +47,17 @@ import {
41
47
  listMessageHistory,
42
48
  log,
43
49
  mutatingTx,
50
+ openHistory,
44
51
  outboundRemovalInFlight,
45
52
  persistBindings,
46
- pidAlive,
53
+ pidDefinitelyDead,
54
+ pinRegistrar,
47
55
  provisionIdentity,
48
56
  publishToBook,
57
+ quarantinedIdentities,
49
58
  readBook,
50
59
  readonlyTx,
60
+ reapStaleTemporaryIdentities,
51
61
  refreshUnread,
52
62
  registrar,
53
63
  renderContactRoots,
@@ -60,14 +70,15 @@ import {
60
70
  resolveBound,
61
71
  rootName,
62
72
  saveState,
73
+ saveStateFailClosed,
63
74
  scheduleCapabilityReconcile,
64
75
  serveNotifications,
65
76
  sessionHeaders,
77
+ sessionReaperIntervalMs,
66
78
  setNotifyHook,
67
79
  startupProgress,
68
80
  structuredVoiceOutcome,
69
81
  sttStatus,
70
- sweepStaleTempIdentities,
71
82
  takeUnreadFiles,
72
83
  takeUnreadMessages,
73
84
  tombstones,
@@ -78,8 +89,9 @@ import {
78
89
  voiceDeliveryLine,
79
90
  withScope,
80
91
  withScopeAsync,
92
+ wrapper,
81
93
  writeTempMetaFile
82
- } from "./chunk-IL724CY2.js";
94
+ } from "./chunk-TSYICL3I.js";
83
95
  import {
84
96
  buildIdentityFile,
85
97
  writeIdentityFile
@@ -88,11 +100,157 @@ import {
88
100
  // ../../src/http/server.ts
89
101
  import { createServer as createHttpServer } from "node:http";
90
102
  import { randomUUID } from "node:crypto";
91
- import * as fs2 from "node:fs";
103
+ import * as fs3 from "node:fs";
92
104
 
93
105
  // ../../src/protocol.ts
94
106
  var OURS_COMPAT_VERSION = 1;
95
107
 
108
+ // ../../src/errors.ts
109
+ var OursError = class extends Error {
110
+ // `details` is PURELY ADDITIVE and optional: every existing construction and
111
+ // every `e.code` check keeps working untouched. It exists because a refusal a
112
+ // caller is expected to ACT on (trim N bytes, shorten the filename) cannot be
113
+ // delivered as prose alone — see errFileTooLarge.
114
+ constructor(code, message, details) {
115
+ super(message);
116
+ this.code = code;
117
+ this.details = details;
118
+ this.name = "OursError";
119
+ }
120
+ };
121
+ var errNameTaken = (tool, name) => new OursError("NAME_TAKEN", `${tool} failed: an identity named "${name}" already exists.`);
122
+ var errNoLeaseHeaders = () => new OursError(
123
+ "NO_LEASE_HEADERS",
124
+ "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`)."
125
+ );
126
+ var errRandomNameExhausted = () => new OursError(
127
+ "RANDOM_NAME_EXHAUSTED",
128
+ "create_temporary_identity failed: could not pick a free random name after 5 attempts \u2014 retry."
129
+ );
130
+ var errRootExists = (rootName2) => new OursError(
131
+ "ROOT_EXISTS",
132
+ `create_root_identity failed: a root identity already exists ("${rootName2}") \u2014 one root per host. Nothing to do.`
133
+ );
134
+ var errNotBoundNoName = () => new OursError("NOT_BOUND_NO_NAME", "close_temporary_identity failed: no identity is bound to this session and no name was given.");
135
+ var errNotTemporary = (name) => new OursError(
136
+ "NOT_TEMPORARY",
137
+ `close_temporary_identity failed: "${name}" is a permanent identity. Use remove_identity if you really mean to delete it.`
138
+ );
139
+ var errTempOwnedElsewhereClose = (name, pid) => new OursError(
140
+ "TEMP_OWNED_ELSEWHERE",
141
+ `close_temporary_identity failed: "${name}" is owned by another LIVE session (owner pid ${pid}) \u2014 one session cannot delete another's temporary identity.`
142
+ );
143
+ var errTempClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}. ${remote}`);
144
+ var errPathNotAbsolute = (path) => new OursError("PATH_NOT_ABSOLUTE", `define_local_identity_file failed: path must be absolute (got "${path}").`);
145
+ var errNoSuchIdentity = (name) => new OursError("NO_SUCH_IDENTITY", `choose_identity failed: no identity named "${name}". Create it with create_identity.`);
146
+ var errNoLeaseToken = () => new OursError(
147
+ "NO_LEASE_TOKEN",
148
+ "choose_identity failed: this client is not connected through the ours connector (no lease token header). Launch ours via the connector (`ours-mcp proxy`)."
149
+ );
150
+ var errTempClosingChoose = (name) => new OursError("TEMP_CLOSING", `choose_identity failed: temporary identity "${name}" is closing \u2014 its state is being deleted.`);
151
+ var errTempOwnedLive = (name, pid) => new OursError(
152
+ "TEMP_OWNED_ELSEWHERE",
153
+ `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.`
154
+ );
155
+ var errTempStale = (name, pid) => new OursError(
156
+ "TEMP_STALE",
157
+ `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.`
158
+ );
159
+ var errBoundElsewhere = (name) => new OursError(
160
+ "BOUND_ELSEWHERE",
161
+ `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.`
162
+ );
163
+ var errTempOwnedElsewhereRemove = (name, pid) => new OursError(
164
+ "TEMP_OWNED_ELSEWHERE",
165
+ `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.`
166
+ );
167
+ var errIdentityClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}.${remote}`);
168
+ var errRootHasRoles = (name, roles) => new OursError(
169
+ "ROOT_HAS_ROLES",
170
+ `remove_identity failed: "${name}" is the root identity and still has ${roles.length} role(s): ${roles.join(", ")}. Remove the roles first.`
171
+ );
172
+ var errIdentityPartiallyRemoved = (name, fail) => new OursError("DELETE_PARTIAL", `Identity "${name}" removed from memory, but ${fail}`);
173
+ var errPublicInviteNamed = () => new OursError(
174
+ "PUBLIC_INVITE_NAMED",
175
+ "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."
176
+ );
177
+ var errNoPolicyArgs = () => new OursError("NO_POLICY_ARGS", "set_local_book_policy: pass expose and/or auto_accept.");
178
+ var errSendFileArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path` or `data_base64`.");
179
+ var errSendFileFilenameRequired = () => new OursError("SEND_ARGS", "send_file: `filename` is required with `data_base64`.");
180
+ var errFileUnreadable = (detail) => new OursError("FILE_UNREADABLE", `send_file: cannot read file: ${detail}`);
181
+ var errSendFileUploadArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path`, `data_base64` or `upload_id`.");
182
+ var errUploadNotFound = (uploadId) => new OursError(
183
+ "FILE_UNREADABLE",
184
+ `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.`
185
+ );
186
+ var errFileTooLarge = (i) => new OursError(
187
+ "FILE_TOO_LARGE",
188
+ `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.`,
189
+ i
190
+ );
191
+ var errNoRoot = () => new OursError("NO_ROOT", "No root identity exists on this host \u2014 create one with create_root_identity first.");
192
+ var errInvalidSelection = (cap) => new OursError("INVALID_SELECTION", `get_files failed: wire_ids must contain 1-${cap} items.`);
193
+ var errMalformedId = () => new OursError("MALFORMED_ID", "get_files failed: every selected wire_id must be exactly 64 hexadecimal characters.");
194
+ var errDuplicateId = () => new OursError("DUPLICATE_ID", "get_files failed: wire_ids must not contain duplicates.");
195
+ var errUnknownOrStaleId = () => new OursError(
196
+ "UNKNOWN_OR_STALE_ID",
197
+ "get_files failed: one or more selected wire_ids is unknown, stale, or no longer unread; no files were retrieved."
198
+ );
199
+ var errTool = (tool, detail, code = "TX_FAILED") => new OursError(code, `${tool} failed: ${detail}`);
200
+
201
+ // ../../src/identity/release.ts
202
+ async function releaseLeaseByToken(token) {
203
+ const tokenHash = hashLeaseToken(token);
204
+ const released = [];
205
+ for (const [name, lease] of [...leases]) {
206
+ if (lease.token !== token) continue;
207
+ leases.delete(name);
208
+ released.push(name);
209
+ }
210
+ const candidates = [
211
+ ...identities.values(),
212
+ ...[...quarantinedIdentities.values()].map((held) => held.identity)
213
+ ];
214
+ const owned = candidates.filter(
215
+ (id) => id.temp?.owner.tokenHash === tokenHash
216
+ );
217
+ const settled = await Promise.allSettled(
218
+ owned.map((id) => closeTemporaryIdentity(id, "owning session released its lease"))
219
+ );
220
+ const closed = [];
221
+ let attempted = 0;
222
+ let notified = 0;
223
+ let failed = 0;
224
+ const localFailures = [];
225
+ for (let i = 0; i < settled.length; i++) {
226
+ const item = settled[i];
227
+ const name = owned[i].name;
228
+ if (item.status === "rejected") {
229
+ localFailures.push({ name, error: String(item.reason) });
230
+ continue;
231
+ }
232
+ const result2 = item.value;
233
+ quarantinedIdentities.delete(name);
234
+ closed.push(name);
235
+ attempted += result2.attempted;
236
+ notified += result2.notified;
237
+ failed += result2.failed;
238
+ if (result2.deleteError) localFailures.push({ name, error: result2.deleteError });
239
+ }
240
+ tombstones.delete(token);
241
+ persistBindings();
242
+ log(`lease released by token \u2026${token.slice(-6)} (${closed.length} temporary identity close(s) awaited)`);
243
+ const result = { released, closed, attempted, notified, failed };
244
+ if (localFailures.length > 0) {
245
+ throw new OursError(
246
+ "DELETE_PARTIAL",
247
+ `Lease release completed with ${localFailures.length} local cleanup failure(s).`,
248
+ { result, localFailures }
249
+ );
250
+ }
251
+ return result;
252
+ }
253
+
96
254
  // ../../src/gc.ts
97
255
  var gcTimer = null;
98
256
  var gcRunning = false;
@@ -350,101 +508,86 @@ function checkFileEnvelope(i) {
350
508
  };
351
509
  }
352
510
 
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";
511
+ // ../../src/api/identity.ts
512
+ import { randomBytes as randomBytes2 } from "node:crypto";
513
+ import { isAbsolute } from "node:path";
514
+
515
+ // ../../src/identity/staged.ts
516
+ import * as fs2 from "node:fs";
517
+ import { randomBytes } from "node:crypto";
518
+ async function provisionIdentityUnexposed(name, opts) {
519
+ if (reservedNames.has(name) || identities.has(name)) {
520
+ throw new Error(`identity name "${name}" is reserved or already present`);
364
521
  }
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}`);
522
+ reservedNames.add(name);
523
+ const dir = identityDir(name);
524
+ let id;
525
+ try {
526
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
527
+ let tempMeta;
528
+ if (opts.temp) {
529
+ tempMeta = {
530
+ owner: { tokenHash: opts.temp.tokenHash, pid: opts.temp.pid },
531
+ createdAt: Date.now()
532
+ };
533
+ writeTempMetaFile(dir, tempMeta);
534
+ }
535
+ id = await createPacket(name, randomBytes(24).toString("hex"), dir, false, void 0, true);
536
+ openHistory(id);
537
+ if (tempMeta) id.temp = tempMeta;
538
+ fs2.writeFileSync(keyPath(dir), exportSigningSecret(id), { mode: 384 });
539
+ await withScopeAsync(async (lt) => {
540
+ await mutatingTx(id, "::a2a_messaging::set_my_name", { name }, lt);
541
+ });
542
+ await pinRegistrar(id);
543
+ if (!opts.localAutoAccept) {
544
+ await withScopeAsync(async (lt) => {
545
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept: false }, lt);
546
+ });
547
+ }
548
+ saveStateFailClosed(id);
549
+ log(`[${name}] provisioned UNEXPOSED pending hierarchy reconciliation`);
550
+ return id;
551
+ } catch (err) {
552
+ rollbackUnexposedIdentity(id, name, dir);
553
+ throw err;
554
+ }
555
+ }
556
+ async function activateIdentity(id, exposeLocal) {
557
+ wrapper.expose_packet(id.cid);
558
+ identities.set(id.name, id);
559
+ try {
560
+ if (exposeLocal) await publishToBook(id);
561
+ log(`[${id.name}] EXPOSED (routing + broker registration) \u2014 hierarchy reconciled`);
562
+ } catch (err) {
563
+ rollbackUnexposedIdentity(id, id.name, id.dir);
564
+ throw err;
565
+ }
566
+ }
567
+ function rollbackUnexposedIdentity(id, name, dir) {
568
+ if (id) {
569
+ try {
570
+ closeHistory(id);
571
+ } catch {
572
+ }
573
+ try {
574
+ unpublishFromBook(id);
575
+ } catch {
576
+ }
577
+ try {
578
+ wrapper.remove_packet(id.cid);
579
+ } catch {
580
+ }
581
+ }
582
+ identities.delete(name);
583
+ try {
584
+ fs2.rmSync(dir, { recursive: true, force: true });
585
+ } catch {
586
+ }
587
+ reservedNames.delete(name);
588
+ }
444
589
 
445
590
  // ../../src/api/identity.ts
446
- import { randomBytes } from "node:crypto";
447
- import { isAbsolute } from "node:path";
448
591
  function describeOrNull(id) {
449
592
  try {
450
593
  return describeIdentity(id);
@@ -479,7 +622,7 @@ async function createIdentity(ctx, a) {
479
622
  const { name, bio, exposeLocal, localAutoAccept } = a;
480
623
  const bad = validateName(name);
481
624
  if (bad) throw errTool("create_identity", bad, "NAME_INVALID");
482
- if (identities.has(name)) throw errNameTaken("create_identity", name);
625
+ if (identities.has(name) || quarantinedIdentities.has(name)) throw errNameTaken("create_identity", name);
483
626
  try {
484
627
  const id = await provisionIdentity(name, { exposeLocal, localAutoAccept });
485
628
  await setBio(id, bio);
@@ -496,8 +639,9 @@ async function createIdentity(ctx, a) {
496
639
  } else {
497
640
  hierarchy = "root";
498
641
  const res = await establishRoot(id);
499
- adopted = res.adopted;
500
- failed = res.failed;
642
+ const held = await adoptQuarantinedIdentities(id);
643
+ adopted = [...res.adopted, ...held.adopted];
644
+ failed = [...res.failed, ...held.failed];
501
645
  }
502
646
  bindSession(ctx.sessionId(), name);
503
647
  return { info: identityInfo(id), hierarchy, underRoot, adopted, failed, exposedLocal: exposeLocal, localAutoAccept };
@@ -519,22 +663,34 @@ async function createTemporaryIdentity(ctx, a) {
519
663
  }
520
664
  } else {
521
665
  for (let i = 0; i < 5 && chosen === void 0; i++) {
522
- const cand = `tmp-${randomBytes(5).toString("hex")}`;
666
+ const cand = `tmp-${randomBytes2(5).toString("hex")}`;
523
667
  if (!identities.has(cand) && !reservedNames.has(cand)) chosen = cand;
524
668
  }
525
669
  if (chosen === void 0) throw errRandomNameExhausted();
526
670
  }
527
671
  const name = chosen;
672
+ const root = rootName ? identities.get(rootName) : void 0;
673
+ if (!root) throw errNoRoot();
674
+ let id;
528
675
  try {
529
- const id = await provisionIdentity(name, {
530
- exposeLocal,
676
+ id = await provisionIdentityUnexposed(name, {
531
677
  localAutoAccept,
532
678
  temp: { tokenHash: hashLeaseToken(token), pid }
533
679
  });
534
680
  await setBio(id, bio);
681
+ await delegateRole(root, id);
682
+ await activateIdentity(id, exposeLocal);
535
683
  bindSession(ctx.sessionId(), name);
536
- return { info: identityInfo(id), ownerPid: pid, exposedLocal: exposeLocal, localAutoAccept };
684
+ return {
685
+ info: identityInfo(id),
686
+ ownerPid: pid,
687
+ hierarchy: "role",
688
+ underRoot: root.name,
689
+ exposedLocal: exposeLocal,
690
+ localAutoAccept
691
+ };
537
692
  } catch (err) {
693
+ if (id) rollbackUnexposedIdentity(id, name, id.dir);
538
694
  throw errTool("create_temporary_identity", String(err), "PROVISION_FAILED");
539
695
  }
540
696
  }
@@ -552,7 +708,7 @@ async function closeTemporaryIdentityOp(ctx, a) {
552
708
  if (!id.temp) throw errNotTemporary(id.name);
553
709
  const owner = id.temp.owner;
554
710
  const isOwner = token !== void 0 && owner.tokenHash === hashLeaseToken(token);
555
- if (!isOwner && !id.temp.closing && pidAlive(owner.pid)) {
711
+ if (!isOwner && !id.temp.closing && !pidDefinitelyDead(owner.pid)) {
556
712
  throw errTempOwnedElsewhereClose(id.name, owner.pid);
557
713
  }
558
714
  try {
@@ -578,7 +734,7 @@ async function createRootIdentity(ctx, a) {
578
734
  if (skipIfRootExists && existingRootName && identities.has(existingRootName)) {
579
735
  throw errRootExists(existingRootName);
580
736
  }
581
- if (identities.has(name)) throw errNameTaken("create_root_identity", name);
737
+ if (identities.has(name) || quarantinedIdentities.has(name)) throw errNameTaken("create_root_identity", name);
582
738
  try {
583
739
  const id = await provisionIdentity(name, { exposeLocal, localAutoAccept });
584
740
  await setBio(id, bio);
@@ -598,13 +754,14 @@ async function createRootIdentity(ctx, a) {
598
754
  };
599
755
  }
600
756
  const { adopted, failed } = await establishRoot(id);
757
+ const held = await adoptQuarantinedIdentities(id);
601
758
  bindSession(ctx.sessionId(), name);
602
759
  return {
603
760
  info: identityInfo(id),
604
761
  hierarchy: "root",
605
762
  underRoot: null,
606
- adopted,
607
- failed,
763
+ adopted: [...adopted, ...held.adopted],
764
+ failed: [...failed, ...held.failed],
608
765
  exposedLocal: exposeLocal,
609
766
  localAutoAccept
610
767
  };
@@ -633,7 +790,7 @@ async function chooseIdentity(ctx, a) {
633
790
  if (target.temp.closing) throw errTempClosingChoose(name);
634
791
  const owner = target.temp.owner;
635
792
  if (owner.tokenHash !== hashLeaseToken(token)) {
636
- if (pidAlive(owner.pid)) throw errTempOwnedLive(name, owner.pid);
793
+ if (!pidDefinitelyDead(owner.pid)) throw errTempOwnedLive(name, owner.pid);
637
794
  throw errTempStale(name, owner.pid);
638
795
  }
639
796
  const hdrPid = ctx.clientPid();
@@ -648,7 +805,7 @@ async function chooseIdentity(ctx, a) {
648
805
  }
649
806
  const existing = leases.get(name);
650
807
  if (existing && existing.token !== token) {
651
- if (!pidAlive(existing.pid)) {
808
+ if (pidDefinitelyDead(existing.pid)) {
652
809
  log(`auto-reclaiming "${name}" from dead client pid ${existing.pid}`);
653
810
  leases.delete(name);
654
811
  } else if (!force) {
@@ -664,13 +821,13 @@ async function chooseIdentity(ctx, a) {
664
821
  return { name, cid: identities.get(name).cid, switchedFrom };
665
822
  }
666
823
  async function listIdentities(ctx) {
667
- if (identities.size === 0) return [];
824
+ if (identities.size === 0 && quarantinedIdentities.size === 0) return [];
668
825
  const myToken = ctx.leaseToken();
669
826
  const sessionOf = (name) => {
670
827
  const lease = leases.get(name);
671
828
  if (!lease) return null;
672
829
  if (lease.token === myToken) return "mine";
673
- if (!pidAlive(lease.pid)) return null;
830
+ if (pidDefinitelyDead(lease.pid)) return null;
674
831
  return "other-live";
675
832
  };
676
833
  const tempOf = (id) => {
@@ -680,7 +837,7 @@ async function listIdentities(ctx) {
680
837
  if (myToken !== void 0 && id.temp.owner.tokenHash === hashLeaseToken(myToken)) {
681
838
  return { state: "mine", ownerPid };
682
839
  }
683
- return pidAlive(ownerPid) ? { state: "other-live", ownerPid } : { state: "stale", ownerPid };
840
+ return pidDefinitelyDead(ownerPid) ? { state: "stale", ownerPid } : { state: "other-live", ownerPid };
684
841
  };
685
842
  const row = (id, kind) => ({
686
843
  name: id.name,
@@ -697,13 +854,10 @@ async function listIdentities(ctx) {
697
854
  if (id.name === root.name) continue;
698
855
  if (describeIdentity(id).roleId !== "") rows.push(row(id, "role"));
699
856
  }
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
857
  } else {
705
- for (const id of identities.values()) rows.push(row(id, "flat"));
858
+ for (const id of identities.values()) rows.push(row(id, "role"));
706
859
  }
860
+ for (const [name, held] of quarantinedIdentities) rows.push({ name, status: held.status });
707
861
  return rows;
708
862
  }
709
863
  async function currentIdentity(ctx) {
@@ -724,16 +878,18 @@ async function currentIdentity(ctx) {
724
878
  }
725
879
  async function removeIdentity(ctx, a) {
726
880
  const { name } = a;
727
- const id = identities.get(name);
881
+ const held = quarantinedIdentities.get(name);
882
+ const id = identities.get(name) ?? held?.identity;
728
883
  if (!id) throw errTool("remove_identity", `no identity named "${name}".`, "NO_SUCH_IDENTITY");
729
884
  if (id.temp) {
730
885
  const token = ctx.leaseToken();
731
886
  const owner = id.temp.owner;
732
- if ((token === void 0 || owner.tokenHash !== hashLeaseToken(token)) && !id.temp.closing && pidAlive(owner.pid)) {
887
+ if ((token === void 0 || owner.tokenHash !== hashLeaseToken(token)) && !id.temp.closing && !pidDefinitelyDead(owner.pid)) {
733
888
  throw errTempOwnedElsewhereRemove(name, owner.pid);
734
889
  }
735
890
  try {
736
891
  const res = await closeTemporaryIdentity(id, "remove_identity");
892
+ quarantinedIdentities.delete(name);
737
893
  if (res.deleteError) {
738
894
  const remote = res.attempted === 0 ? "" : ` Remove-me notices: ${res.notified}/${res.attempted} queued, ${res.failed} not sent (best effort).`;
739
895
  throw errIdentityClosedWithError(name, res.deleteError, remote);
@@ -752,27 +908,13 @@ async function removeIdentity(ctx, a) {
752
908
  }
753
909
  const fail = deleteIdentityCompletely(id);
754
910
  if (fail) throw errIdentityPartiallyRemoved(name, fail);
911
+ quarantinedIdentities.delete(name);
755
912
  return { name, kind: "permanent" };
756
913
  }
757
914
  async function releaseLease(ctx) {
758
915
  const token = ctx.leaseToken();
759
916
  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 };
917
+ return releaseLeaseByToken(token);
776
918
  }
777
919
 
778
920
  // ../../src/api/contacts.ts
@@ -1621,7 +1763,13 @@ async function serveApiOperation(req, res, pathname, readBody2) {
1621
1763
  sendJson(res, 200, await handler(session.ctx, body));
1622
1764
  } catch (err) {
1623
1765
  if (err instanceof OursError) {
1624
- sendJson(res, 400, { error: { code: err.code, message: err.message } });
1766
+ sendJson(res, 400, {
1767
+ error: {
1768
+ code: err.code,
1769
+ message: err.message,
1770
+ ...err.details ? { details: err.details } : {}
1771
+ }
1772
+ });
1625
1773
  return;
1626
1774
  }
1627
1775
  log("api handler error:", name, String(err));
@@ -1776,7 +1924,7 @@ async function startHttpDaemon(opts) {
1776
1924
  for (const sid of [...serversBySession.keys()]) {
1777
1925
  if (sid === "stdio") continue;
1778
1926
  const pid = sessionHeaders.get(sid)?.pid;
1779
- if (pid === void 0 || pid <= 1 || pidAlive(pid)) continue;
1927
+ if (pid === void 0 || !pidDefinitelyDead(pid)) continue;
1780
1928
  const inf = inflight.get(sid);
1781
1929
  if (inf && inf.n > 0 && Date.now() - inf.since < STUCK_MS) continue;
1782
1930
  const srv = serversBySession.get(sid);
@@ -1792,11 +1940,16 @@ async function startHttpDaemon(opts) {
1792
1940
  }
1793
1941
  return reaped;
1794
1942
  };
1943
+ let reaperRunning = false;
1795
1944
  const sessionReaper = setInterval(() => {
1945
+ if (reaperRunning) return;
1946
+ reaperRunning = true;
1796
1947
  reapDeadSessions();
1797
- sweepStaleTempIdentities();
1798
1948
  sweepStaleUploads();
1799
- }, 6e4);
1949
+ void reapStaleTemporaryIdentities().finally(() => {
1950
+ reaperRunning = false;
1951
+ });
1952
+ }, sessionReaperIntervalMs());
1800
1953
  sessionReaper.unref?.();
1801
1954
  const REQ_META_MAX = 1e3;
1802
1955
  const REQ_META_TTL_MS = 10 * 6e4;
@@ -1863,8 +2016,8 @@ async function startHttpDaemon(opts) {
1863
2016
  res.end(JSON.stringify({
1864
2017
  identities: [...identities.values()].map((i) => ({
1865
2018
  name: i.name,
1866
- ...i.temp ? { temporary: true, stale: !pidAlive(i.temp.owner.pid) } : {}
1867
- }))
2019
+ ...i.temp ? { temporary: true, stale: pidDefinitelyDead(i.temp.owner.pid) } : {}
2020
+ })).concat([...quarantinedIdentities.entries()].map(([name, held]) => ({ name, status: held.status })))
1868
2021
  }));
1869
2022
  return;
1870
2023
  }
@@ -1884,6 +2037,11 @@ async function startHttpDaemon(opts) {
1884
2037
  res.end(JSON.stringify({ error: "invalid identity name" }));
1885
2038
  return;
1886
2039
  }
2040
+ if (!identities.has(name)) {
2041
+ res.writeHead(404, { "Content-Type": "application/json" });
2042
+ res.end(JSON.stringify({ error: "no active identity with that name" }));
2043
+ return;
2044
+ }
1887
2045
  await serveNotifications(req, res, name, url.searchParams.get("since"), url.searchParams.get("kinds"));
1888
2046
  return;
1889
2047
  }
@@ -1939,9 +2097,9 @@ async function startHttpDaemon(opts) {
1939
2097
  return;
1940
2098
  }
1941
2099
  try {
1942
- const stat = fs2.statSync(filePath);
2100
+ const stat = fs3.statSync(filePath);
1943
2101
  res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": String(stat.size) });
1944
- const stream = fs2.createReadStream(filePath);
2102
+ const stream = fs3.createReadStream(filePath);
1945
2103
  stream.on("error", () => {
1946
2104
  try {
1947
2105
  res.destroy();
@@ -2043,19 +2201,7 @@ async function startHttpDaemon(opts) {
2043
2201
  }
2044
2202
  const token = sessionHeaders.get(sessionId)?.token ?? req.headers["x-ours-lease-token"];
2045
2203
  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)}`);
2204
+ await releaseLeaseByToken(token);
2059
2205
  }
2060
2206
  await transports[sessionId].handleRequest(req, res);
2061
2207
  } else {