sentinelayer-cli 0.46.3 → 0.46.5

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.
@@ -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,155 @@ 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
+ async function recoverAndStore(auth, sid, agent, admissionId, key, fingerprint, deps, homeDir, now, rejectedToken = null) {
498
+ return withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
499
+ // Another process may have completed recovery while this process waited.
500
+ const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
501
+ if (current.state === "live" && current.credential.admissionId === admissionId &&
502
+ current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
503
+ current.credential.apiUrl === auth.apiUrl && current.credential.token !== rejectedToken) {
504
+ return redacted({ status: "active", reused: true, stored: current.credential, sid, agent, homeDir });
505
+ }
506
+ const claimed = await recoverCredential(auth, sid, agent, admissionId, key, deps, now);
507
+ const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now });
508
+ return { ...redacted({ status: "active", reused: true, stored, claimed, sid, agent, homeDir }), recovered: true };
509
+ });
510
+ }
511
+
512
+ async function storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now }) {
513
+ if (!String(claimed?.credential?.token || "").startsWith("sladm_")) {
514
+ throw new Error("Admission did not return a scoped agent credential.");
515
+ }
516
+ const identity = identityMetadata(claimed.identity);
517
+ const grant = grantMetadata(claimed.grant);
518
+ const previous = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
519
+ if (previous.state === "live" && previous.credential.admissionId === claimed.admissionId &&
520
+ previous.credential.publicKey === key.publicKey && previous.credential.apiUrl === auth.apiUrl &&
521
+ previous.credential.identity?.passportId === identity.passportId &&
522
+ previous.credential.identity?.keyThumbprint === identity.keyThumbprint &&
523
+ previous.credential.grant?.grantId === grant.grantId && previous.credential.expiresAt === grant.expiresAt) {
524
+ // Recovery does not reissue signed documents. Retain original documents
525
+ // only for this exact unchanged identity/grant; never synthesize new ones.
526
+ identity.passport ??= previous.credential.identity.passport;
527
+ grant.document ??= previous.credential.grant.document;
528
+ }
529
+ const stored = {
530
+ version: 3, sessionId: sid, agentId: agent, apiUrl: auth.apiUrl,
531
+ publicKey: key.publicKey, requestFingerprint: fingerprint,
532
+ admissionId: claimed.admissionId, token: claimed.credential.token,
533
+ expiresAt: claimed.grant.expiresAt, grantedActions: claimed.grant.actions,
534
+ passportId: claimed.identity.passportId, subject: claimed.identity.subject,
535
+ // Non-secret identity metadata survives reconnects. Email is never used as
536
+ // authentication: the credential/key binding remains the authority.
537
+ identity,
538
+ grant,
539
+ correlation: claimed.correlation,
540
+ storedAt: new Date(now()).toISOString(),
541
+ };
542
+ await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), stored);
543
+ return stored;
544
+ }
545
+
546
+ function identityMetadata(identity) {
547
+ return {
548
+ subject: identity.subject, passportId: identity.passportId,
549
+ passportStatus: identity.passportStatus, keyThumbprint: identity.keyThumbprint,
550
+ popAssurance: identity.popAssurance,
551
+ email: identity.email && { status: identity.email.status, address: identity.email.address },
552
+ passport: identity.passport,
553
+ };
554
+ }
555
+
556
+ function grantMetadata(grant) {
557
+ return {
558
+ grantId: grant.grantId, sessionId: grant.sessionId, actions: grant.actions,
559
+ expiresAt: grant.expiresAt, goalDigest: grant.goalDigest, document: grant.document,
560
+ };
561
+ }
562
+
405
563
  /**
406
564
  * Request admission, wait for a human decision, and claim the grant.
407
565
  * Returns a REDACTED result: the credential token is never included.
@@ -419,6 +577,7 @@ export async function runAdmissionJoin(
419
577
  actions = DEFAULT_ADMISSION_ACTIONS,
420
578
  ttlSeconds = 7200,
421
579
  wait = true,
580
+ recoverActive = false,
422
581
  waitTimeoutMs = 15 * 60_000,
423
582
  targetPath = process.cwd(),
424
583
  homeDir,
@@ -464,9 +623,19 @@ export async function runAdmissionJoin(
464
623
  // same key. A different goal, scope or duration is a question for the server,
465
624
  // which answers with the existing grant (same purpose) or a conflict (new one).
466
625
  const held = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
467
- if (held.state === "live" && held.credential.requestFingerprint === fingerprint) {
626
+ if (!recoverActive && held.state === "live" && held.credential.requestFingerprint === fingerprint) {
468
627
  return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
469
628
  }
629
+ if (recoverActive && held.credential?.admissionId) {
630
+ if (held.credential.requestFingerprint !== fingerprint || held.credential.publicKey !== key.publicKey ||
631
+ held.credential.apiUrl !== auth.apiUrl) {
632
+ throw new Error("Credential recovery requires the unchanged agent key, API, goal, scope and TTL.");
633
+ }
634
+ // Go directly to recovery, never file a new request after a refused cached
635
+ // bearer. A revoked/expired grant must be refused, not reopened as pending.
636
+ return recoverAndStore(auth, sid, agent, held.credential.admissionId, key, fingerprint, deps, homeDir, now,
637
+ held.credential.token);
638
+ }
470
639
 
471
640
  const statePath = admissionStatePath(targetPath, sid, agent);
472
641
  let state = await readJsonFile(statePath);
@@ -501,6 +670,7 @@ export async function runAdmissionJoin(
501
670
  if (
502
671
  created?.status === "active" &&
503
672
  created?.reused &&
673
+ !recoverActive &&
504
674
  held.state === "live" &&
505
675
  held.credential.admissionId === created.admissionId
506
676
  ) {
@@ -508,12 +678,10 @@ export async function runAdmissionJoin(
508
678
  return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
509
679
  }
510
680
  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
- );
681
+ const recovered = await recoverAndStore(auth, sid, agent, created.admissionId, key, fingerprint, deps, homeDir, now,
682
+ recoverActive && held.state === "live" ? held.credential.token : null);
683
+ await fsp.rm(statePath, { force: true });
684
+ return recovered;
517
685
  }
518
686
  state = {
519
687
  admissionId: created.admissionId,
@@ -590,46 +758,53 @@ export async function runAdmissionJoin(
590
758
  return { status: polled.status, admissionId: state.admissionId, waited: true };
591
759
  }
592
760
  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.");
761
+ const recovered = await recoverAndStore(auth, sid, agent, state.admissionId, key, fingerprint, deps, homeDir, now,
762
+ recoverActive && held.state === "live" ? held.credential.token : null);
763
+ await fsp.rm(statePath, { force: true });
764
+ return recovered;
603
765
  }
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
766
 
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);
767
+ // Initial claim is a credential writer too. The same cross-process lock must
768
+ // cover it through publication, or a delayed first claim could overwrite a
769
+ // recovered replacement. Refresh the nonce only after obtaining this lock.
770
+ const result = await withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
771
+ const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
772
+ if (current.state === "live" && current.credential.admissionId === state.admissionId &&
773
+ current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
774
+ current.credential.apiUrl === auth.apiUrl) {
775
+ return redacted({ status: "active", reused: true, stored: current.credential, sid, agent, homeDir });
776
+ }
777
+ const fresh = await checkedTransport(requestRead)(admissionMemberUrl(auth.apiUrl, sid, state.admissionId), {
778
+ method: "GET", credential: auth.credential,
779
+ });
780
+ if (TERMINAL.has(fresh.status)) {
781
+ return { status: fresh.status, admissionId: state.admissionId, waited: true };
782
+ }
783
+ if (fresh.status === "active") {
784
+ const recovered = await recoverCredential(auth, sid, agent, state.admissionId, key, deps, now);
785
+ const stored = await storeClaimedCredential(recovered, { auth, sid, agent, key, fingerprint, homeDir, now });
786
+ return { ...redacted({ status: "active", reused: true, stored, claimed: recovered, sid, agent, homeDir }), recovered: true };
787
+ }
788
+ const fields = fresh?.claim?.preimage;
789
+ if (fresh.status !== "approved" || fresh.identityReady === false || !fields || fresh.claim.domain !== CLAIM_DOMAIN) {
790
+ throw new Error("Approved admission did not include a valid claim challenge.");
791
+ }
792
+ if (fields.publicKey !== key.publicKey || fields.agentId !== agent || fields.sessionId !== sid ||
793
+ fields.admissionId !== state.admissionId) {
794
+ throw new Error("Refusing to sign: the claim challenge does not name this agent's key.");
795
+ }
796
+ const signature = signBytes(null, canonicalPreimage(fields, { domain: CLAIM_DOMAIN, fields: [...CLAIM_FIELDS] }), key.privateKey);
797
+ const claimed = await mutate(
798
+ auth, sid, ROUTE.claim,
799
+ admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/claim"),
800
+ { nonce: fields.nonce, signature: signature.toString("base64url") },
801
+ { ...deps, operationName: "session.admission_claim" }
802
+ );
803
+ const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now });
804
+ return redacted({ status: "active", reused: false, stored, claimed, sid, agent, homeDir });
805
+ });
631
806
  await fsp.rm(statePath, { force: true });
632
- return redacted({ status: "active", reused: false, stored, claimed, sid, agent, homeDir });
807
+ return result;
633
808
  }
634
809
 
635
810
  function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
@@ -637,28 +812,9 @@ function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
637
812
  status,
638
813
  reused,
639
814
  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}` },
815
+ identity: stored.identity || (claimed ? identityMetadata(claimed.identity) : { subject: stored.subject, passportId: stored.passportId }),
816
+ grant: stored.grant || (claimed ? grantMetadata(claimed.grant) : { sessionId: sid, actions: stored.grantedActions, expiresAt: stored.expiresAt }),
817
+ correlation: claimed ? claimed.correlation : stored.correlation || { actorRef: `adm:${stored.admissionId}` },
662
818
  jev: claimed ? claimed.jev : undefined,
663
819
  // A reference only. The token itself never appears in output.
664
820
  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()) {