sentinelayer-cli 0.44.0 → 0.46.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 (51) hide show
  1. package/README.md +10 -5
  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/agents/shared-tools/shell.js +9 -0
  7. package/src/ai/aidenid.js +5 -4
  8. package/src/ai/proxy.js +15 -14
  9. package/src/audit/orchestrator.js +8 -5
  10. package/src/auth/admission-scope.js +5 -3
  11. package/src/auth/credential-destinations.js +221 -0
  12. package/src/auth/gate.js +4 -4
  13. package/src/auth/http.js +22 -5
  14. package/src/auth/service.js +52 -56
  15. package/src/cli.js +24 -6
  16. package/src/commands/ai/provision-governance.js +7 -6
  17. package/src/commands/init.js +25 -16
  18. package/src/commands/scan.js +6 -4
  19. package/src/commands/session.js +33 -45
  20. package/src/commands/spec.js +2 -2
  21. package/src/commands/watch.js +4 -3
  22. package/src/config/service.js +37 -0
  23. package/src/daemon/ast-parser-layer.js +8 -3
  24. package/src/daemon/callgraph-overlay.js +7 -2
  25. package/src/daemon/pulse.js +2 -1
  26. package/src/daemon/watchdog.js +117 -68
  27. package/src/legacy-cli.js +119 -39
  28. package/src/mcp/bridge-inputs.js +223 -0
  29. package/src/mcp/cli-command-tools.js +157 -7
  30. package/src/mcp/registry.js +2 -1
  31. package/src/mcp/session-stdio-server.js +114 -35
  32. package/src/mcp/smoke.js +27 -11
  33. package/src/mcp/token-service.js +7 -9
  34. package/src/memory/retrieval.js +14 -5
  35. package/src/pocket/ring-owner.js +13 -8
  36. package/src/program-lookup.js +54 -0
  37. package/src/review/ai-review.js +2 -2
  38. package/src/review/dd-report-email-client.js +4 -5
  39. package/src/review/local-review.js +11 -5
  40. package/src/session/admission-access.js +13 -10
  41. package/src/session/admission-auth.js +95 -16
  42. package/src/session/admission.js +44 -9
  43. package/src/session/checkpoints.js +11 -10
  44. package/src/session/file-locks.js +19 -18
  45. package/src/session/invitations.js +26 -19
  46. package/src/session/live-source.js +13 -13
  47. package/src/session/route-classes.js +107 -0
  48. package/src/session/sync.js +70 -56
  49. package/src/session/tickets.js +21 -19
  50. package/src/session/title-sync.js +4 -3
  51. package/src/telemetry/sync.js +8 -9
@@ -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";
@@ -271,6 +272,39 @@ export async function hasStoredAdmissionCredentials({ homeDir } = {}) {
271
272
  return false;
272
273
  }
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
+
274
308
  /**
275
309
  * What this machine holds for (session, agent):
276
310
  * { state: "none" } -- never admitted here: the legacy path applies
@@ -333,10 +367,10 @@ function requestFingerprint({ apiUrl, agentId, publicKey, goal, actions, ttlSeco
333
367
 
334
368
  async function authContext({ targetPath, resolveAuthSession }) {
335
369
  const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
336
- if (!auth?.token || !auth?.apiUrl) {
370
+ if (!isAuthenticated(auth) || !auth?.apiUrl) {
337
371
  throw new Error("Not authenticated. Run `sl auth login` first.");
338
372
  }
339
- return { token: auth.token, apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, "") };
373
+ return { credential: await credentialFor(auth), apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, "") };
340
374
  }
341
375
 
342
376
  function admissionUrl(apiUrl, sessionId, suffix = "") {
@@ -345,12 +379,13 @@ function admissionUrl(apiUrl, sessionId, suffix = "") {
345
379
 
346
380
  async function mutate(auth, sessionId, routeId, url, body, { requestMutation, operationName, origin }) {
347
381
  const idempotencyKey = createSessionMutationIdempotencyKey(operationName);
348
- return requestMutation(url, {
382
+ return checkedTransport(requestMutation)(url, {
349
383
  method: "POST",
350
384
  operationName,
351
385
  idempotencyKey,
386
+ credential: auth.credential,
352
387
  headers: createSessionMutationHeaders({
353
- bearerToken: auth.token,
388
+ credential: auth.credential,
354
389
  sessionId,
355
390
  routeId,
356
391
  idempotencyKey,
@@ -495,9 +530,9 @@ export async function runAdmissionJoin(
495
530
  const deadline = now() + Math.max(0, waitTimeoutMs);
496
531
  let polled;
497
532
  for (;;) {
498
- polled = await requestRead(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
533
+ polled = await checkedTransport(requestRead)(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
499
534
  method: "GET",
500
- headers: { Authorization: `Bearer ${auth.token}` },
535
+ credential: auth.credential,
501
536
  });
502
537
  state.status = polled.status;
503
538
  // Identity readiness is a server capability: a server that gates the claim on
@@ -635,13 +670,13 @@ export async function fetchOwnAdmissionReceipt(
635
670
  ) {
636
671
  const sid = normalizeString(sessionId);
637
672
  const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
638
- if (!auth?.token || auth.source !== "session_admission") {
673
+ if (!isAuthenticated(auth) || auth.source !== "session_admission") {
639
674
  throw new Error("The admission receipt must be fetched with the admission credential.");
640
675
  }
641
676
  const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
642
- return requestRead(admissionUrl(apiUrl, sid, "/self"), {
677
+ return checkedTransport(requestRead)(admissionUrl(apiUrl, sid, "/self"), {
643
678
  method: "GET",
644
- headers: { Authorization: `Bearer ${auth.token}` },
679
+ credential: await credentialFor(auth),
645
680
  });
646
681
  }
647
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
  },
@@ -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,
@@ -3,6 +3,7 @@ import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
 
6
+ import { checkedTransport, credentialFor, credentialHmac, isAuthenticated, isCredential } from "../auth/credential-destinations.js";
6
7
  import { requestJsonMutation } from "../auth/http.js";
7
8
  import { resolveActiveAuthSession } from "../auth/service.js";
8
9
  import { renderCoordinationBulletList } from "./coordination-guidance.js";
@@ -30,6 +31,16 @@ export function canonicalSessionRef(value) {
30
31
  return raw.toLowerCase();
31
32
  }
32
33
 
34
+ function sessionMutationCsrfMessage({ sessionId, routeId, idempotencyKey } = {}) {
35
+ return [
36
+ "session-mutation-csrf:v1",
37
+ canonicalSessionRef(sessionId),
38
+ normalizeString(routeId),
39
+ normalizeString(idempotencyKey),
40
+ ].join("\0");
41
+ }
42
+
43
+ // The API's CSRF proof for a given bearer: what the server (or a test fixture) checks against.
33
44
  export function createSessionMutationCsrfToken({
34
45
  bearerToken,
35
46
  sessionId,
@@ -38,35 +49,29 @@ export function createSessionMutationCsrfToken({
38
49
  } = {}) {
39
50
  const secret = normalizeString(bearerToken);
40
51
  if (!secret) return "";
41
- const message = [
42
- "session-mutation-csrf:v1",
43
- canonicalSessionRef(sessionId),
44
- normalizeString(routeId),
45
- normalizeString(idempotencyKey),
46
- ].join("\0");
47
- return createHmac("sha256", secret).update(message, "utf-8").digest("hex");
52
+ return createHmac("sha256", secret)
53
+ .update(sessionMutationCsrfMessage({ sessionId, routeId, idempotencyKey }), "utf-8")
54
+ .digest("hex");
48
55
  }
49
56
 
57
+ // The session-mutation headers: origin and a CSRF proof derived from the credential the request
58
+ // is sent with. The credential itself is attached by credentialedRequest, never here.
50
59
  export function createSessionMutationHeaders({
51
- bearerToken,
60
+ credential,
52
61
  sessionId,
53
62
  routeId,
54
63
  idempotencyKey,
55
64
  origin = SESSION_MUTATION_ORIGIN,
56
65
  } = {}) {
57
- const token = normalizeString(bearerToken);
58
66
  const normalizedOrigin = normalizeString(origin) || SESSION_MUTATION_ORIGIN;
59
67
  return {
60
- Authorization: `Bearer ${token}`,
61
68
  Origin: normalizedOrigin,
62
69
  "Sec-Fetch-Site": "same-site",
63
70
  "X-Sentinelayer-Session-Mutation": "session-mutation",
64
- "X-CSRF-Token": createSessionMutationCsrfToken({
65
- bearerToken: token,
66
- sessionId,
67
- routeId,
68
- idempotencyKey,
69
- }),
71
+ // keyed inside src/auth/credential-destinations.js; the token never leaves it
72
+ "X-CSRF-Token": isCredential(credential)
73
+ ? credentialHmac(credential, sessionMutationCsrfMessage({ sessionId, routeId, idempotencyKey }))
74
+ : "",
70
75
  };
71
76
  }
72
77
 
@@ -106,10 +111,11 @@ export async function acceptSessionInvitation(
106
111
  env: process.env,
107
112
  autoRotate: false,
108
113
  });
109
- if (!auth?.token || !auth?.apiUrl) {
114
+ if (!isAuthenticated(auth) || !auth?.apiUrl) {
110
115
  throw new Error("Not authenticated. Run `sl auth login` first.");
111
116
  }
112
117
 
118
+ const credential = await credentialFor(auth);
113
119
  const resolvedIdempotencyKey =
114
120
  normalizeString(idempotencyKey) || createSessionMutationIdempotencyKey("session-invite-accept");
115
121
  const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
@@ -121,14 +127,15 @@ export async function acceptSessionInvitation(
121
127
  if (normalizedSeatKey) body.seatKey = normalizedSeatKey;
122
128
  if (normalizedAgentId) body.agentId = normalizedAgentId;
123
129
 
124
- const result = await requestMutation(
130
+ const result = await checkedTransport(requestMutation)(
125
131
  `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/invitations/accept`,
126
132
  {
127
133
  method: "POST",
128
134
  operationName: "session.invitation_accept",
129
135
  idempotencyKey: resolvedIdempotencyKey,
136
+ credential,
130
137
  headers: createSessionMutationHeaders({
131
- bearerToken: auth.token,
138
+ credential,
132
139
  sessionId: normalizedSessionId,
133
140
  routeId: SESSION_INVITATION_ACCEPT_ROUTE_ID,
134
141
  idempotencyKey: resolvedIdempotencyKey,
@@ -18,6 +18,7 @@
18
18
  import fs from "node:fs";
19
19
  import { setTimeout as sleep } from "node:timers/promises";
20
20
 
21
+ import { CredentialDestinationRefused, credentialedRequest } from "../auth/credential-destinations.js";
21
22
  import { resolveSessionPaths } from "./paths.js";
22
23
  import { readStream } from "./stream.js";
23
24
  import {
@@ -155,12 +156,12 @@ export async function* watchLocalStream({
155
156
  export async function* watchRemoteStream({
156
157
  apiBaseUrl,
157
158
  sessionId,
158
- token,
159
+ credential,
159
160
  signal,
160
161
  _sseFetch = fetch,
161
162
  reconnectBackoffMs = DEFAULT_RECONNECT_BACKOFF_MS,
162
163
  } = {}) {
163
- if (!apiBaseUrl || !sessionId || !token) return;
164
+ if (!apiBaseUrl || !sessionId || !credential) return;
164
165
  const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/sessions/${encodeURIComponent(
165
166
  sessionId,
166
167
  )}/stream`;
@@ -182,17 +183,16 @@ export async function* watchRemoteStream({
182
183
  ? `${endpoint}?fromSequence=${encodeURIComponent(String(resumeSequence))}`
183
184
  : endpoint;
184
185
  try {
185
- response = await _sseFetch(url, {
186
- method: "GET",
187
- headers: {
188
- Authorization: `Bearer ${token}`,
189
- Accept: "text/event-stream",
190
- },
191
- signal,
192
- });
186
+ response = await credentialedRequest(
187
+ credential,
188
+ url,
189
+ { method: "GET", headers: { Accept: "text/event-stream" }, signal },
190
+ { fetchImpl: _sseFetch },
191
+ );
193
192
  } catch (err) {
194
193
  if (signal?.aborted) return;
195
194
  yield { source: "sse", error: String(err?.message || err) };
195
+ if (err instanceof CredentialDestinationRefused) return; // final: no reconnect can change it
196
196
  await sleep(backoff);
197
197
  backoff = Math.min(backoff * 2, MAX_RECONNECT_BACKOFF_MS);
198
198
  continue;
@@ -267,7 +267,7 @@ export async function* mergeLiveSources({
267
267
  sessionId,
268
268
  targetPath,
269
269
  apiBaseUrl,
270
- token,
270
+ credential,
271
271
  signal,
272
272
  _localIterator,
273
273
  _remoteIterator,
@@ -278,10 +278,10 @@ export async function* mergeLiveSources({
278
278
  ? _localIterator
279
279
  : watchLocalStream({ sessionId, targetPath, signal });
280
280
  const remoteIterable =
281
- _remoteIterator || (apiBaseUrl && token)
281
+ _remoteIterator || (apiBaseUrl && credential)
282
282
  ? _remoteIterator
283
283
  ? _remoteIterator
284
- : watchRemoteStream({ apiBaseUrl, sessionId, token, signal })
284
+ : watchRemoteStream({ apiBaseUrl, sessionId, credential, signal })
285
285
  : null;
286
286
 
287
287
  const seen = new Map();