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.
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.3",
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
  }