sentinelayer-cli 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +29 -16
  2. package/package.json +1 -1
  3. package/src/agents/jules/tools/aidenid-email.js +7 -9
  4. package/src/agents/jules/tools/frontend-analyze.js +15 -3
  5. package/src/agents/jules/tools/runtime-audit.js +51 -21
  6. package/src/ai/aidenid.js +5 -4
  7. package/src/ai/proxy.js +15 -14
  8. package/src/audit/orchestrator.js +8 -5
  9. package/src/auth/admission-scope.js +5 -3
  10. package/src/auth/credential-destinations.js +221 -0
  11. package/src/auth/gate.js +4 -4
  12. package/src/auth/http.js +22 -5
  13. package/src/auth/service.js +52 -56
  14. package/src/cli.js +14 -6
  15. package/src/commands/ai/provision-governance.js +7 -6
  16. package/src/commands/init.js +25 -16
  17. package/src/commands/scan.js +6 -4
  18. package/src/commands/session.js +166 -51
  19. package/src/commands/spec.js +2 -2
  20. package/src/commands/watch.js +4 -3
  21. package/src/config/service.js +37 -0
  22. package/src/daemon/pulse.js +2 -1
  23. package/src/daemon/watchdog.js +117 -68
  24. package/src/legacy-cli.js +119 -39
  25. package/src/mcp/bridge-inputs.js +223 -0
  26. package/src/mcp/cli-command-tools.js +160 -7
  27. package/src/mcp/registry.js +6 -5
  28. package/src/mcp/session-stdio-server.js +157 -8
  29. package/src/mcp/smoke.js +27 -11
  30. package/src/mcp/token-service.js +7 -9
  31. package/src/memory/retrieval.js +14 -5
  32. package/src/pocket/ring-owner.js +13 -8
  33. package/src/review/ai-review.js +2 -2
  34. package/src/review/dd-report-email-client.js +4 -5
  35. package/src/session/admission-access.js +41 -10
  36. package/src/session/admission-auth.js +95 -16
  37. package/src/session/admission.js +91 -21
  38. package/src/session/checkpoints.js +11 -10
  39. package/src/session/control-events.js +2 -0
  40. package/src/session/file-locks.js +19 -18
  41. package/src/session/invitations.js +26 -19
  42. package/src/session/live-source.js +13 -13
  43. package/src/session/reactions.js +258 -0
  44. package/src/session/route-classes.js +107 -0
  45. package/src/session/sync.js +110 -66
  46. package/src/session/tickets.js +21 -19
  47. package/src/session/title-sync.js +4 -3
  48. package/src/telemetry/sync.js +8 -9
@@ -8,14 +8,40 @@
8
8
 
9
9
  import process from "node:process";
10
10
 
11
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
11
12
  import { requestJson, requestJsonMutation } from "../auth/http.js";
12
13
  import { resolveActiveAuthSession } from "../auth/service.js";
14
+ import { hasStoredAdmissionCredentials } from "./admission.js";
15
+ import { currentAdmittedAgent } from "./admission-auth.js";
13
16
  import {
14
17
  SESSION_MUTATION_ORIGIN,
15
18
  createSessionMutationHeaders,
16
19
  createSessionMutationIdempotencyKey,
17
20
  } from "./invitations.js";
18
21
 
22
+ /**
23
+ * Approve, deny, revoke and mode changes are the room owner's decisions, made with the
24
+ * owner's own session. They are unavailable while this process is an agent context:
25
+ * SENTINELAYER_AGENT_ID is set, an agent admission scope is in force, or this machine
26
+ * stores agent admission credentials. Checked before any credential lookup or request.
27
+ */
28
+ export async function assertOwnerAccessContext({ env = process.env, homeDir } = {}) {
29
+ let why = "";
30
+ if (normalizeString(env.SENTINELAYER_AGENT_ID)) {
31
+ why = "SENTINELAYER_AGENT_ID is set";
32
+ } else if (currentAdmittedAgent()) {
33
+ why = "an agent admission is in use";
34
+ } else if (await hasStoredAdmissionCredentials({ homeDir })) {
35
+ why = "agent admission credentials are stored on this machine";
36
+ }
37
+ if (why) {
38
+ throw new Error(
39
+ `Access approve, deny, revoke and mode are owner actions and are unavailable in an agent context (${why}). ` +
40
+ `Use the room's web dashboard, or run the command from an environment without agent credentials.`,
41
+ );
42
+ }
43
+ }
44
+
19
45
  export const ADMISSION_STATUSES = Object.freeze([
20
46
  "pending",
21
47
  "approved",
@@ -49,11 +75,11 @@ async function authContext(targetPath, resolveAuthSession) {
49
75
  env: process.env,
50
76
  autoRotate: false,
51
77
  });
52
- if (!auth?.token || !auth?.apiUrl) {
78
+ if (!isAuthenticated(auth) || !auth?.apiUrl) {
53
79
  throw new Error("Not authenticated. Run `sl auth login` first.");
54
80
  }
55
81
  return {
56
- token: auth.token,
82
+ credential: await credentialFor(auth),
57
83
  apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, ""),
58
84
  };
59
85
  }
@@ -84,12 +110,13 @@ async function mutateAdmission(
84
110
  ) {
85
111
  const key = boundedIdempotencyKey(idempotencyKey, operationName);
86
112
  const auth = await authContext(targetPath, resolveAuthSession);
87
- const result = await requestMutation(admissionsUrl(auth.apiUrl, sessionId, suffix), {
113
+ const result = await checkedTransport(requestMutation)(admissionsUrl(auth.apiUrl, sessionId, suffix), {
88
114
  method: "POST",
89
115
  operationName,
90
116
  idempotencyKey: key,
117
+ credential: auth.credential,
91
118
  headers: createSessionMutationHeaders({
92
- bearerToken: auth.token,
119
+ credential: auth.credential,
93
120
  sessionId,
94
121
  routeId,
95
122
  idempotencyKey: key,
@@ -118,9 +145,9 @@ export async function listSessionAdmissions(
118
145
  }
119
146
  const auth = await authContext(targetPath, resolveAuthSession);
120
147
  const query = normalizedStatus ? `?status=${encodeURIComponent(normalizedStatus)}` : "";
121
- return requestRead(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
148
+ return checkedTransport(requestRead)(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
122
149
  method: "GET",
123
- headers: { Authorization: `Bearer ${auth.token}` },
150
+ credential: auth.credential,
124
151
  });
125
152
  }
126
153
 
@@ -136,9 +163,9 @@ export async function getSessionAdmission(
136
163
  const sid = required(sessionId, "session id");
137
164
  const aid = required(admissionId, "admission id");
138
165
  const auth = await authContext(targetPath, resolveAuthSession);
139
- return requestRead(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
166
+ return checkedTransport(requestRead)(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
140
167
  method: "GET",
141
- headers: { Authorization: `Bearer ${auth.token}` },
168
+ credential: auth.credential,
142
169
  });
143
170
  }
144
171
 
@@ -171,6 +198,7 @@ export async function decideSessionAdmission(
171
198
  if (ttlSeconds !== undefined) body.ttlSeconds = ttlSeconds;
172
199
  const normalizedNote = normalizeString(note);
173
200
  if (normalizedNote) body.note = normalizedNote;
201
+ await assertOwnerAccessContext();
174
202
  return mutateAdmission(
175
203
  sid,
176
204
  `/${encodeURIComponent(aid)}/decision`,
@@ -202,6 +230,7 @@ export async function revokeSessionAdmission(
202
230
  const sid = required(sessionId, "session id");
203
231
  const aid = required(admissionId, "admission id");
204
232
  const normalizedReason = normalizeString(reason);
233
+ await assertOwnerAccessContext();
205
234
  return mutateAdmission(
206
235
  sid,
207
236
  `/${encodeURIComponent(aid)}/revoke`,
@@ -234,16 +263,18 @@ export async function setSessionAdmissionMode(
234
263
  if (!["legacy", "required"].includes(normalizedMode)) {
235
264
  throw new Error("mode must be legacy or required.");
236
265
  }
266
+ await assertOwnerAccessContext();
237
267
  const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
238
268
  const auth = await authContext(targetPath, resolveAuthSession);
239
- const result = await requestMutation(
269
+ const result = await checkedTransport(requestMutation)(
240
270
  `${auth.apiUrl}/api/v1/sessions/${encodeURIComponent(sid)}/admission-mode`,
241
271
  {
242
272
  method: "POST",
243
273
  operationName: "session.admission_mode",
244
274
  idempotencyKey: key,
275
+ credential: auth.credential,
245
276
  headers: createSessionMutationHeaders({
246
- bearerToken: auth.token,
277
+ credential: auth.credential,
247
278
  sessionId: sid,
248
279
  routeId: ROUTES.mode,
249
280
  idempotencyKey: key,
@@ -25,7 +25,12 @@ import path from "node:path";
25
25
  import process from "node:process";
26
26
 
27
27
  import { admittedAgentScope } from "../auth/admission-scope.js";
28
- import { readAdmissionCredentialState } from "./admission.js";
28
+ import {
29
+ hasStoredAdmissionCredentials,
30
+ readAdmissionCredentialState,
31
+ storedAdmissionAgentsForSession,
32
+ } from "./admission.js";
33
+ import { sessionRouteClass } from "./route-classes.js";
29
34
 
30
35
  export class AdmissionCredentialRefused extends Error {
31
36
  constructor(message, { reason, sessionId, agentId } = {}) {
@@ -238,26 +243,89 @@ export async function resolveAgentAdmissionTarget(args = [], { env = process.env
238
243
  return null;
239
244
  }
240
245
 
241
- /**
242
- * preAction guard: the identity Commander ACTUALLY resolved for this action must be
243
- * the one whose admission scope (if any) the dispatch was wrapped in. Computed from
244
- * Commander's own resolved options -- counting only values given on the command line,
245
- * not defaults such as read's `--agent cli-user` -- so no argv reading of ours can
246
- * disagree with execution. A mismatch refuses before the action runs.
247
- */
248
- export async function assertDispatchMatchesScope(actionCommand, { env = process.env, homeDir } = {}) {
246
+ // What Commander parsed for the action about to run: its `session` command path, its
247
+ // options (and which of them were given on the command line, not defaulted, such as read's
248
+ // `--agent cli-user`), and its operands. The preAction guard reads only these, the same values
249
+ // the action itself uses, so no reading of the raw argv can disagree with execution.
250
+ function parsedSessionAction(actionCommand) {
249
251
  const names = [];
250
252
  for (let cmd = actionCommand; cmd && cmd.parent; cmd = cmd.parent) names.unshift(cmd.name());
251
- if (String(names[0] || "").toLowerCase() !== "session") return;
252
- const command = names.slice(1).join(" ").toLowerCase();
253
- const principal = SESSION_AGENT_PRINCIPAL[command];
253
+ if (String(names[0] || "").toLowerCase() !== "session") return null;
254
254
  const opts = actionCommand.opts();
255
255
  const given = (key) =>
256
256
  actionCommand.getOptionValueSource?.(key) === "cli" ? String(opts[key] ?? "").trim() : "";
257
+ const processed = Array.isArray(actionCommand.processedArgs) ? actionCommand.processedArgs : [];
258
+ return {
259
+ route: `cli:${names.join(" ").toLowerCase()}`,
260
+ command: names.slice(1).join(" ").toLowerCase(),
261
+ opts,
262
+ given,
263
+ first: String(processed[0] ?? "").trim(),
264
+ operands: processed.flat().map((value) => String(value ?? "").trim()).filter(Boolean),
265
+ };
266
+ }
267
+
268
+ // Through the MCP CLI bridge (which marks its child processes with SENTINELAYER_MCP_BRIDGE=1)
269
+ // in an agent context (SENTINELAYER_AGENT_ID is set, or this machine stores any agent
270
+ // admission), by the command's class in SESSION_ROUTE_CLASSES:
271
+ // - exempt-local: runs as before
272
+ // - owner: refused
273
+ // - a session the command will use (its --session or --id value, given or defaulted, or any
274
+ // operand) stores an admission, in any state: the action must be running in that
275
+ // session's admission scope, or it is refused before it runs
276
+ // - no session it uses stores one: legacy-data-plane runs as before; control and
277
+ // unclassified commands are refused
278
+ async function assertBridgedRoute(action, { env, homeDir }) {
279
+ if (env.SENTINELAYER_MCP_BRIDGE !== "1") return;
280
+ const agentContext =
281
+ Boolean(String(env.SENTINELAYER_AGENT_ID || "").trim()) || (await hasStoredAdmissionCredentials({ homeDir }));
282
+ if (!agentContext) return;
283
+ const routeClass = sessionRouteClass(action.route);
284
+ const refuse = (why) => {
285
+ throw new AdmissionCredentialRefused(
286
+ `\`sl ${action.route.slice("cli:".length)}\` runs for an agent here and needs an admission it can bind to: ` +
287
+ `${why}. It will not fall back to other credentials.`,
288
+ { reason: "bridged_route_refused" },
289
+ );
290
+ };
291
+ if (routeClass === "exempt-local") return;
292
+ if (routeClass === "owner") refuse("owner actions are not available to agents");
293
+ const named = [action.opts.session, action.opts.id, ...action.operands]
294
+ .map((value) => String(value ?? "").trim())
295
+ .filter(Boolean);
296
+ const admitted = [];
297
+ for (const sessionId of named) {
298
+ if ((await storedAdmissionAgentsForSession(sessionId, { homeDir })).length > 0) admitted.push(sessionId);
299
+ }
300
+ if (admitted.length > 0) {
301
+ const bound = String(currentAdmittedAgent()?.sessionId || "").toLowerCase();
302
+ if (!bound || !admitted.some((sid) => sid.toLowerCase() === bound)) {
303
+ refuse(`session ${admitted[0]} stores an admission and this command's agent is not bound to it`);
304
+ }
305
+ return;
306
+ }
307
+ if (routeClass === "legacy-data-plane") return;
308
+ refuse(routeClass === "control" ? "it is a control operation" : "it is not classified for agents");
309
+ }
310
+
311
+ /**
312
+ * preAction guard, run before ANY action, on Commander's parsed values only:
313
+ * 1. a bridged command in an agent context binds to its session's admission, runs as a
314
+ * legacy data-plane route, or is refused (assertBridgedRoute above);
315
+ * 2. the identity Commander resolved for the action must be the one whose admission scope
316
+ * (if any) the dispatch was wrapped in.
317
+ * Either refusal happens before the action runs, so before any request.
318
+ */
319
+ export async function assertDispatchMatchesScope(actionCommand, { env = process.env, homeDir } = {}) {
320
+ const action = parsedSessionAction(actionCommand);
321
+ if (!action) return;
322
+ await assertBridgedRoute(action, { env, homeDir });
323
+ const { command, given } = action;
324
+ const principal = SESSION_AGENT_PRINCIPAL[command];
257
325
  let actual = null;
258
326
  if (principal === "actor" || (!principal && given("agent"))) {
259
- const sessionId = given("session") || given("id") || String(actionCommand.processedArgs?.[0] ?? "").trim();
260
- const targetPath = path.resolve(process.cwd(), String(opts.path || "."));
327
+ const sessionId = given("session") || given("id") || action.first;
328
+ const targetPath = path.resolve(process.cwd(), String(action.opts.path || "."));
261
329
  const agentId =
262
330
  given("agent") ||
263
331
  String(env.SENTINELAYER_AGENT_ID || "").trim() ||
@@ -279,9 +347,20 @@ export async function assertDispatchMatchesScope(actionCommand, { env = process.
279
347
  }
280
348
 
281
349
  /** Run `fn` with the right credential for (sessionId, agentId). See the module header. */
282
- export async function withAgentAdmission(sessionId, agentId, fn, { homeDir, now } = {}) {
350
+ export async function withAgentAdmission(sessionId, agentId, fn, { homeDir, now, requireStored = false } = {}) {
283
351
  const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir, now });
284
- if (held.state === "none") return fn({ admitted: false });
352
+ if (held.state === "none") {
353
+ // A caller that already decided this must bind (it saw a stored admission) never runs
354
+ // without one: a credential file that vanished in between is a refusal, not absence.
355
+ if (requireStored) {
356
+ throw new AdmissionCredentialRefused(
357
+ `Agent "${agentId}"'s stored admission for session ${sessionId} is no longer present. ` +
358
+ `It will not fall back to your own credentials.`,
359
+ { reason: "admission_removed", sessionId, agentId },
360
+ );
361
+ }
362
+ return fn({ admitted: false });
363
+ }
285
364
  if (held.state !== "live") {
286
365
  const why = REFUSAL_HINT[held.reason] || `its stored admission is unusable (${held.reason})`;
287
366
  throw new AdmissionCredentialRefused(
@@ -30,6 +30,7 @@ import path from "node:path";
30
30
  import process from "node:process";
31
31
  import { setTimeout as sleepMs } from "node:timers/promises";
32
32
 
33
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
33
34
  import { requestJson, requestJsonMutation } from "../auth/http.js";
34
35
  import { resolveActiveAuthSession } from "../auth/service.js";
35
36
  import { canonicalize } from "../engram/canonical.js";
@@ -63,7 +64,8 @@ const ROUTE = {
63
64
  claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
64
65
  cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
65
66
  };
66
- const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked"]);
67
+ // Final admission statuses: polling stops, nothing is claimed, local request state is cleared.
68
+ const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked", "stopped"]);
67
69
  const TTL_MIN_SECONDS = 300;
68
70
  const TTL_MAX_SECONDS = 86_400;
69
71
 
@@ -242,6 +244,67 @@ export function admissionCredentialPath(sessionId, agentId, { homeDir } = {}) {
242
244
  );
243
245
  }
244
246
 
247
+ /**
248
+ * Whether this machine stores ANY agent admission credential (usable or not, any
249
+ * session): `<home>/.sentinelayer/agents/<agent>/admissions/<session>.json`. Agent keys
250
+ * alone do not count. An unreadable directory counts as present, never as absence.
251
+ */
252
+ export async function hasStoredAdmissionCredentials({ homeDir } = {}) {
253
+ const agentsDir = path.join(sentinelayerHome(homeDir), "agents");
254
+ let agents;
255
+ try {
256
+ agents = await fsp.readdir(agentsDir, { withFileTypes: true });
257
+ } catch (error) {
258
+ if (error && error.code === "ENOENT") return false;
259
+ return true;
260
+ }
261
+ for (const agent of agents) {
262
+ if (!agent.isDirectory()) continue;
263
+ let files;
264
+ try {
265
+ files = await fsp.readdir(path.join(agentsDir, agent.name, "admissions"));
266
+ } catch (error) {
267
+ if (error && error.code === "ENOENT") continue;
268
+ return true;
269
+ }
270
+ if (files.some((name) => name.endsWith(".json"))) return true;
271
+ }
272
+ return false;
273
+ }
274
+
275
+ /**
276
+ * The agents for which this machine stores an admission credential file for this
277
+ * session, usable or not (`<home>/.sentinelayer/agents/<agent>/admissions/<session>.json`).
278
+ * An unreadable agents directory is reported as a stored entry, never as absence.
279
+ */
280
+ export async function storedAdmissionAgentsForSession(sessionId, { homeDir } = {}) {
281
+ let sessionFile;
282
+ try {
283
+ sessionFile = `${safeSegment(sessionId, "sessionId")}.json`;
284
+ } catch {
285
+ return []; // not a storable session id, so nothing can be stored for it
286
+ }
287
+ const agentsDir = path.join(sentinelayerHome(homeDir), "agents");
288
+ let agents;
289
+ try {
290
+ agents = await fsp.readdir(agentsDir, { withFileTypes: true });
291
+ } catch (error) {
292
+ if (error && error.code === "ENOENT") return [];
293
+ return ["(unreadable)"];
294
+ }
295
+ const found = [];
296
+ for (const agent of agents) {
297
+ if (!agent.isDirectory()) continue;
298
+ try {
299
+ await fsp.access(path.join(agentsDir, agent.name, "admissions", sessionFile));
300
+ found.push(agent.name.replace(/%3a/g, ":"));
301
+ } catch (error) {
302
+ if (!(error && error.code === "ENOENT")) found.push(agent.name.replace(/%3a/g, ":"));
303
+ }
304
+ }
305
+ return found;
306
+ }
307
+
245
308
  /**
246
309
  * What this machine holds for (session, agent):
247
310
  * { state: "none" } -- never admitted here: the legacy path applies
@@ -304,10 +367,10 @@ function requestFingerprint({ apiUrl, agentId, publicKey, goal, actions, ttlSeco
304
367
 
305
368
  async function authContext({ targetPath, resolveAuthSession }) {
306
369
  const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
307
- if (!auth?.token || !auth?.apiUrl) {
370
+ if (!isAuthenticated(auth) || !auth?.apiUrl) {
308
371
  throw new Error("Not authenticated. Run `sl auth login` first.");
309
372
  }
310
- return { token: auth.token, apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, "") };
373
+ return { credential: await credentialFor(auth), apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, "") };
311
374
  }
312
375
 
313
376
  function admissionUrl(apiUrl, sessionId, suffix = "") {
@@ -316,12 +379,13 @@ function admissionUrl(apiUrl, sessionId, suffix = "") {
316
379
 
317
380
  async function mutate(auth, sessionId, routeId, url, body, { requestMutation, operationName, origin }) {
318
381
  const idempotencyKey = createSessionMutationIdempotencyKey(operationName);
319
- return requestMutation(url, {
382
+ return checkedTransport(requestMutation)(url, {
320
383
  method: "POST",
321
384
  operationName,
322
385
  idempotencyKey,
386
+ credential: auth.credential,
323
387
  headers: createSessionMutationHeaders({
324
- bearerToken: auth.token,
388
+ credential: auth.credential,
325
389
  sessionId,
326
390
  routeId,
327
391
  idempotencyKey,
@@ -466,23 +530,29 @@ export async function runAdmissionJoin(
466
530
  const deadline = now() + Math.max(0, waitTimeoutMs);
467
531
  let polled;
468
532
  for (;;) {
469
- polled = await requestRead(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
533
+ polled = await checkedTransport(requestRead)(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
470
534
  method: "GET",
471
- headers: { Authorization: `Bearer ${auth.token}` },
535
+ credential: auth.credential,
472
536
  });
473
537
  state.status = polled.status;
474
- const claimReady =
475
- polled.status === "approved" &&
476
- polled.identityReady === true &&
477
- polled?.claim?.domain === CLAIM_DOMAIN &&
478
- polled?.claim?.preimage;
479
- if (polled.status === "approved" && polled.identityReady === true && !claimReady) {
480
- throw new Error("Identity-ready admission did not include a valid claim challenge.");
481
- }
482
- if (polled.status === "approved" && polled.identityReady !== true && polled?.claim) {
483
- throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
538
+ // Identity readiness is a server capability: a server that gates the claim on
539
+ // AIdenID identity evidence reports `identityReady` (true or false) on every
540
+ // approved poll. A server that does not report it issues the claim challenge on
541
+ // approval, so the claim proceeds as soon as the admission is approved.
542
+ const reportsIdentityReadiness =
543
+ polled.status === "approved" && Object.prototype.hasOwnProperty.call(polled, "identityReady");
544
+ const claimable =
545
+ polled.status === "approved" && (!reportsIdentityReadiness || polled.identityReady === true);
546
+ if (reportsIdentityReadiness) {
547
+ const claimReady = claimable && polled?.claim?.domain === CLAIM_DOMAIN && polled?.claim?.preimage;
548
+ if (polled.identityReady === true && !claimReady) {
549
+ throw new Error("Identity-ready admission did not include a valid claim challenge.");
550
+ }
551
+ if (polled.identityReady !== true && polled?.claim) {
552
+ throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
553
+ }
484
554
  }
485
- if (claimReady || TERMINAL.has(polled.status) || polled.status === "active") break;
555
+ if (claimable || TERMINAL.has(polled.status) || polled.status === "active") break;
486
556
  const phase = polled.status === "approved" ? "identity" : "approval";
487
557
  onPending({
488
558
  admissionId: state.admissionId,
@@ -600,13 +670,13 @@ export async function fetchOwnAdmissionReceipt(
600
670
  ) {
601
671
  const sid = normalizeString(sessionId);
602
672
  const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
603
- if (!auth?.token || auth.source !== "session_admission") {
673
+ if (!isAuthenticated(auth) || auth.source !== "session_admission") {
604
674
  throw new Error("The admission receipt must be fetched with the admission credential.");
605
675
  }
606
676
  const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
607
- return requestRead(admissionUrl(apiUrl, sid, "/self"), {
677
+ return checkedTransport(requestRead)(admissionUrl(apiUrl, sid, "/self"), {
608
678
  method: "GET",
609
- headers: { Authorization: `Bearer ${auth.token}` },
679
+ credential: await credentialFor(auth),
610
680
  });
611
681
  }
612
682
 
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import process from "node:process";
3
3
 
4
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
4
5
  import { SentinelayerApiError, requestJson, requestJsonMutation } from "../auth/http.js";
5
6
  import { resolveActiveAuthSession } from "../auth/service.js";
6
7
  import { pollSessionEventsBefore } from "./sync.js";
@@ -419,12 +420,12 @@ async function resolveCheckpointApi({
419
420
  env: process.env,
420
421
  autoRotate: false,
421
422
  });
422
- if (!auth || !auth.token) {
423
+ if (!isAuthenticated(auth)) {
423
424
  throw new Error("Sentinelayer auth is required. Run `sl auth login` first.");
424
425
  }
425
426
  return {
426
427
  apiUrl: normalizeApiUrl(auth.apiUrl),
427
- headers: { Authorization: `Bearer ${auth.token}` },
428
+ credential: await credentialFor(auth),
428
429
  };
429
430
  }
430
431
 
@@ -438,11 +439,11 @@ export async function listSessionCheckpoints(sessionId, {
438
439
  if (!normalizedSessionId) {
439
440
  throw new Error("session id is required.");
440
441
  }
441
- const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
442
+ const { apiUrl, credential } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
442
443
  const params = new URLSearchParams({ limit: String(normalizeLimit(limit)) });
443
444
  const response = await request(
444
445
  `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints?${params.toString()}`,
445
- { method: "GET", headers },
446
+ { method: "GET", credential },
446
447
  );
447
448
  const checkpoints = Array.isArray(response?.checkpoints) ? response.checkpoints : [];
448
449
  return {
@@ -519,12 +520,12 @@ export async function createSessionCheckpoint(sessionId, options = {}) {
519
520
  requestMutation = requestJsonMutation,
520
521
  } = options;
521
522
  const { body, idempotencyKey } = buildManualCheckpointPayload(normalizedSessionId, options);
522
- const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
523
- const response = await requestMutation(
523
+ const { apiUrl, credential } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
524
+ const response = await checkedTransport(requestMutation)(
524
525
  `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints`,
525
526
  {
526
527
  operationName: "session-checkpoint-create",
527
- headers,
528
+ credential,
528
529
  body,
529
530
  idempotencyKey,
530
531
  },
@@ -546,12 +547,12 @@ export async function generateSessionCheckpoint(sessionId, options = {}) {
546
547
  requestMutation = requestJsonMutation,
547
548
  } = options;
548
549
  const { body, idempotencyKey } = buildGenerateCheckpointPayload(normalizedSessionId, options);
549
- const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
550
- const response = await requestMutation(
550
+ const { apiUrl, credential } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
551
+ const response = await checkedTransport(requestMutation)(
551
552
  `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints/generate`,
552
553
  {
553
554
  operationName: "session-checkpoint-generate",
554
- headers,
555
+ credential,
555
556
  body,
556
557
  idempotencyKey,
557
558
  },
@@ -14,6 +14,8 @@ const QUIET_SESSION_ACTION_TYPES = new Set([
14
14
  "dislike",
15
15
  "disregard",
16
16
  "like",
17
+ "undislike",
18
+ "unlike",
17
19
  "view",
18
20
  ]);
19
21
 
@@ -9,6 +9,7 @@ import {
9
9
  requestJson,
10
10
  requestJsonMutation,
11
11
  } from "../auth/http.js";
12
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
12
13
  import { resolveActiveAuthSession } from "../auth/service.js";
13
14
  import { resolveSessionPaths } from "./paths.js";
14
15
 
@@ -477,14 +478,14 @@ async function resolveLeaseApi({
477
478
  env: process.env,
478
479
  autoRotate: false,
479
480
  });
480
- if (!auth?.token || !auth?.apiUrl) {
481
+ if (!isAuthenticated(auth) || !auth?.apiUrl) {
481
482
  throw new Error(
482
483
  "Authoritative session file leases require Sentinelayer auth. Run `sl auth login` first.",
483
484
  );
484
485
  }
485
486
  return {
486
487
  apiUrl: normalizeApiUrl(auth.apiUrl),
487
- headers: { Authorization: `Bearer ${auth.token}` },
488
+ credential: await credentialFor(auth),
488
489
  };
489
490
  }
490
491
 
@@ -505,10 +506,10 @@ async function listRemoteLeases(
505
506
  } = {},
506
507
  ) {
507
508
  const normalizedSessionId = normalizeSessionId(sessionId);
508
- const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
509
+ const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
509
510
  const response = await request(leaseCollectionUrl(apiUrl, normalizedSessionId), {
510
511
  method: "GET",
511
- headers,
512
+ credential,
512
513
  });
513
514
  if (response?.authoritative !== true || !Array.isArray(response?.leases)) {
514
515
  throw new Error("File-lease authority returned an invalid list response; refusing local fallback.");
@@ -529,13 +530,13 @@ async function guardRemoteClaims(
529
530
  ) {
530
531
  const normalizedSessionId = normalizeSessionId(sessionId);
531
532
  const normalizedHolderId = normalizeAgentId(holderId);
532
- const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
533
- const response = await requestMutation(
533
+ const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
534
+ const response = await checkedTransport(requestMutation)(
534
535
  `${leaseCollectionUrl(apiUrl, normalizedSessionId)}/guard`,
535
536
  {
536
537
  method: "POST",
537
538
  operationName: "session-file-lease-guard",
538
- headers,
539
+ credential,
539
540
  body: {
540
541
  holderId: normalizedHolderId,
541
542
  claims: paths.map((file, index) => ({
@@ -712,15 +713,15 @@ export async function lockFile(
712
713
  }
713
714
 
714
715
  const leaseToken = exactClaim?.leaseToken || newCapabilityToken();
715
- const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
716
+ const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
716
717
  let response;
717
718
  try {
718
- response = await requestMutation(
719
+ response = await checkedTransport(requestMutation)(
719
720
  leaseCollectionUrl(apiUrl, normalizedSessionId),
720
721
  {
721
722
  method: "POST",
722
723
  operationName: "session-file-lease-acquire",
723
- headers,
724
+ credential,
724
725
  body: {
725
726
  path: normalizedFilePath,
726
727
  holderId: normalizedAgentId,
@@ -792,12 +793,12 @@ export async function lockFile(
792
793
  } catch (cacheError) {
793
794
  let compensated = false;
794
795
  try {
795
- const release = await requestMutation(
796
+ const release = await checkedTransport(requestMutation)(
796
797
  leaseMemberUrl(apiUrl, normalizedSessionId, lease.leaseId, "release"),
797
798
  {
798
799
  method: "POST",
799
800
  operationName: "session-file-lease-acquire-compensation",
800
- headers,
801
+ credential,
801
802
  body: {
802
803
  holderId: normalizedAgentId,
803
804
  leaseToken,
@@ -845,15 +846,15 @@ async function releaseCapabilityClaim(
845
846
  requestMutation,
846
847
  },
847
848
  ) {
848
- const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
849
+ const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
849
850
  let response;
850
851
  try {
851
- response = await requestMutation(
852
+ response = await checkedTransport(requestMutation)(
852
853
  leaseMemberUrl(apiUrl, sessionId, claim.leaseId, "release"),
853
854
  {
854
855
  method: "POST",
855
856
  operationName: "session-file-lease-release",
856
- headers,
857
+ credential,
857
858
  body: {
858
859
  holderId: agentId,
859
860
  leaseToken: claim.leaseToken,
@@ -1007,13 +1008,13 @@ export async function renewFileLease(
1007
1008
  };
1008
1009
  }
1009
1010
 
1010
- const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
1011
- const response = await requestMutation(
1011
+ const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
1012
+ const response = await checkedTransport(requestMutation)(
1012
1013
  leaseMemberUrl(apiUrl, normalizedSessionId, claim.leaseId, "renew"),
1013
1014
  {
1014
1015
  method: "POST",
1015
1016
  operationName: "session-file-lease-renew",
1016
- headers,
1017
+ credential,
1017
1018
  body: {
1018
1019
  holderId: normalizedAgentId,
1019
1020
  leaseToken: claim.leaseToken,