sentinelayer-cli 0.46.4 → 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.
package/README.md CHANGED
@@ -46,6 +46,22 @@ sl watch run-events --run-id <run-id>
46
46
 
47
47
  Windows PowerShell note: `sl` is a built-in alias for `Set-Location`. Use `sentinelayer-cli` (or short alias `slc`) instead.
48
48
 
49
+ ### Human sign-in and agent identity
50
+
51
+ `sl auth login --skip-browser-open` prints the approval URL before waiting;
52
+ in JSON mode the URL goes to stderr. A server outage during `auth status` is
53
+ unavailable validation, not logout. Do not delete credential encryption keys
54
+ or log out to repair a keyring/file-permission error.
55
+
56
+ An admitted agent receives its own AIdenID email, Ed25519-backed passport and
57
+ room-scoped credential after human approval. Its email and cryptographic
58
+ identity remain visible on reconnect; email alone never grants access.
59
+ An interrupted claim or missing credential can be recovered with the retained
60
+ agent key. For a stale active credential, repeat the original join with
61
+ `--recover-admission`, the same `--goal`, `--scope` and `--ttl`.
62
+ Recovery requires API support, preserves the original expiry, and cannot revive
63
+ revoked, stopped or expired permissions. It is not TTL renewal.
64
+
49
65
  ## MCP Live Entry Points And Incident Response
50
66
 
51
67
  Live MCP entry points:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.46.4",
3
+ "version": "0.46.5",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/auth/gate.js CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import pc from "picocolors";
7
7
  import { resolveActiveAuthSession } from "./service.js";
8
+ import { readStoredSessionMetadata } from "./session-store.js";
8
9
  import { resolveAgentAdmissionTarget } from "../session/admission-auth.js";
9
10
  import { authLoginHint } from "../ui/command-hints.js";
10
11
  import { isAuthenticated } from "./credential-destinations.js";
@@ -378,7 +379,9 @@ export async function checkAuthGate(args) {
378
379
  resolveError = "session_invalid";
379
380
  }
380
381
  } else {
381
- resolveError = "no_session";
382
+ // A keyring/decryption failure leaves the metadata intact. That is not
383
+ // absence, and recommending logout would destroy recoverable sign-in.
384
+ resolveError = (await readStoredSessionMetadata()) ? "session_token_missing" : "no_session";
382
385
  }
383
386
  } catch (error) {
384
387
  resolveError = error instanceof Error ? `session_read_error: ${error.message}` : "session_read_error";
@@ -405,15 +408,19 @@ export function printAuthRequired(failureReason = null) {
405
408
  if (reason === "session_expired") {
406
409
  console.error(" Your stored session has expired. Log in again:");
407
410
  } else if (reason === "session_token_missing") {
408
- console.error(" Your session metadata is present but the token is missing");
409
- console.error(" (likely a keyring read failure or mismatched storage).");
411
+ console.error(" Your session metadata is present but its credential cannot be read.");
412
+ console.error(" Check the current user/home, keyring, encryption key and file permissions.");
413
+ console.error(" Your stored credentials were not cleared; repair storage before signing in again.");
410
414
  console.error("");
411
- console.error(" " + pc.yellow("Fix:") + " log out to clear the stale metadata, then log in:");
412
- console.error(" " + pc.cyan("sentinelayer-cli auth logout"));
415
+ process.exitCode = 1;
416
+ return;
413
417
  } else if (reason && reason.startsWith("session_read_error")) {
414
418
  console.error(" Session read failed: " + pc.yellow(reason.replace(/^session_read_error:\s*/, "")));
415
- console.error(" Log out and back in to reset local state:");
416
- console.error(" " + pc.cyan("sentinelayer-cli auth logout"));
419
+ console.error(" Check the current user/home and credential-file permissions.");
420
+ console.error(" Your stored credentials were not cleared; do not log out to repair a read error.");
421
+ console.error("");
422
+ process.exitCode = 1;
423
+ return;
417
424
  } else {
418
425
  console.error(" Log in to SentinelLayer to use CLI commands:");
419
426
  }
@@ -7,6 +7,7 @@ import open from "open";
7
7
  import { loadConfig } from "../config/service.js";
8
8
  import { scopedAdmissionAuth } from "./admission-scope.js";
9
9
  import {
10
+ CredentialDestinationRefused,
10
11
  DEFAULT_API_URL,
11
12
  assertTrustedApiUrl,
12
13
  credentialFor,
@@ -453,6 +454,7 @@ async function rotateStoredApiTokenIfNeeded({
453
454
  tokenPrefix: issued.token_prefix || null,
454
455
  tokenExpiresAt: issued.expires_at || null,
455
456
  user: session.user,
457
+ aidenid: session.aidenid,
456
458
  },
457
459
  { homeDir }
458
460
  );
@@ -490,7 +492,8 @@ async function rotateStoredApiTokenIfNeeded({
490
492
  * tokenTtlDays?: number,
491
493
  * ide?: string,
492
494
  * cliVersion?: string,
493
- * homeDir?: string
495
+ * homeDir?: string,
496
+ * onAuthorize?: (details: { authorizeUrl: string, browserOpened: boolean }) => void | Promise<void>
494
497
  * }} [options]
495
498
  * @returns {Promise<{
496
499
  * apiUrl: string,
@@ -521,6 +524,7 @@ export async function loginAndPersistSession({
521
524
  ide = DEFAULT_IDE_NAME,
522
525
  cliVersion = "",
523
526
  homeDir,
527
+ onAuthorize = () => {},
524
528
  } = {}) {
525
529
  const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
526
530
  // The token this flow issues is the user's, so the API that issues it must be the configured
@@ -548,6 +552,10 @@ export async function loginAndPersistSession({
548
552
  }
549
553
  }
550
554
 
555
+ // Headless hosts must receive the URL before approval polling can complete.
556
+ // The command renders this on stderr in JSON mode, keeping stdout parseable.
557
+ await onAuthorize({ authorizeUrl, browserOpened });
558
+
551
559
  const approval = await pollCliAuthSession({
552
560
  apiUrl,
553
561
  sessionId: String(session.session_id || "").trim(),
@@ -768,7 +776,8 @@ export async function resolveActiveAuthSession({
768
776
  * homeDir?: string
769
777
  * }} [options]
770
778
  * @returns {Promise<{
771
- * authenticated: boolean,
779
+ * authenticated: boolean | null,
780
+ * validation: "none" | "not_checked" | "verified" | "rejected" | "unavailable" | "storage_unavailable",
772
781
  * apiUrl: string,
773
782
  * source: string | null,
774
783
  * storage: string | null,
@@ -805,20 +814,25 @@ export async function getAuthStatus({
805
814
  });
806
815
 
807
816
  if (!session) {
817
+ // Encrypted metadata can survive a missing/unreadable key or keyring.
818
+ // Distinguish recoverable storage from actual absence without creating a
819
+ // replacement key, validating remotely, or claiming authentication.
820
+ const stored = await readStoredSessionMetadata({ homeDir });
808
821
  return {
809
- authenticated: false,
822
+ authenticated: stored ? null : false,
823
+ validation: stored ? "storage_unavailable" : "none",
810
824
  apiUrl: await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir }),
811
- source: null,
812
- storage: null,
813
- user: null,
814
- aidenid: null,
825
+ source: stored ? "storage_unknown" : null,
826
+ storage: stored?.storage || null,
827
+ user: stored ? normalizeUser(stored.user || {}) : null,
828
+ aidenid: stored?.aidenid || null,
815
829
  remoteUser: null,
816
830
  remoteError: null,
817
831
  rotated: false,
818
- tokenExpiresAt: null,
819
- tokenPrefix: null,
820
- tokenId: null,
821
- filePath: null,
832
+ tokenExpiresAt: stored?.tokenExpiresAt || null,
833
+ tokenPrefix: stored?.tokenPrefix || null,
834
+ tokenId: stored?.tokenId || null,
835
+ filePath: stored?.filePath || null,
822
836
  };
823
837
  }
824
838
 
@@ -829,12 +843,12 @@ export async function getAuthStatus({
829
843
  remoteUser = normalizeUser(await fetchCurrentUser({ apiUrl: session.apiUrl, credential: session.credential }));
830
844
  } catch (error) {
831
845
  remoteError =
832
- error instanceof SentinelayerApiError
846
+ error instanceof SentinelayerApiError || error instanceof CredentialDestinationRefused
833
847
  ? {
834
848
  code: error.code,
835
849
  message: error.message,
836
- status: error.status,
837
- requestId: error.requestId,
850
+ status: error instanceof CredentialDestinationRefused ? 403 : error.status,
851
+ requestId: error.requestId || null,
838
852
  }
839
853
  : {
840
854
  code: "UNKNOWN",
@@ -845,8 +859,13 @@ export async function getAuthStatus({
845
859
  }
846
860
  }
847
861
 
862
+ const validationUnavailable = remoteError && (
863
+ [408, 425, 429, 499].includes(remoteError.status) || remoteError.status >= 500
864
+ );
848
865
  return {
849
- authenticated: !remoteError,
866
+ // Unknown remote health is neither a successful validation nor a logout.
867
+ authenticated: validationUnavailable ? null : !remoteError,
868
+ validation: validationUnavailable ? "unavailable" : remoteError ? "rejected" : checkRemote ? "verified" : "not_checked",
850
869
  apiUrl: session.apiUrl,
851
870
  source: session.source,
852
871
  storage: session.storage,
@@ -135,60 +135,68 @@ function resolveLegacyCredentialsKeyPath({ homeDir } = {}) {
135
135
  return path.join(resolvedHome, ".sentinelayer", "credentials.key");
136
136
  }
137
137
 
138
- async function loadOrCreateFileKey({ homeDir } = {}) {
139
- const keyPath = resolveCredentialsKeyPath({ homeDir });
140
- const legacyKeyPath = resolveLegacyCredentialsKeyPath({ homeDir });
141
- try {
142
- const raw = await fsp.readFile(keyPath, "utf-8");
143
- const key = Buffer.from(String(raw || "").trim(), "base64");
144
- if (key.length === 32) {
145
- return key;
146
- }
147
- } catch (error) {
148
- if (!(error && typeof error === "object" && error.code === "ENOENT")) {
138
+ async function readFileKeyAt(keyPath) {
139
+ // Exclusive-create fallback filesystems may expose a partially written key.
140
+ // Retry that bounded race, but never replace an invalid or unreadable key.
141
+ for (let attempt = 0; ; attempt += 1) {
142
+ let raw;
143
+ try {
144
+ raw = await fsp.readFile(keyPath, "utf-8");
145
+ } catch (error) {
146
+ if (error?.code === "ENOENT") return null;
149
147
  throw error;
150
148
  }
151
- }
152
-
153
- try {
154
- const legacyRaw = await fsp.readFile(legacyKeyPath, "utf-8");
155
- const legacyKey = Buffer.from(String(legacyRaw || "").trim(), "base64");
156
- if (legacyKey.length === 32) {
157
- await fsp.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
158
- await fsp.writeFile(keyPath, legacyKey.toString("base64"), { encoding: "utf-8", mode: 0o600 });
159
- try {
160
- await fsp.chmod(keyPath, 0o600);
161
- } catch {
162
- // Windows does not reliably support POSIX chmod semantics.
163
- }
164
- let verified = false;
165
- try {
166
- const verifyRaw = await fsp.readFile(keyPath, "utf-8");
167
- const verifyKey = Buffer.from(String(verifyRaw || "").trim(), "base64");
168
- verified = verifyKey.length === 32 && verifyKey.equals(legacyKey);
169
- } catch {
170
- verified = false;
171
- }
172
- if (verified) {
173
- await fsp.rm(legacyKeyPath, { force: true });
174
- }
175
- return legacyKey;
176
- }
177
- } catch (error) {
178
- if (!(error && typeof error === "object" && error.code === "ENOENT")) {
149
+ const encoded = String(raw || "").trim();
150
+ const key = Buffer.from(encoded, "base64");
151
+ if (key.length === 32 && key.toString("base64") === encoded) return key;
152
+ if (attempt >= 5) {
153
+ const error = new Error("Stored credential encryption key is invalid; it was not replaced.");
154
+ error.code = "CREDENTIALS_KEY_INVALID";
179
155
  throw error;
180
156
  }
157
+ await delayMilliseconds(20);
181
158
  }
159
+ }
160
+
161
+ async function readFileKey({ homeDir } = {}) {
162
+ return (await readFileKeyAt(resolveCredentialsKeyPath({ homeDir }))) ||
163
+ (await readFileKeyAt(resolveLegacyCredentialsKeyPath({ homeDir })));
164
+ }
182
165
 
183
- const key = crypto.randomBytes(32);
166
+ async function loadOrCreateFileKey({ homeDir } = {}) {
167
+ const keyPath = resolveCredentialsKeyPath({ homeDir });
168
+ const existing = await readFileKeyAt(keyPath);
169
+ if (existing) return existing;
170
+ const legacyKey = await readFileKeyAt(resolveLegacyCredentialsKeyPath({ homeDir }));
171
+ const key = legacyKey || crypto.randomBytes(32);
172
+ const encoded = key.toString("base64");
184
173
  await fsp.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
185
- await fsp.writeFile(keyPath, key.toString("base64"), { encoding: "utf-8", mode: 0o600 });
174
+ const tmpPath = `${keyPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
186
175
  try {
187
- await fsp.chmod(keyPath, 0o600);
188
- } catch {
189
- // Windows does not reliably support POSIX chmod semantics.
176
+ await fsp.writeFile(tmpPath, encoded, { encoding: "utf-8", mode: 0o600, flag: "wx" });
177
+ await syncFile(tmpPath);
178
+ try {
179
+ // Publish complete bytes once. Every concurrent writer then uses the winner.
180
+ await fsp.link(tmpPath, keyPath);
181
+ } catch (error) {
182
+ if (error?.code !== "EEXIST") {
183
+ if (!["ENOTSUP", "ENOSYS", "EXDEV", "EPERM"].includes(error?.code)) throw error;
184
+ try {
185
+ await fsp.writeFile(keyPath, encoded, { encoding: "utf-8", mode: 0o600, flag: "wx" });
186
+ } catch (fallbackError) {
187
+ if (fallbackError?.code !== "EEXIST") throw fallbackError;
188
+ }
189
+ }
190
+ }
191
+ const persisted = await readFileKeyAt(keyPath);
192
+ if (!persisted) throw new Error("Credential encryption key could not be persisted.");
193
+ await syncFile(keyPath);
194
+ await syncDirectory(path.dirname(keyPath));
195
+ // Keep the legacy key: deleting it can race a read-only legacy reader.
196
+ return persisted;
197
+ } finally {
198
+ await fsp.rm(tmpPath, { force: true }).catch(() => {});
190
199
  }
191
- return key;
192
200
  }
193
201
 
194
202
  function encryptToken(token, key) {
@@ -540,7 +548,11 @@ async function tryDecryptFileToken({ metadata, homeDir }) {
540
548
  return null;
541
549
  }
542
550
  try {
543
- const key = await loadOrCreateFileKey({ homeDir });
551
+ const key = await readFileKey({ homeDir });
552
+ if (!key) {
553
+ emitSessionWarning("FILE_CREDENTIAL_UNAVAILABLE", { reason: "key_missing", source: "file-encrypted" });
554
+ return null;
555
+ }
544
556
  const token = decryptToken(
545
557
  {
546
558
  tokenEncrypted: metadata.tokenEncrypted,
@@ -550,7 +562,13 @@ async function tryDecryptFileToken({ metadata, homeDir }) {
550
562
  key
551
563
  );
552
564
  return token || null;
553
- } catch {
565
+ } catch (error) {
566
+ emitSessionWarning("FILE_CREDENTIAL_UNAVAILABLE", {
567
+ reason: error?.code === "EACCES" || error?.code === "EPERM"
568
+ ? "key_unreadable"
569
+ : error?.code === "CREDENTIALS_KEY_INVALID" ? "key_invalid" : "decryption_failed",
570
+ source: "file-encrypted",
571
+ });
554
572
  return null;
555
573
  }
556
574
  }
@@ -722,6 +740,7 @@ export async function readStoredSessionMetadata({ homeDir } = {}) {
722
740
  * tokenPrefix?: string | null,
723
741
  * tokenExpiresAt?: string | null,
724
742
  * user?: Record<string, unknown>
743
+ * aidenid?: Record<string, unknown> | null
725
744
  * }} [session]
726
745
  * @param {{ homeDir?: string }} [options]
727
746
  * @returns {Promise<{
@@ -754,6 +773,7 @@ export async function writeStoredSession(
754
773
  tokenPrefix = null,
755
774
  tokenExpiresAt = null,
756
775
  user = {},
776
+ aidenid = null,
757
777
  } = {},
758
778
  { homeDir } = {}
759
779
  ) {
@@ -778,6 +798,7 @@ export async function writeStoredSession(
778
798
  tokenPrefix,
779
799
  tokenExpiresAt,
780
800
  user: normalizeUser(user),
801
+ aidenid,
781
802
  createdAt: existingMetadata?.createdAt || updatedAt,
782
803
  updatedAt,
783
804
  });
@@ -133,6 +133,14 @@ export function registerAuthCommand(program) {
133
133
  tokenLabel: options.tokenLabel,
134
134
  tokenTtlDays,
135
135
  cliVersion: CLI_VERSION,
136
+ onAuthorize: ({ authorizeUrl }) => {
137
+ // A spawned opener does not prove a browser exists on a headless host.
138
+ if (!authorizeUrl) return;
139
+ const safeUrl = String(authorizeUrl).replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 4096);
140
+ const line = `Open this URL to approve sign-in:\n${safeUrl}\n`;
141
+ if (shouldEmitJson(options, command)) process.stderr.write(line);
142
+ else console.log(line.trimEnd());
143
+ },
136
144
  });
137
145
  } catch (error) {
138
146
  throw new Error(formatApiError(error));
@@ -159,10 +167,6 @@ export function registerAuthCommand(program) {
159
167
  if (result.filePath) {
160
168
  console.log(pc.gray(`Session metadata: ${result.filePath}`));
161
169
  }
162
- if (!result.browserOpened && result.authorizeUrl) {
163
- console.log(pc.yellow("Open this URL to approve sign-in:"));
164
- console.log(result.authorizeUrl);
165
- }
166
170
  });
167
171
 
168
172
  auth
@@ -199,6 +203,13 @@ export function registerAuthCommand(program) {
199
203
 
200
204
  console.log(pc.bold("Authentication status"));
201
205
  console.log(pc.gray(`API: ${status.apiUrl}`));
206
+ if (status.validation === "storage_unavailable") {
207
+ console.log(pc.yellow("Stored sign-in metadata exists, but its credential cannot be read. Authentication is unknown."));
208
+ if (status.filePath) console.log(pc.gray(`Session metadata: ${status.filePath}`));
209
+ console.log(pc.gray("Check the current user/home, keyring availability, encryption key and file permissions."));
210
+ console.log(pc.gray("Credentials were not cleared. Repair storage instead of logging out or signing in again for this error."));
211
+ return;
212
+ }
202
213
  if (!status.source) {
203
214
  console.log(pc.yellow("No active session token found."));
204
215
  printAuthHint();
@@ -254,6 +265,15 @@ export function registerAuthCommand(program) {
254
265
  }`
255
266
  )
256
267
  );
268
+ if (status.validation === "unavailable") {
269
+ console.log(pc.yellow("Remote validation is temporarily unavailable; your existing credentials were not cleared."));
270
+ console.log(pc.gray("Retry auth status when the API is reachable. Do not sign in again solely for this validation error."));
271
+ return;
272
+ }
273
+ if (["CREDENTIAL_DESTINATION_REFUSED", "CREDENTIAL_PATH_REFUSED"].includes(status.remoteError.code)) {
274
+ console.log(pc.yellow("Credential destination was refused. Check the configured API; signing in again does not change this boundary."));
275
+ return;
276
+ }
257
277
  } else {
258
278
  console.log(pc.yellow("Remote validation was skipped (`--offline`)."));
259
279
  }
@@ -33,6 +33,7 @@ import {
33
33
  unregisterAgent,
34
34
  } from "../session/agent-registry.js";
35
35
  import { inferSessionAgentIdentity } from "../session/agent-identity.js";
36
+ import { legacyJoinAdmissionRefusal, verifyLegacyAgentJoin } from "../session/agent-access.js";
36
37
  import { startSenti, stopSenti } from "../session/daemon.js";
37
38
  import {
38
39
  getDaemonStatus,
@@ -3437,6 +3438,7 @@ export function registerSessionCommand(program) {
3437
3438
  .option("--no-wait", "Request admission and return immediately with the approval URL")
3438
3439
  .option("--wait-seconds <n>", "How long to wait for approval before returning pending", "900")
3439
3440
  .option("--cancel-admission", "Cancel this workspace's pending admission request for the session")
3441
+ .option("--recover-admission", "Recover a live admission with the retained agent key; requires the unchanged --goal/scope/ttl")
3440
3442
  .option("--json", "Emit machine-readable output")
3441
3443
  .action(async (sessionId, options, command) => {
3442
3444
  const normalizedSessionId = normalizeString(sessionId);
@@ -3455,6 +3457,27 @@ export function registerSessionCommand(program) {
3455
3457
  // the local registration and every later implicit command name the same agent.
3456
3458
  if (!explicitAgent) assertCanonicalAgentId(legacyName, "--name");
3457
3459
  const requestedAgent = canonicalAgentId(explicitAgent || legacyName);
3460
+ if (options.recoverAdmission && !normalizeString(options.goal)) {
3461
+ throw new Error("--recover-admission requires --goal and the admission's unchanged scope/ttl.");
3462
+ }
3463
+ const rejectLegacyJoin = (verification, agentId) => {
3464
+ process.exitCode = 4;
3465
+ const payload = {
3466
+ command: "session join", joined: false, sessionId: normalizedSessionId,
3467
+ agentId, remoteAgentVerification: verification,
3468
+ };
3469
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3470
+ else {
3471
+ const hint = verification.code.includes("UNAVAILABLE")
3472
+ ? "Retry when the API/presence service is available; no new approval was requested."
3473
+ : verification.code.includes("CONTRACT_MISMATCH")
3474
+ ? "Verify the CLI/server contract; no new authorization was created."
3475
+ : verification.code === "LEGACY_GRANT_ROLE_MISMATCH"
3476
+ ? "Use the already-granted role, or request an explicit owner-approved change."
3477
+ : "Request scoped admission with --goal, or have the owner explicitly authorize its legacy grant.";
3478
+ console.log(pc.red(`Agent was not joined: ${verification.code}. ${hint}`));
3479
+ }
3480
+ };
3458
3481
  if (options.cancelAdmission) {
3459
3482
  const cancelled = await cancelAdmission(normalizedSessionId, {
3460
3483
  agentId: requestedAgent,
@@ -3473,6 +3496,13 @@ export function registerSessionCommand(program) {
3473
3496
  else console.log(`Admission request ${payload.admission.admissionId || ""} cancelled.`);
3474
3497
  return;
3475
3498
  }
3499
+ if (requestedAgent && !normalizeString(options.goal)) {
3500
+ const refusal = await legacyJoinAdmissionRefusal(normalizedSessionId, requestedAgent);
3501
+ if (refusal) {
3502
+ rejectLegacyJoin(refusal, requestedAgent);
3503
+ return;
3504
+ }
3505
+ }
3476
3506
  // Membership first. Only a member may ask for an agent to be admitted, so an
3477
3507
  // invitation is accepted BEFORE the admission request, never after.
3478
3508
  let invitationAccept = null;
@@ -3491,13 +3521,14 @@ export function registerSessionCommand(program) {
3491
3521
  : null;
3492
3522
  }
3493
3523
  let admission = null;
3524
+ let admissionJoinOptions = null;
3494
3525
  if (normalizeString(options.goal)) {
3495
3526
  const admissionAgent = requestedAgent;
3496
3527
  if (!admissionAgent) {
3497
3528
  throw new Error("--goal requires --agent <id>: admission is requested for one named agent.");
3498
3529
  }
3499
3530
  const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
3500
- admission = await runAdmissionJoin(normalizedSessionId, {
3531
+ admissionJoinOptions = {
3501
3532
  agentId: admissionAgent,
3502
3533
  displayName: legacyName && explicitAgent ? legacyName : "",
3503
3534
  model: options.model,
@@ -3508,6 +3539,7 @@ export function registerSessionCommand(program) {
3508
3539
  actions: parseAdmissionScope(options.scope),
3509
3540
  ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
3510
3541
  wait: options.wait !== false,
3542
+ recoverActive: Boolean(options.recoverAdmission),
3511
3543
  waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
3512
3544
  targetPath,
3513
3545
  onPending: ({ approveUrl, phase }) => {
@@ -3523,7 +3555,8 @@ export function registerSessionCommand(program) {
3523
3555
  else if (phase === "identity") console.log(pc.yellow(line));
3524
3556
  else console.log(line);
3525
3557
  },
3526
- });
3558
+ };
3559
+ admission = await runAdmissionJoin(normalizedSessionId, admissionJoinOptions);
3527
3560
  if (admission.status !== "active") {
3528
3561
  // Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
3529
3562
  // registers the agent or materializes the room for it.
@@ -3550,6 +3583,34 @@ export function registerSessionCommand(program) {
3550
3583
  }
3551
3584
  }
3552
3585
 
3586
+ const admittedAgentId = admission ? requestedAgent : "";
3587
+ const acceptedOnboarding = normalizeSessionOnboarding(
3588
+ invitationAcceptResult?.onboarding,
3589
+ invitationAcceptResult?.claimedSeat,
3590
+ );
3591
+ const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
3592
+ if (!admission && !explicitAgent && acceptedAgentId) {
3593
+ assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
3594
+ }
3595
+ const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
3596
+ const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
3597
+ if (admission && resolvedAgentId !== admittedAgentId) {
3598
+ throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
3599
+ }
3600
+ const roleWasExplicit = optionWasSetByCli(command, "role");
3601
+ const role =
3602
+ normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
3603
+ "coder";
3604
+ const model = normalizeString(options.model) || "cli";
3605
+ const namedLegacyAgent = !admission && Boolean(agentSeed || normalizeString(process.env.SENTINELAYER_AGENT_ID));
3606
+ if (namedLegacyAgent) {
3607
+ const refusal = await legacyJoinAdmissionRefusal(normalizedSessionId, resolvedAgentId);
3608
+ if (refusal) {
3609
+ rejectLegacyJoin(refusal, resolvedAgentId);
3610
+ return;
3611
+ }
3612
+ }
3613
+
3553
3614
  // PR #483 contract: verify the session exists and the caller has access
3554
3615
  // BEFORE materializing local cache state. Without this we'd silently
3555
3616
  // create a phantom local NDJSON for a session that's archived or owned
@@ -3564,21 +3625,36 @@ export function registerSessionCommand(program) {
3564
3625
  // grant's receipt, and never with the human token. A cached "active" is not
3565
3626
  // reported until this succeeds (a revoked grant is not joined), and nothing
3566
3627
  // about the room is fetched on the human's authority.
3567
- const admittedAgentId = admission ? requestedAgent : "";
3568
3628
  let admissionReceipt = null;
3569
3629
  if (admission) {
3570
- try {
3571
- admissionReceipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3630
+ const verifyAdmissionReceipt = async () => {
3631
+ const receipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3572
3632
  fetchOwnAdmissionReceipt(normalizedSessionId, { targetPath }),
3573
3633
  );
3574
3634
  // The grant must be for EXACTLY the identity this join registers, byte for
3575
3635
  // byte: a receipt for any other spelling is a different agent's authority.
3576
- const receiptAgent = String(admissionReceipt?.agentId ?? "");
3636
+ const receiptAgent = String(receipt?.agentId ?? "");
3577
3637
  if (receiptAgent !== admittedAgentId) {
3578
3638
  throw new Error(
3579
3639
  `the admission is for agent "${receiptAgent || "(none)"}", not "${admittedAgentId}"`,
3580
3640
  );
3581
3641
  }
3642
+ return receipt;
3643
+ };
3644
+ try {
3645
+ try {
3646
+ admissionReceipt = await verifyAdmissionReceipt();
3647
+ } catch (error) {
3648
+ // One key-possession recovery for a stale bearer, never a different
3649
+ // identity, denied scope, provider/network failure or generic 403.
3650
+ if (options.recoverAdmission || !(error instanceof SentinelayerApiError) ||
3651
+ ![401, 403].includes(error.status) || error.code !== "INVALID_TOKEN") throw error;
3652
+ admission = await runAdmissionJoin(normalizedSessionId, {
3653
+ ...admissionJoinOptions, recoverActive: true,
3654
+ });
3655
+ if (admission.status !== "active") throw new Error("The admission is not active; recovery did not join the agent.");
3656
+ admissionReceipt = await verifyAdmissionReceipt();
3657
+ }
3582
3658
  } catch (error) {
3583
3659
  process.exitCode = 4;
3584
3660
  const payload = {
@@ -3594,7 +3670,7 @@ export function registerSessionCommand(program) {
3594
3670
  },
3595
3671
  };
3596
3672
  if (emitJson) console.log(JSON.stringify(payload, null, 2));
3597
- else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined.`));
3673
+ else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined. For a live grant with a retained key, retry the unchanged request with --recover-admission.`));
3598
3674
  return;
3599
3675
  }
3600
3676
  admission = { ...admission, verified: true, receipt: admissionReceipt };
@@ -3622,6 +3698,16 @@ export function registerSessionCommand(program) {
3622
3698
  }
3623
3699
 
3624
3700
  const remoteSession = verification.session || {};
3701
+ let legacyAgentVerification = null;
3702
+ if (namedLegacyAgent) {
3703
+ legacyAgentVerification = await verifyLegacyAgentJoin(normalizedSessionId, {
3704
+ agentId: resolvedAgentId, role, model, targetPath, remoteSession,
3705
+ });
3706
+ if (!legacyAgentVerification.ok) {
3707
+ rejectLegacyJoin(legacyAgentVerification, resolvedAgentId);
3708
+ return;
3709
+ }
3710
+ }
3625
3711
  const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
3626
3712
  targetPath,
3627
3713
  title: normalizeString(remoteSession.title),
@@ -3637,29 +3723,6 @@ export function registerSessionCommand(program) {
3637
3723
  )
3638
3724
  : { skipped: true, reason: "the grant does not include session.read" };
3639
3725
 
3640
- const acceptedOnboarding = normalizeSessionOnboarding(
3641
- invitationAcceptResult?.onboarding,
3642
- invitationAcceptResult?.claimedSeat,
3643
- );
3644
- const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
3645
- // An admitted join registers the admitted identity and nothing else. Otherwise an
3646
- // invitation's onboarding agent id is the identity only when none was given, and
3647
- // it is held to the same canonical rule before anything is registered locally.
3648
- if (!admission && !explicitAgent && acceptedAgentId) {
3649
- assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
3650
- }
3651
- const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
3652
- const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
3653
- if (admission && resolvedAgentId !== admittedAgentId) {
3654
- throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
3655
- }
3656
- const roleWasExplicit = optionWasSetByCli(command, "role");
3657
- const role =
3658
- normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
3659
- "coder";
3660
- const model = normalizeString(options.model) || "cli";
3661
- const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
3662
-
3663
3726
  // Registration updates the local participant projection and returns
3664
3727
  // onboarding directly to this process. It never writes join, identity,
3665
3728
  // or coaching rows into the transcript.
@@ -3671,7 +3734,7 @@ export function registerSessionCommand(program) {
3671
3734
  clientKind: "cli",
3672
3735
  role,
3673
3736
  trackProcessExit: false,
3674
- awaitRemoteSync: hasConcreteAgentIdentity,
3737
+ preserveIdentity: Boolean(admission || namedLegacyAgent),
3675
3738
  });
3676
3739
  const agentJoinRelayed = false;
3677
3740
  const localBriefingPayload =
@@ -3714,6 +3777,7 @@ export function registerSessionCommand(program) {
3714
3777
  materializedLocalSession: localSession.materialized,
3715
3778
  refreshedLocalSession: Boolean(localSession.refreshed),
3716
3779
  verificationSource: verification.source,
3780
+ remoteAgentVerification: legacyAgentVerification,
3717
3781
  joinHydration,
3718
3782
  eventCount: Number.isFinite(eventCount) ? eventCount : 0,
3719
3783
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
@@ -202,6 +202,9 @@ export const BRIDGE_DENIED_INPUTS = Object.freeze({
202
202
  "ring-owner": {
203
203
  gatewayUrl: "the gateway that receives the user's token",
204
204
  },
205
+ "session.join": {
206
+ recoverAdmission: "restores an agent credential using the delegator's retained private key; CLI control only",
207
+ },
205
208
  "swarm.create": {
206
209
  target: "the site that this plan tests",
207
210
  },
@@ -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()) {