sentinelayer-cli 0.46.4 → 0.48.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.
Files changed (137) hide show
  1. package/README.md +63 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +46 -8
  26. package/src/auth/service.js +34 -15
  27. package/src/auth/session-store.js +105 -47
  28. package/src/board/evidence-resolvers.js +3 -4
  29. package/src/cli.js +23 -3
  30. package/src/commands/ai/identity-lifecycle.js +30 -26
  31. package/src/commands/ai/provision-governance.js +32 -24
  32. package/src/commands/ai/shared.js +6 -4
  33. package/src/commands/audit.js +14 -12
  34. package/src/commands/auth.js +24 -4
  35. package/src/commands/board.js +2 -2
  36. package/src/commands/chat.js +8 -6
  37. package/src/commands/judge.js +125 -0
  38. package/src/commands/legacy-args.js +1 -0
  39. package/src/commands/mcp.js +45 -52
  40. package/src/commands/omargate.js +2 -1
  41. package/src/commands/plugin.js +11 -3
  42. package/src/commands/review.js +9 -4
  43. package/src/commands/scan.js +7 -7
  44. package/src/commands/session.js +607 -57
  45. package/src/commands/skills.js +45 -0
  46. package/src/commands/watch.js +9 -7
  47. package/src/config/output-filesystem.js +156 -0
  48. package/src/config/schema.js +4 -2
  49. package/src/config/service.js +40 -4
  50. package/src/cost/history.js +15 -9
  51. package/src/daemon/artifact-lineage.js +15 -12
  52. package/src/daemon/assignment-ledger.js +31 -25
  53. package/src/daemon/ast-drift.js +7 -3
  54. package/src/daemon/budget-governor.js +26 -20
  55. package/src/daemon/error-worker.js +28 -22
  56. package/src/daemon/fix-cycle.js +5 -4
  57. package/src/daemon/hybrid-mapper.js +19 -16
  58. package/src/daemon/ingest-refresh.js +4 -0
  59. package/src/daemon/jira-lifecycle.js +21 -17
  60. package/src/daemon/operator-control.js +18 -15
  61. package/src/daemon/reliability-lane.js +18 -15
  62. package/src/daemon/scope-engine.js +12 -8
  63. package/src/daemon/watchdog.js +15 -12
  64. package/src/engram/build-state.js +24 -3
  65. package/src/engram/capability-index.js +367 -0
  66. package/src/engram/compile-graph.js +1063 -0
  67. package/src/engram/contracts/action-receipt.js +82 -0
  68. package/src/engram/contracts/capability-index.js +259 -0
  69. package/src/engram/contracts/capability.js +377 -0
  70. package/src/engram/contracts/content-hash.js +8 -0
  71. package/src/engram/contracts/evidence.js +258 -0
  72. package/src/engram/contracts/graph.js +594 -0
  73. package/src/engram/contracts/identity.js +8 -0
  74. package/src/engram/contracts/index.js +149 -0
  75. package/src/engram/contracts/media-asset.js +85 -0
  76. package/src/engram/contracts/money.js +249 -0
  77. package/src/engram/contracts/source-snapshot.js +57 -0
  78. package/src/engram/contracts/task-context-pack.js +114 -0
  79. package/src/engram/contracts/temporal-segment.js +72 -0
  80. package/src/engram/contracts/versions.js +96 -0
  81. package/src/engram/extract-html.js +692 -0
  82. package/src/engram/graph-store.js +877 -0
  83. package/src/engram/index.js +98 -14
  84. package/src/engram/ingest-site.js +132 -0
  85. package/src/engram/live-state.js +1180 -0
  86. package/src/engram/namespace.js +34 -1
  87. package/src/engram/retrieve-site.js +753 -0
  88. package/src/engram/safe-reader.js +597 -0
  89. package/src/engram/source-intake.js +251 -0
  90. package/src/engram/store.js +227 -9
  91. package/src/ingest/engine.js +21 -28
  92. package/src/judge/verdict.js +305 -0
  93. package/src/legacy-cli.js +96 -19
  94. package/src/mcp/bridge-inputs.js +28 -4
  95. package/src/mcp/cli-command-tools.js +70 -0
  96. package/src/mcp/registry.js +17 -13
  97. package/src/mcp/session-stdio-server.js +297 -19
  98. package/src/memory/blackboard.js +5 -4
  99. package/src/net/gh-api-path.js +35 -9
  100. package/src/net/test-egress-guard.js +16 -4
  101. package/src/plugin/manifest.js +12 -8
  102. package/src/review/ai-review.js +110 -10
  103. package/src/review/investor-dd-devtestbot.js +17 -6
  104. package/src/review/investor-dd-orchestrator.js +21 -13
  105. package/src/review/local-review.js +26 -16
  106. package/src/review/omargate-cache.js +5 -5
  107. package/src/review/omargate-orchestrator.js +89 -17
  108. package/src/review/replay.js +9 -4
  109. package/src/review/report.js +22 -13
  110. package/src/review/typesafe-severity.js +201 -0
  111. package/src/session/admission-auth.js +34 -7
  112. package/src/session/admission.js +322 -71
  113. package/src/session/agent-access.js +137 -0
  114. package/src/session/agent-registry.js +2 -1
  115. package/src/session/attachment-summary.js +77 -0
  116. package/src/session/edit-summary.js +19 -0
  117. package/src/session/invitations.js +65 -1
  118. package/src/session/mentions.js +17 -9
  119. package/src/session/message-edits.js +5 -4
  120. package/src/session/preview.js +10 -2
  121. package/src/session/recap.js +3 -2
  122. package/src/session/room-files.js +519 -0
  123. package/src/session/route-classes.js +22 -8
  124. package/src/session/setup-guides.js +4 -0
  125. package/src/session/standup.js +192 -0
  126. package/src/session/sync.js +229 -10
  127. package/src/session/tickets.js +41 -0
  128. package/src/session/transcript.js +2 -1
  129. package/src/session/wake/resolve-target.js +7 -3
  130. package/src/skills/listener-runtime.cjs +92 -0
  131. package/src/skills/service.js +207 -0
  132. package/src/swarm/factory.js +5 -5
  133. package/src/swarm/pentest.js +9 -9
  134. package/src/swarm/report.js +4 -4
  135. package/src/swarm/runtime.js +15 -12
  136. package/src/swarm/scenario-dsl.js +7 -5
  137. package/src/telemetry/ledger.js +16 -7
@@ -60,9 +60,18 @@ export const CLAIM_FIELDS = Object.freeze([
60
60
  "keyThumbprint",
61
61
  "nonce",
62
62
  ]);
63
+ export const RECOVERY_DOMAIN = "sentinelayer.admission.recover.v1";
64
+ export const RECOVERY_FIELDS = Object.freeze([
65
+ "admissionId", "sessionId", "agentId", "delegatorUserId", "publicKey",
66
+ "keyThumbprint", "grantId", "goalDigest", "actions", "policyVersion",
67
+ "expiresAt", "nonce", "challengeExpiresAt", "tenantUserId", "passportId",
68
+ "aidenidIdentityId", "aidenidHandle",
69
+ ]);
63
70
  const ROUTE = {
64
71
  request: "POST /api/v1/sessions/{session_id}/admissions",
65
72
  claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
73
+ recoverChallenge: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/recover/challenge",
74
+ recover: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/recover",
66
75
  cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
67
76
  };
68
77
  // Final admission statuses: polling stops, nothing is claimed, local request state is cleared.
@@ -402,6 +411,201 @@ async function mutate(auth, sessionId, routeId, url, body, { requestMutation, op
402
411
  });
403
412
  }
404
413
 
414
+ // Recovery rotates only an ACTIVE credential. It never extends a permission or
415
+ // signs the old claim domain, and it needs both the human connection and this
416
+ // agent's own retained private key. A lost response can be recovered again with
417
+ // another fresh server challenge; plaintext credentials are never redelivered.
418
+ async function recoverCredential(auth, sid, agent, admissionId, key, deps, now) {
419
+ const challenge = await mutate(
420
+ auth, sid, ROUTE.recoverChallenge,
421
+ admissionMemberUrl(auth.apiUrl, sid, admissionId, "/recover/challenge"), {},
422
+ { ...deps, operationName: "session.admission_recovery_challenge" }
423
+ );
424
+ const fields = challenge?.recovery?.preimage;
425
+ const advertised = challenge?.recovery?.fields;
426
+ if (
427
+ challenge?.status !== "active" || challenge.admissionId !== admissionId ||
428
+ challenge?.recovery?.domain !== RECOVERY_DOMAIN || !fields ||
429
+ !Array.isArray(advertised) || advertised.length !== RECOVERY_FIELDS.length ||
430
+ [...advertised].sort().join("\0") !== [...RECOVERY_FIELDS].sort().join("\0") ||
431
+ fields.admissionId !== admissionId || fields.sessionId !== sid ||
432
+ fields.agentId !== agent || fields.publicKey !== key.publicKey ||
433
+ !normalizeString(fields.aidenidIdentityId) || !normalizeString(fields.aidenidHandle) ||
434
+ !Array.isArray(fields.actions) || !fields.actions.length ||
435
+ fields.actions.some((action) => !ADMISSION_ACTIONS.includes(action)) ||
436
+ !Number.isSafeInteger(fields.expiresAt) || fields.expiresAt * 1000 <= now() ||
437
+ !Number.isSafeInteger(fields.challengeExpiresAt) || fields.challengeExpiresAt * 1000 <= now()
438
+ ) {
439
+ throw new Error("Refusing to sign: invalid or mismatched admission recovery challenge.");
440
+ }
441
+ const signature = signBytes(null, canonicalPreimage(fields, {
442
+ domain: RECOVERY_DOMAIN, fields: [...RECOVERY_FIELDS],
443
+ }), key.privateKey);
444
+ const recovered = await mutate(
445
+ auth, sid, ROUTE.recover,
446
+ admissionMemberUrl(auth.apiUrl, sid, admissionId, "/recover"),
447
+ { nonce: fields.nonce, signature: signature.toString("base64url") },
448
+ { ...deps, operationName: "session.admission_recover" }
449
+ );
450
+ if (
451
+ recovered?.status !== "active" || recovered.admissionId !== admissionId ||
452
+ recovered?.grant?.sessionId !== sid || recovered.grant.grantId !== fields.grantId ||
453
+ recovered.grant.expiresAt !== fields.expiresAt ||
454
+ recovered.grant.goalDigest !== fields.goalDigest ||
455
+ !Array.isArray(recovered.grant.actions) ||
456
+ [...recovered.grant.actions].sort().join("\0") !== [...fields.actions].sort().join("\0") ||
457
+ recovered?.identity?.keyThumbprint !== fields.keyThumbprint ||
458
+ recovered.identity.passportId !== fields.passportId ||
459
+ recovered.identity.email?.address !== fields.aidenidHandle
460
+ ) {
461
+ throw new Error("Recovered admission does not match the unchanged signed authority.");
462
+ }
463
+ return recovered;
464
+ }
465
+
466
+ /** Serialize credential rotation AND publication across CLI processes.
467
+ * No age-based lock stealing: an API call may still commit after a local timer
468
+ * expires. Unknown/crashed owners fail explicitly without changing identity.
469
+ */
470
+ export async function withAdmissionCredentialLock(filePath, run, { timeoutMs = 180_000 } = {}) {
471
+ const lockPath = `${filePath}.recovery-lock`;
472
+ await fsp.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
473
+ const deadline = Date.now() + timeoutMs;
474
+ let handle;
475
+ for (;;) {
476
+ try {
477
+ handle = await fsp.open(lockPath, "wx", 0o600);
478
+ break;
479
+ } catch (error) {
480
+ if (error?.code !== "EEXIST") throw error;
481
+ if (Date.now() >= deadline) {
482
+ throw new Error(`Agent credential recovery is already locked at ${lockPath}. ` +
483
+ "Wait for the other join; if its process crashed, verify it has stopped before removing only this lock file. Human sign-in and the agent key were not changed.");
484
+ }
485
+ await sleepMs(25);
486
+ }
487
+ }
488
+ try {
489
+ await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname(), createdAt: new Date().toISOString() }));
490
+ return await run();
491
+ } finally {
492
+ await handle.close();
493
+ await fsp.unlink(lockPath);
494
+ }
495
+ }
496
+
497
+ // Persist noAutoGrant on a cached credential when the caller requests it.
498
+ // Never downgrade: noAutoGrant=false on an already-true credential is a no-op.
499
+ async function persistNoAutoGrantIfNeeded(credential, noAutoGrant, sid, agent, homeDir) {
500
+ if (noAutoGrant && !credential.noAutoGrant) {
501
+ const updated = { ...credential, noAutoGrant: true };
502
+ await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), updated);
503
+ return updated;
504
+ }
505
+ return credential;
506
+ }
507
+
508
+ async function recoverAndStore(auth, sid, agent, admissionId, key, fingerprint, deps, homeDir, now, rejectedToken = null, noAutoGrant = false, statePath = null) {
509
+ return withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
510
+ const effectiveNoAutoGrant = noAutoGrant || await pendingNoAutoGrantAtPath(statePath, admissionId, fingerprint);
511
+ // Another process may have completed recovery while this process waited.
512
+ const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
513
+ if (current.state === "live" && current.credential.admissionId === admissionId &&
514
+ current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
515
+ current.credential.apiUrl === auth.apiUrl && current.credential.token !== rejectedToken) {
516
+ const updated = await persistNoAutoGrantIfNeeded(current.credential, effectiveNoAutoGrant, sid, agent, homeDir);
517
+ return redacted({ status: "active", reused: true, stored: updated, sid, agent, homeDir });
518
+ }
519
+ const claimed = await recoverCredential(auth, sid, agent, admissionId, key, deps, now);
520
+ const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant: effectiveNoAutoGrant });
521
+ return { ...redacted({ status: "active", reused: true, stored, claimed, sid, agent, homeDir }), recovered: true };
522
+ });
523
+ }
524
+
525
+ async function pendingNoAutoGrantAtPath(statePath, admissionId, fingerprint) {
526
+ if (!statePath) return false;
527
+ const state = await readJsonFile(statePath);
528
+ return state?.admissionId === admissionId && state.requestFingerprint === fingerprint && state.noAutoGrant === true;
529
+ }
530
+
531
+ // Pending writers share the credential lock with claim/recovery. Merge the
532
+ // opt-out monotonically so a waiter holding an older snapshot cannot erase it.
533
+ async function writePendingAdmissionState(statePath, state, sid, agent, homeDir, now) {
534
+ return withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
535
+ const latest = await readJsonFile(statePath);
536
+ const sameRequest = latest?.admissionId === state.admissionId &&
537
+ latest.requestFingerprint === state.requestFingerprint;
538
+ state.noAutoGrant = state.noAutoGrant === true || (sameRequest && latest.noAutoGrant === true);
539
+ const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
540
+ if (current.state === "live" && current.credential.admissionId === state.admissionId &&
541
+ current.credential.requestFingerprint === state.requestFingerprint &&
542
+ current.credential.publicKey === state.publicKey && current.credential.apiUrl === state.apiUrl) {
543
+ const stored = await persistNoAutoGrantIfNeeded(current.credential, state.noAutoGrant, sid, agent, homeDir);
544
+ if (sameRequest) await fsp.rm(statePath, { force: true });
545
+ return stored;
546
+ }
547
+ await fsp.mkdir(path.dirname(statePath), { recursive: true });
548
+ await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
549
+ return null;
550
+ });
551
+ }
552
+
553
+ async function storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant = false }) {
554
+ if (!String(claimed?.credential?.token || "").startsWith("sladm_")) {
555
+ throw new Error("Admission did not return a scoped agent credential.");
556
+ }
557
+ const identity = identityMetadata(claimed.identity);
558
+ const grant = grantMetadata(claimed.grant);
559
+ const previous = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
560
+ if (previous.state === "live" && previous.credential.admissionId === claimed.admissionId &&
561
+ previous.credential.publicKey === key.publicKey && previous.credential.apiUrl === auth.apiUrl &&
562
+ previous.credential.identity?.passportId === identity.passportId &&
563
+ previous.credential.identity?.keyThumbprint === identity.keyThumbprint &&
564
+ previous.credential.grant?.grantId === grant.grantId && previous.credential.expiresAt === grant.expiresAt) {
565
+ // Recovery does not reissue signed documents. Retain original documents
566
+ // only for this exact unchanged identity/grant; never synthesize new ones.
567
+ identity.passport ??= previous.credential.identity.passport;
568
+ grant.document ??= previous.credential.grant.document;
569
+ }
570
+ const stored = {
571
+ version: 3, sessionId: sid, agentId: agent, apiUrl: auth.apiUrl,
572
+ publicKey: key.publicKey, requestFingerprint: fingerprint,
573
+ admissionId: claimed.admissionId, token: claimed.credential.token,
574
+ expiresAt: claimed.grant.expiresAt, grantedActions: claimed.grant.actions,
575
+ passportId: claimed.identity.passportId, subject: claimed.identity.subject,
576
+ // Non-secret identity metadata survives reconnects. Email is never used as
577
+ // authentication: the credential/key binding remains the authority.
578
+ identity,
579
+ grant,
580
+ correlation: claimed.correlation,
581
+ storedAt: new Date(now()).toISOString(),
582
+ // Preserve an existing noAutoGrant opt-out: recovery/replacement without
583
+ // the flag must not clear a previously persisted refusal. Only an explicit
584
+ // `noAutoGrant: true` overrides (never downgrades).
585
+ // Preserve existing opt-out regardless of credential state (live, expired, revoked).
586
+ noAutoGrant: noAutoGrant || (previous.credential?.noAutoGrant === true),
587
+ };
588
+ await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), stored);
589
+ return stored;
590
+ }
591
+
592
+ function identityMetadata(identity) {
593
+ return {
594
+ subject: identity.subject, passportId: identity.passportId,
595
+ passportStatus: identity.passportStatus, keyThumbprint: identity.keyThumbprint,
596
+ popAssurance: identity.popAssurance,
597
+ email: identity.email && { status: identity.email.status, address: identity.email.address },
598
+ passport: identity.passport,
599
+ };
600
+ }
601
+
602
+ function grantMetadata(grant) {
603
+ return {
604
+ grantId: grant.grantId, sessionId: grant.sessionId, actions: grant.actions,
605
+ expiresAt: grant.expiresAt, goalDigest: grant.goalDigest, document: grant.document,
606
+ };
607
+ }
608
+
405
609
  /**
406
610
  * Request admission, wait for a human decision, and claim the grant.
407
611
  * Returns a REDACTED result: the credential token is never included.
@@ -419,7 +623,9 @@ export async function runAdmissionJoin(
419
623
  actions = DEFAULT_ADMISSION_ACTIONS,
420
624
  ttlSeconds = 7200,
421
625
  wait = true,
626
+ recoverActive = false,
422
627
  waitTimeoutMs = 15 * 60_000,
628
+ noAutoGrant = false,
423
629
  targetPath = process.cwd(),
424
630
  homeDir,
425
631
  origin = SESSION_MUTATION_ORIGIN,
@@ -464,8 +670,37 @@ export async function runAdmissionJoin(
464
670
  // same key. A different goal, scope or duration is a question for the server,
465
671
  // which answers with the existing grant (same purpose) or a conflict (new one).
466
672
  const held = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
467
- if (held.state === "live" && held.credential.requestFingerprint === fingerprint) {
468
- return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
673
+ if (!recoverActive && held.state === "live" && held.credential.requestFingerprint === fingerprint) {
674
+ const fastPathCredential = await withAdmissionCredentialLock(
675
+ admissionCredentialPath(sid, agent, { homeDir }),
676
+ async () => {
677
+ // Reread under lock: a concurrent recovery may have rotated the token.
678
+ const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
679
+ if (current.state === "live" && current.credential.requestFingerprint === fingerprint) {
680
+ const cred = await persistNoAutoGrantIfNeeded(current.credential, noAutoGrant, sid, agent, homeDir);
681
+ return cred;
682
+ }
683
+ // Credential changed under us; re-read one final time under the
684
+ // held lock. If another writer replaced the credential, fail rather
685
+ // than returning a stale snapshot.
686
+ const retry = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
687
+ if (retry.state === "live" && retry.credential.requestFingerprint === fingerprint) {
688
+ const cred = await persistNoAutoGrantIfNeeded(retry.credential, noAutoGrant, sid, agent, homeDir);
689
+ return cred;
690
+ }
691
+ throw new Error("Admission credential changed during join; retry the join.");
692
+ });
693
+ return redacted({ status: "active", reused: true, stored: fastPathCredential, sid, agent, homeDir });
694
+ }
695
+ if (recoverActive && held.credential?.admissionId) {
696
+ if (held.credential.requestFingerprint !== fingerprint || held.credential.publicKey !== key.publicKey ||
697
+ held.credential.apiUrl !== auth.apiUrl) {
698
+ throw new Error("Credential recovery requires the unchanged agent key, API, goal, scope and TTL.");
699
+ }
700
+ // Go directly to recovery, never file a new request after a refused cached
701
+ // bearer. A revoked/expired grant must be refused, not reopened as pending.
702
+ return recoverAndStore(auth, sid, agent, held.credential.admissionId, key, fingerprint, deps, homeDir, now,
703
+ held.credential.token, noAutoGrant);
469
704
  }
470
705
 
471
706
  const statePath = admissionStatePath(targetPath, sid, agent);
@@ -478,6 +713,14 @@ export async function runAdmissionJoin(
478
713
  );
479
714
  }
480
715
  const resumable = inFlight && state.publicKey === key.publicKey;
716
+ // An opt-out made while approval is pending must survive a later invocation
717
+ // that resumes the same request without repeating the flag.
718
+ const pendingNoAutoGrant = Boolean(noAutoGrant || (resumable && state.noAutoGrant === true));
719
+ if (resumable && pendingNoAutoGrant && state.noAutoGrant !== true) {
720
+ state.noAutoGrant = true;
721
+ const stored = await writePendingAdmissionState(statePath, state, sid, agent, homeDir, now);
722
+ if (stored) return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
723
+ }
481
724
 
482
725
  if (!resumable) {
483
726
  const created = await mutate(
@@ -501,19 +744,35 @@ export async function runAdmissionJoin(
501
744
  if (
502
745
  created?.status === "active" &&
503
746
  created?.reused &&
747
+ !recoverActive &&
504
748
  held.state === "live" &&
505
749
  held.credential.admissionId === created.admissionId
506
750
  ) {
507
751
  // The server judged this request covered by the grant we already hold.
508
- return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
752
+ // Lock and reread: a concurrent recovery may have rotated the token.
753
+ const fastPathCredential = await withAdmissionCredentialLock(
754
+ admissionCredentialPath(sid, agent, { homeDir }),
755
+ async () => {
756
+ const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
757
+ if (current.state === "live" && current.credential?.admissionId === created.admissionId) {
758
+ return persistNoAutoGrantIfNeeded(current.credential, noAutoGrant, sid, agent, homeDir);
759
+ }
760
+ // Credential changed under us; re-read one final time under
761
+ // the held lock. If another writer replaced the credential, fail
762
+ // rather than writing a stale snapshot.
763
+ const retry = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
764
+ if (retry.state === "live" && retry.credential?.admissionId === created.admissionId) {
765
+ return persistNoAutoGrantIfNeeded(retry.credential, noAutoGrant, sid, agent, homeDir);
766
+ }
767
+ throw new Error("Admission credential changed during join; retry the join.");
768
+ });
769
+ return redacted({ status: "active", reused: true, stored: fastPathCredential, sid, agent, homeDir });
509
770
  }
510
771
  if (created?.status === "active" && created?.reused) {
511
- // The server already holds a live grant for this key, but we have no stored
512
- // credential for it (e.g. a different machine). It cannot be re-issued.
513
- throw new Error(
514
- "This agent already holds an active admission here, but no credential is stored " +
515
- "on this machine. Ask the room owner to revoke it, then join again."
516
- );
772
+ const recovered = await recoverAndStore(auth, sid, agent, created.admissionId, key, fingerprint, deps, homeDir, now,
773
+ recoverActive && held.state === "live" ? held.credential.token : null, pendingNoAutoGrant, statePath);
774
+ await fsp.rm(statePath, { force: true });
775
+ return recovered;
517
776
  }
518
777
  state = {
519
778
  admissionId: created.admissionId,
@@ -525,9 +784,10 @@ export async function runAdmissionJoin(
525
784
  approveUrl: created.approveUrl || null,
526
785
  pendingExpiresAt: created.pendingExpiresAt || null,
527
786
  requestedAt: new Date(now()).toISOString(),
787
+ noAutoGrant: pendingNoAutoGrant,
528
788
  };
529
- await fsp.mkdir(path.dirname(statePath), { recursive: true });
530
- await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
789
+ const stored = await writePendingAdmissionState(statePath, state, sid, agent, homeDir, now);
790
+ if (stored) return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
531
791
  }
532
792
 
533
793
  if (!wait) {
@@ -569,7 +829,8 @@ export async function runAdmissionJoin(
569
829
  identity: polled.identity || null,
570
830
  });
571
831
  if (now() >= deadline) {
572
- await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
832
+ const stored = await writePendingAdmissionState(statePath, state, sid, agent, homeDir, now);
833
+ if (stored) return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
573
834
  return {
574
835
  status: polled.status === "approved" ? "approved" : "pending",
575
836
  phase,
@@ -590,46 +851,55 @@ export async function runAdmissionJoin(
590
851
  return { status: polled.status, admissionId: state.admissionId, waited: true };
591
852
  }
592
853
  if (polled.status === "active") {
593
- throw new Error("Admission is already active but this machine holds no credential for it.");
594
- }
595
-
596
- const fields = polled?.claim?.preimage;
597
- if (!fields || polled.claim.domain !== CLAIM_DOMAIN) {
598
- throw new Error("Approved admission did not include a valid claim challenge.");
599
- }
600
- // Never sign a claim for a key or agent that is not ours.
601
- if (fields.publicKey !== key.publicKey || fields.agentId !== agent || fields.sessionId !== sid) {
602
- throw new Error("Refusing to sign: the claim challenge does not name this agent's key.");
854
+ const recovered = await recoverAndStore(auth, sid, agent, state.admissionId, key, fingerprint, deps, homeDir, now,
855
+ recoverActive && held.state === "live" ? held.credential.token : null, pendingNoAutoGrant, statePath);
856
+ await fsp.rm(statePath, { force: true });
857
+ return recovered;
603
858
  }
604
- const signature = signBytes(null, canonicalPreimage(fields, { domain: CLAIM_DOMAIN, fields: [...CLAIM_FIELDS] }), key.privateKey);
605
- const claimed = await mutate(
606
- auth,
607
- sid,
608
- ROUTE.claim,
609
- admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/claim"),
610
- { nonce: fields.nonce, signature: signature.toString("base64url") },
611
- { ...deps, operationName: "session.admission_claim" }
612
- );
613
859
 
614
- const stored = {
615
- version: 2,
616
- sessionId: sid,
617
- agentId: agent,
618
- // The credential is only ever sent to the API that issued it.
619
- apiUrl: auth.apiUrl,
620
- publicKey: key.publicKey,
621
- requestFingerprint: fingerprint,
622
- admissionId: claimed.admissionId,
623
- token: claimed.credential.token,
624
- expiresAt: claimed.grant.expiresAt,
625
- grantedActions: claimed.grant.actions,
626
- passportId: claimed.identity.passportId,
627
- subject: claimed.identity.subject,
628
- storedAt: new Date(now()).toISOString(),
629
- };
630
- await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), stored);
860
+ // Initial claim is a credential writer too. The same cross-process lock must
861
+ // cover it through publication, or a delayed first claim could overwrite a
862
+ // recovered replacement. Refresh the nonce only after obtaining this lock.
863
+ const result = await withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
864
+ const claimNoAutoGrant = pendingNoAutoGrant || await pendingNoAutoGrantAtPath(statePath, state.admissionId, fingerprint);
865
+ const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
866
+ if (current.state === "live" && current.credential.admissionId === state.admissionId &&
867
+ current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
868
+ current.credential.apiUrl === auth.apiUrl) {
869
+ const stored = await persistNoAutoGrantIfNeeded(current.credential, claimNoAutoGrant, sid, agent, homeDir);
870
+ return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
871
+ }
872
+ const fresh = await checkedTransport(requestRead)(admissionMemberUrl(auth.apiUrl, sid, state.admissionId), {
873
+ method: "GET", credential: auth.credential,
874
+ });
875
+ if (TERMINAL.has(fresh.status)) {
876
+ return { status: fresh.status, admissionId: state.admissionId, waited: true };
877
+ }
878
+ if (fresh.status === "active") {
879
+ const recovered = await recoverCredential(auth, sid, agent, state.admissionId, key, deps, now);
880
+ const stored = await storeClaimedCredential(recovered, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant: claimNoAutoGrant });
881
+ return { ...redacted({ status: "active", reused: true, stored, claimed: recovered, sid, agent, homeDir }), recovered: true };
882
+ }
883
+ const fields = fresh?.claim?.preimage;
884
+ if (fresh.status !== "approved" || fresh.identityReady === false || !fields || fresh.claim.domain !== CLAIM_DOMAIN) {
885
+ throw new Error("Approved admission did not include a valid claim challenge.");
886
+ }
887
+ if (fields.publicKey !== key.publicKey || fields.agentId !== agent || fields.sessionId !== sid ||
888
+ fields.admissionId !== state.admissionId) {
889
+ throw new Error("Refusing to sign: the claim challenge does not name this agent's key.");
890
+ }
891
+ const signature = signBytes(null, canonicalPreimage(fields, { domain: CLAIM_DOMAIN, fields: [...CLAIM_FIELDS] }), key.privateKey);
892
+ const claimed = await mutate(
893
+ auth, sid, ROUTE.claim,
894
+ admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/claim"),
895
+ { nonce: fields.nonce, signature: signature.toString("base64url") },
896
+ { ...deps, operationName: "session.admission_claim" }
897
+ );
898
+ const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant: claimNoAutoGrant });
899
+ return redacted({ status: "active", reused: false, stored, claimed, sid, agent, homeDir });
900
+ });
631
901
  await fsp.rm(statePath, { force: true });
632
- return redacted({ status: "active", reused: false, stored, claimed, sid, agent, homeDir });
902
+ return result;
633
903
  }
634
904
 
635
905
  function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
@@ -637,28 +907,9 @@ function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
637
907
  status,
638
908
  reused,
639
909
  admissionId: stored.admissionId,
640
- identity: claimed
641
- ? {
642
- subject: claimed.identity.subject,
643
- passportId: claimed.identity.passportId,
644
- passportStatus: claimed.identity.passportStatus,
645
- keyThumbprint: claimed.identity.keyThumbprint,
646
- popAssurance: claimed.identity.popAssurance,
647
- email: claimed.identity.email,
648
- passport: claimed.identity.passport,
649
- }
650
- : { subject: stored.subject, passportId: stored.passportId },
651
- grant: claimed
652
- ? {
653
- grantId: claimed.grant.grantId,
654
- sessionId: claimed.grant.sessionId,
655
- actions: claimed.grant.actions,
656
- expiresAt: claimed.grant.expiresAt,
657
- goalDigest: claimed.grant.goalDigest,
658
- document: claimed.grant.document,
659
- }
660
- : { sessionId: sid, actions: stored.grantedActions, expiresAt: stored.expiresAt },
661
- correlation: claimed ? claimed.correlation : { actorRef: `adm:${stored.admissionId}` },
910
+ identity: stored.identity || (claimed ? identityMetadata(claimed.identity) : { subject: stored.subject, passportId: stored.passportId }),
911
+ grant: stored.grant || (claimed ? grantMetadata(claimed.grant) : { sessionId: sid, actions: stored.grantedActions, expiresAt: stored.expiresAt }),
912
+ correlation: claimed ? claimed.correlation : stored.correlation || { actorRef: `adm:${stored.admissionId}` },
662
913
  jev: claimed ? claimed.jev : undefined,
663
914
  // A reference only. The token itself never appears in output.
664
915
  credential: { storage: admissionCredentialPath(sid, agent, { homeDir }), redacted: true },
@@ -0,0 +1,137 @@
1
+ import process from "node:process";
2
+
3
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
4
+ import { requestJson, requestJsonMutation } from "../auth/http.js";
5
+ import { resolveActiveAuthSession } from "../auth/service.js";
6
+ import { urlPathSegment } from "../net/url-path.js";
7
+ import { readAdmissionCredentialState } from "./admission.js";
8
+ import { assertCanonicalAgentId, currentAdmittedAgent } from "./admission-auth.js";
9
+
10
+ const GRANT_ROLES = new Set(["auditor", "coder", "coordinator", "observer", "orchestrator", "reviewer"]);
11
+ const MAX_GRANT_PAGES = 20;
12
+ const PAGE_LIMIT = 100;
13
+
14
+ function refused(code, reason, status = null) {
15
+ return { ok: false, code, reason, status, source: "legacy_grant_presence" };
16
+ }
17
+
18
+ /** A stored admission is never permission to enter the human-account legacy path. */
19
+ export async function legacyJoinAdmissionRefusal(sessionId, agentId, {
20
+ homeDir,
21
+ readAdmissionState = readAdmissionCredentialState,
22
+ } = {}) {
23
+ if (currentAdmittedAgent()) return refused("ADMISSION_CREDENTIAL_REFUSED", "admitted_context");
24
+ let held;
25
+ try {
26
+ held = await readAdmissionState(sessionId, agentId, { homeDir });
27
+ } catch {
28
+ return refused("ADMISSION_CREDENTIAL_REFUSED", "admission_lookup_unavailable");
29
+ }
30
+ if (held?.state === "none") return null;
31
+ return held?.state === "live"
32
+ ? refused("ADMISSION_REQUIRED", "stored_admission_requires_scoped_join")
33
+ : refused("ADMISSION_CREDENTIAL_REFUSED", held?.reason || "admission_lookup_unavailable");
34
+ }
35
+
36
+ /**
37
+ * Verify an existing account-bound legacy grant and its room-scoped presence.
38
+ * Never POST /agent-grants: GET lists active grants only, whereas POST reactivates
39
+ * revoked grants. Absence therefore cannot safely authorize an automatic grant.
40
+ * No transcript event, email or cryptographic identity is minted here.
41
+ */
42
+ export async function verifyLegacyAgentJoin(sessionId, {
43
+ agentId,
44
+ role = "coder",
45
+ model = "cli",
46
+ targetPath = process.cwd(),
47
+ homeDir,
48
+ remoteSession = {},
49
+ timeoutMs = 10_000,
50
+ now = () => Date.now(),
51
+ readAdmissionState = readAdmissionCredentialState,
52
+ resolveAuthSession = resolveActiveAuthSession,
53
+ requestRead = requestJson,
54
+ requestMutation = requestJsonMutation,
55
+ } = {}) {
56
+ const sid = String(sessionId || "").trim();
57
+ const agent = String(agentId || "").trim().toLowerCase();
58
+ const requestedRole = String(role || "").trim().toLowerCase();
59
+ try {
60
+ assertCanonicalAgentId(agent, "legacy join agent");
61
+ if (!sid || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent) || agent.startsWith("human-") || ["cli-user", "unknown"].includes(agent)) {
62
+ return refused("LEGACY_AGENT_ID_INVALID", "ungrantable_agent");
63
+ }
64
+ if (!GRANT_ROLES.has(requestedRole)) return refused("LEGACY_GRANT_ROLE_MISMATCH", "unsupported_role");
65
+ if (remoteSession.agentAdmissionMode && remoteSession.agentAdmissionMode !== "legacy") {
66
+ return refused("ADMISSION_REQUIRED", "room_requires_admission");
67
+ }
68
+ const admissionOptions = { homeDir, readAdmissionState };
69
+ let refusal = await legacyJoinAdmissionRefusal(sid, agent, admissionOptions);
70
+ if (refusal) return refusal;
71
+ const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, homeDir, autoRotate: false });
72
+ if (!isAuthenticated(auth)) return refused("AUTH_REQUIRED", "not_authenticated", 401);
73
+ if (auth.source === "session_admission") return refused("ADMISSION_CREDENTIAL_REFUSED", "admitted_context");
74
+ const credential = await credentialFor(auth);
75
+ const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
76
+ const deadline = now() + Math.max(1, Math.min(Number(timeoutMs) || 10_000, 30_000));
77
+ const remaining = () => Math.max(0, deadline - now());
78
+ let after = "";
79
+ let grant;
80
+ for (let page = 0; page < MAX_GRANT_PAGES; page += 1) {
81
+ refusal = await legacyJoinAdmissionRefusal(sid, agent, admissionOptions);
82
+ if (refusal) return refusal;
83
+ if (!remaining()) return refused("LEGACY_GRANT_UNAVAILABLE", "verification_timeout");
84
+ const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
85
+ if (after) query.set("after", after);
86
+ const result = await checkedTransport(requestRead)(`${apiUrl}/api/v1/sessions/agent-grants?${query}`, {
87
+ method: "GET", credential, timeoutMs: remaining(), maxRetries: 0,
88
+ });
89
+ if (!Array.isArray(result?.grants) || result.grants.length > PAGE_LIMIT) {
90
+ return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "invalid_grant_page");
91
+ }
92
+ const matches = result.grants.filter((item) => item?.agentId === agent);
93
+ if (matches.length > 1) return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "duplicate_grant_identity");
94
+ if (matches.length) { grant = matches[0]; break; }
95
+ if (result.nextAfter === null) return refused("LEGACY_GRANT_NOT_ACTIVE", "missing_or_revoked");
96
+ const next = result.nextAfter;
97
+ if (typeof next !== "string" || !next || next <= after || !result.grants.some((item) => item?.agentId === next)) {
98
+ return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "invalid_grant_cursor");
99
+ }
100
+ after = next;
101
+ }
102
+ if (!grant) return refused("LEGACY_GRANT_UNAVAILABLE", "grant_page_limit");
103
+ if (grant.exists === false || grant.revoked === true || grant.revokedAt != null) {
104
+ return refused("LEGACY_GRANT_NOT_ACTIVE", "missing_or_revoked");
105
+ }
106
+ if (grant.exists !== true || grant.revoked !== false || grant.revokedAt !== null || typeof grant.id !== "string" || !grant.id) {
107
+ return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "invalid_grant_confirmation");
108
+ }
109
+ if (grant.role !== requestedRole) return refused("LEGACY_GRANT_ROLE_MISMATCH", "requested_role_not_granted");
110
+ refusal = await legacyJoinAdmissionRefusal(sid, agent, admissionOptions);
111
+ if (refusal) return refusal;
112
+ if (!remaining()) return refused("LEGACY_PRESENCE_UNAVAILABLE", "verification_timeout");
113
+ const presence = await checkedTransport(requestMutation)(
114
+ `${apiUrl}/api/v1/sessions/${urlPathSegment(sid, { label: "sessionId" })}/presence`, {
115
+ method: "PUT", operationName: "session.legacy_join_presence", credential,
116
+ timeoutMs: remaining(), maxRetries: 0,
117
+ body: { agentId: agent, state: "idle", model, clientKind: "cli", observedAt: new Date(now()).toISOString() },
118
+ },
119
+ );
120
+ if (presence?.agentId !== agent) return refused("LEGACY_PRESENCE_CONTRACT_MISMATCH", "presence_identity_mismatch");
121
+ if (presence.status !== "ok" || presence.recorded !== true || !Number.isFinite(presence.ttlSeconds) || presence.ttlSeconds <= 0) {
122
+ return refused("LEGACY_PRESENCE_UNAVAILABLE", "presence_not_recorded");
123
+ }
124
+ return {
125
+ ok: true, source: "legacy_grant_presence",
126
+ grant: { id: grant.id, agentId: grant.agentId, role: grant.role },
127
+ presence: { recorded: true, agentId: agent, ttlSeconds: presence.ttlSeconds },
128
+ };
129
+ } catch (error) {
130
+ const status = Number(error?.status) || null;
131
+ const code = error?.code;
132
+ if (code === "ADMISSION_REQUIRED" || code === "ADMISSION_SCOPE_DENIED") return refused(code, "admission_required_or_refused", status);
133
+ if (status === 401) return refused("AUTH_REQUIRED", "credential_rejected", status);
134
+ if (status === 403) return refused("LEGACY_AGENT_ACCESS_DENIED", "grant_or_membership_refused", status);
135
+ return refused("LEGACY_AGENT_ACCESS_UNAVAILABLE", "verification_failed", status);
136
+ }
137
+ }
@@ -305,6 +305,7 @@ export async function registerAgent(
305
305
  role = "observer",
306
306
  targetPath = process.cwd(),
307
307
  trackProcessExit = true,
308
+ preserveIdentity = false,
308
309
  } = {}
309
310
  ) {
310
311
  const paths = resolveSessionPaths(sessionId, { targetPath });
@@ -320,7 +321,7 @@ export async function registerAgent(
320
321
  // a random hex blob. Caller-supplied real ids are NEVER renamed
321
322
  // (kill tests like PR 348/351 register `codex-task-holder-1` with
322
323
  // model="" and need the id to round-trip verbatim).
323
- if (shouldAutoRenameInRegistry({ originalCallerAgentId })) {
324
+ if (!preserveIdentity && shouldAutoRenameInRegistry({ originalCallerAgentId })) {
324
325
  const existingAgents = await listAgentsInternal(paths);
325
326
  const friendly = assignFriendlyName({ model, existingAgents });
326
327
  if (friendly && friendly !== resolvedAgentId.toLowerCase()) {