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
@@ -0,0 +1,221 @@
1
+ // credential-destinations.js — the user's credentials, and the one way to send them.
2
+ //
3
+ // A credential is an opaque object, { origin, source }, and only this module makes one. The
4
+ // token it carries is held here, out of reach: it is not a property of the credential or of any
5
+ // auth result, so code elsewhere cannot read it, copy it into a header, a URL, a body or a
6
+ // cookie, or hand it to a child process. credentialedRequest is the one thing that uses it.
7
+ //
8
+ // Its origin is fixed when it is minted, from ONE trust context (resolveTrustContext) built from
9
+ // the environment and the user's global config:
10
+ //
11
+ // - the configured API: SENTINELAYER_API_URL, else `apiUrl` in ~/.sentinelayer/config.yml,
12
+ // else the default API
13
+ // - the pocket gateway: SENTI_POCKET_URL, when set (POCKET_GATEWAY_URL is not read)
14
+ //
15
+ // The CLI entry resolves that context once, at start, and freezes it (freezeProcessTrustContext),
16
+ // so nothing that changes process.env later can move it. Login, auth resolution and the request
17
+ // check all use it; tests inject their own env and homeDir. Nothing a command is given (an
18
+ // option, an argument, an MCP tool input, a workspace's .sentinelayer.yml) can change where a
19
+ // credential goes. An agent's admission credential is bound to the API that issued it
20
+ // (src/auth/admission-scope.js) and is never re-bound to the pocket gateway.
21
+ //
22
+ // credentialedRequest(credential, url, init, { fetchImpl }) is the one way to send a credential.
23
+ // It refuses a URL on any other origin, sets the Authorization header itself, and never follows
24
+ // a redirect. An injected transport (fetchImpl) is what it calls once those checks pass. A bare
25
+ // token from an injected auth resolver (tests) is bound to the configured API by credentialFor.
26
+ // tests/unit.credential-census.test.mjs fails on any other place in src/ that builds an
27
+ // Authorization header, reads a raw token, or writes a trust-source variable into process.env.
28
+
29
+ import { createHmac } from "node:crypto";
30
+ import os from "node:os";
31
+ import process from "node:process";
32
+
33
+ import { readConfigFile } from "../config/io.js";
34
+ import { getGlobalConfigPath } from "../config/paths.js";
35
+
36
+ export const DEFAULT_API_URL = "https://api.sentinelayer.com";
37
+
38
+ const tokens = new WeakMap(); // credential -> token; the only place a token is kept
39
+ const contexts = new WeakSet(); // trust contexts this module built
40
+ const USER_SOURCES = new Set(["user", "env", "config", "session"]); // the person's own token
41
+ let processContext = null; // frozen at the CLI entry
42
+
43
+ export class CredentialDestinationRefused extends Error {
44
+ constructor(origin, trusted = []) {
45
+ super(
46
+ `Refusing to send your SentinelLayer credential to ${origin}: it is only sent to ` +
47
+ `${trusted.filter(Boolean).join(", ") || "the configured API"}. Custom API origins must be configured: set ` +
48
+ `SENTINELAYER_API_URL or SENTI_POCKET_URL (or \`apiUrl\` in ~/.sentinelayer/config.yml).`,
49
+ );
50
+ this.name = "CredentialDestinationRefused";
51
+ this.code = "CREDENTIAL_DESTINATION_REFUSED";
52
+ this.origin = origin;
53
+ }
54
+ }
55
+
56
+ function originOf(value) {
57
+ try {
58
+ const parsed = new URL(String(value || "").trim());
59
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : "";
60
+ } catch {
61
+ return "";
62
+ }
63
+ }
64
+
65
+ function mint(token, origin, source) {
66
+ const value = String(token ?? "").trim();
67
+ if (!value || !origin) return null;
68
+ const credential = Object.freeze({ origin, source });
69
+ tokens.set(credential, value);
70
+ return credential;
71
+ }
72
+
73
+ /** Whether a value is a credential this module minted. */
74
+ export function isCredential(value) {
75
+ return tokens.has(value);
76
+ }
77
+
78
+ /** Whether an auth result carries a credential (or, from an injected resolver, a bare token). */
79
+ export function isAuthenticated(auth) {
80
+ return isCredential(auth?.credential) || Boolean(String(auth?.token ?? "").trim());
81
+ }
82
+
83
+ async function buildTrustContext({ env, homeDir }) {
84
+ let apiUrl = String(env.SENTINELAYER_API_URL || "").trim();
85
+ if (!apiUrl) {
86
+ const globalConfig = await readConfigFile(getGlobalConfigPath({ homeDir })).catch(() => ({}));
87
+ apiUrl = String(globalConfig?.apiUrl || "").trim() || DEFAULT_API_URL;
88
+ }
89
+ const context = Object.freeze({
90
+ apiOrigin: originOf(apiUrl),
91
+ gatewayOrigin: originOf(env.SENTI_POCKET_URL),
92
+ });
93
+ contexts.add(context);
94
+ return context;
95
+ }
96
+
97
+ /**
98
+ * Resolve the process trust context once, from the real environment and global config, and
99
+ * freeze it: from then on, process.env changes do not move it. Called first by the CLI entry.
100
+ */
101
+ export async function freezeProcessTrustContext() {
102
+ if (!processContext) {
103
+ const env = { SENTINELAYER_API_URL: process.env.SENTINELAYER_API_URL, SENTI_POCKET_URL: process.env.SENTI_POCKET_URL };
104
+ processContext = await buildTrustContext({ env, homeDir: os.homedir() });
105
+ }
106
+ return processContext;
107
+ }
108
+
109
+ /**
110
+ * THE trust context: where the user's credentials may be sent. Built from the environment and
111
+ * the global config only. For the process environment, once frozen, it is the frozen context.
112
+ */
113
+ export async function resolveTrustContext({ env = process.env, homeDir } = {}) {
114
+ if (processContext && env === process.env && homeDir === undefined) return processContext;
115
+ return buildTrustContext({ env, homeDir });
116
+ }
117
+
118
+ async function trustOf({ context, env, homeDir }) {
119
+ if (context === undefined) return resolveTrustContext({ env, homeDir });
120
+ if (!contexts.has(context)) throw new TypeError("A trust context from resolveTrustContext is required.");
121
+ return context;
122
+ }
123
+
124
+ /** A credential for one of the user's tokens, bound to the configured API. */
125
+ export async function userCredential(token, { context, env, homeDir, source = "user" } = {}) {
126
+ return mint(token, (await trustOf({ context, env, homeDir })).apiOrigin, source);
127
+ }
128
+
129
+ /**
130
+ * The person's own token bound to the configured pocket gateway; null when none is configured.
131
+ * An agent's admission credential, or any token minted for one purpose, is never re-bound.
132
+ */
133
+ export async function gatewayCredential(credential, { context, env, homeDir } = {}) {
134
+ if (!isCredential(credential)) throw new TypeError("gatewayCredential needs a credential.");
135
+ const trust = await trustOf({ context, env, homeDir });
136
+ if (!USER_SOURCES.has(credential.source)) {
137
+ throw new CredentialDestinationRefused(trust.gatewayOrigin || "the pocket gateway", [credential.origin]);
138
+ }
139
+ return mint(tokens.get(credential), trust.gatewayOrigin, "pocket_gateway");
140
+ }
141
+
142
+ /** An agent's admission credential, bound to the API that issued it. */
143
+ export function admissionCredential({ token, apiUrl } = {}) {
144
+ return mint(token, originOf(apiUrl), "session_admission");
145
+ }
146
+
147
+ /**
148
+ * The credential for an auth result: its own, or, for a bare token from an injected resolver,
149
+ * the token bound to the configured API. Never bound to an origin the caller supplies.
150
+ */
151
+ export async function credentialFor(auth, { context, env, homeDir } = {}) {
152
+ if (isCredential(auth?.credential)) return auth.credential;
153
+ if (!String(auth?.token ?? "").trim()) return null;
154
+ return userCredential(auth.token, { context, env, homeDir, source: auth.source || "user" });
155
+ }
156
+
157
+ /** Refuse an API URL outside the trust context, before any credential is minted for it. */
158
+ export async function assertTrustedApiUrl(url, { context, env, homeDir } = {}) {
159
+ const trust = await trustOf({ context, env, homeDir });
160
+ const origin = originOf(url) || String(url);
161
+ if (origin !== trust.apiOrigin) throw new CredentialDestinationRefused(origin, [trust.apiOrigin]);
162
+ }
163
+
164
+ /** Refuse sending `credential` anywhere but its own origin. `target` is the exact URL string sent. */
165
+ export function assertCredentialDestination(credential, target) {
166
+ if (!isCredential(credential)) throw new TypeError("A credential from src/auth/credential-destinations.js is required.");
167
+ const origin = originOf(target);
168
+ if (origin !== credential.origin) throw new CredentialDestinationRefused(origin || String(target), [credential.origin]);
169
+ }
170
+
171
+ /**
172
+ * An injectable request function (requestJson-shaped: (url, options)), checked first: a
173
+ * credential in its options goes nowhere but its own origin, whichever implementation runs.
174
+ */
175
+ export function checkedTransport(requestImpl) {
176
+ return async (url, options, ...rest) => {
177
+ const target = String(url); // checked and passed on as the same string
178
+ if (options?.credential) assertCredentialDestination(options.credential, target);
179
+ return requestImpl(target, options, ...rest);
180
+ };
181
+ }
182
+
183
+ function headersWithout(headers, name) {
184
+ const entries = headers instanceof Headers ? [...headers.entries()] : Object.entries(headers || {});
185
+ return Object.fromEntries(entries.filter(([key]) => String(key).toLowerCase() !== name));
186
+ }
187
+
188
+ /**
189
+ * Send a request carrying `credential`: only to the credential's own origin, with the
190
+ * Authorization header set here, and without following redirects. `fetchImpl` is the transport
191
+ * it calls once those checks pass. The URL is turned into a string once, and that string is both
192
+ * checked and sent.
193
+ */
194
+ export async function credentialedRequest(credential, url, init = {}, { fetchImpl = globalThis.fetch } = {}) {
195
+ const target = String(url);
196
+ assertCredentialDestination(credential, target);
197
+ return fetchImpl(target, {
198
+ ...init,
199
+ headers: { ...headersWithout(init.headers, "authorization"), Authorization: `Bearer ${tokens.get(credential)}` },
200
+ redirect: "error",
201
+ });
202
+ }
203
+
204
+ /** An HMAC-SHA256 of `message` keyed by the credential's token (a request proof; never the token). */
205
+ export function credentialHmac(credential, message) {
206
+ if (!isCredential(credential)) throw new TypeError("A credential from src/auth/credential-destinations.js is required.");
207
+ return createHmac("sha256", tokens.get(credential)).update(String(message), "utf8").digest("hex");
208
+ }
209
+
210
+ /**
211
+ * The token itself, for the one reviewed export: the operator command `sl scan setup-secrets`,
212
+ * which writes it to a GitHub Actions secret through the gh CLI. Nothing else may call this
213
+ * (tests/unit.credential-census.test.mjs).
214
+ */
215
+ export function exportCredentialToken(credential, { purpose } = {}) {
216
+ if (purpose !== "github-actions-secret") throw new TypeError("exportCredentialToken: unknown purpose.");
217
+ if (!isCredential(credential) || !USER_SOURCES.has(credential.source)) {
218
+ throw new TypeError("exportCredentialToken needs the person's own credential.");
219
+ }
220
+ return tokens.get(credential);
221
+ }
package/src/auth/gate.js CHANGED
@@ -7,6 +7,7 @@ import pc from "picocolors";
7
7
  import { resolveActiveAuthSession } from "./service.js";
8
8
  import { resolveAgentAdmissionTarget } from "../session/admission-auth.js";
9
9
  import { authLoginHint } from "../ui/command-hints.js";
10
+ import { isAuthenticated } from "./credential-destinations.js";
10
11
 
11
12
  /**
12
13
  * Auth gate — ensures user is logged in before running any command.
@@ -316,12 +317,11 @@ function isSessionUnexpired(tokenExpiresAt) {
316
317
  // without fixing anything.
317
318
  //
318
319
  // So the gate checks:
319
- // - session.token is present and non-empty
320
+ // - the session carries a credential
320
321
  // - for source === "session", expiry is in the future
321
322
  // - for source === "env" or "config", the downstream API call is the gate
322
323
  function isAuthenticatedSessionValid(session) {
323
- const token = String(session?.token || "").trim();
324
- if (!token) {
324
+ if (!isAuthenticated(session)) {
325
325
  return false;
326
326
  }
327
327
  if (String(session?.source || "").trim() === "session") {
@@ -369,7 +369,7 @@ export async function checkAuthGate(args) {
369
369
  }
370
370
  if (session) {
371
371
  // Session resolved but failed validation (empty token or expired).
372
- const tokenPresent = Boolean(String(session?.token || "").trim());
372
+ const tokenPresent = isAuthenticated(session);
373
373
  if (!tokenPresent) {
374
374
  resolveError = "session_token_missing";
375
375
  } else if (String(session?.source || "").trim() === "session" && !isSessionUnexpired(session?.tokenExpiresAt)) {
package/src/auth/http.js CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { installTestEgressGuard } from "../net/test-egress-guard.js";
7
+ import { CredentialDestinationRefused, credentialedRequest, isCredential } from "./credential-destinations.js";
7
8
 
8
9
  // No-op outside tests; see src/net/test-egress-guard.js.
9
10
  installTestEgressGuard();
@@ -20,7 +21,10 @@ export const CIRCUIT_BREAKER_THRESHOLD = 5;
20
21
  export const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
21
22
 
22
23
  const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
23
- const CIRCUIT_TRACK_STATUS_CODES = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
24
+ // 401 and 403 are not tracked: they refuse one credential (for example an expired or
25
+ // revoked agent admission) and say nothing about the origin's health, so they must not
26
+ // open the per-origin breaker for every other credential on this machine.
27
+ const CIRCUIT_TRACK_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
24
28
  const circuitStateByScope = new Map();
25
29
 
26
30
  // File-backed cache. Lets cooldown + failure counts survive across CLI
@@ -422,7 +426,8 @@ export function __resetRequestCircuitForTests(scope) {
422
426
  * maxRetries?: number,
423
427
  * retryDelayMs?: number
424
428
  * allowEmptyBody?: boolean
425
- * }} [options]
429
+ * credential?: object
430
+ * }} [options] `credential` (src/auth/credential-destinations.js) is sent with credentialedRequest.
426
431
  * @returns {Promise<any>}
427
432
  */
428
433
  export async function requestJson(
@@ -430,6 +435,7 @@ export async function requestJson(
430
435
  {
431
436
  method = "GET",
432
437
  headers = {},
438
+ credential = null,
433
439
  body,
434
440
  idempotencyKey = null,
435
441
  allowNonIdempotent = false,
@@ -439,6 +445,13 @@ export async function requestJson(
439
445
  allowEmptyBody = false,
440
446
  } = {}
441
447
  ) {
448
+ if (credential && !isCredential(credential)) {
449
+ throw new TypeError("requestJson: credential must come from src/auth/credential-destinations.js.");
450
+ }
451
+ // Without a credential, nothing here attaches one: a caller-built Authorization header is refused.
452
+ if (Object.keys(headers || {}).some((name) => /^(?:proxy-)?authorization$/i.test(name))) {
453
+ throw new TypeError("requestJson: send a credential with { credential }, not an Authorization header.");
454
+ }
442
455
  const normalizedMethod = String(method || "GET").trim().toUpperCase();
443
456
  const explicitIdempotencyKey = String(idempotencyKey || "").trim() || null;
444
457
  const existingIdempotencyKey = explicitIdempotencyKey || resolveIdempotencyKey(headers);
@@ -492,13 +505,14 @@ export async function requestJson(
492
505
  }, normalizedTimeoutMs);
493
506
 
494
507
  try {
495
- const response = await fetch(String(url), {
508
+ const init = {
496
509
  method: normalizedMethod,
497
510
  headers: outgoingHeaders,
498
511
  body: body === undefined ? undefined : JSON.stringify(body),
499
512
  redirect: "error",
500
513
  signal: controller.signal,
501
- });
514
+ };
515
+ const response = credential ? await credentialedRequest(credential, url, init) : await fetch(String(url), init);
502
516
 
503
517
  const rawBody = await response.text();
504
518
  const trimmedBody = rawBody.trim();
@@ -575,7 +589,8 @@ export async function requestJson(
575
589
  await sleep(delayMs);
576
590
  continue;
577
591
  } catch (error) {
578
- if (error instanceof SentinelayerApiError) {
592
+ // A refused destination is final: nothing was sent, so no retry and no breaker count.
593
+ if (error instanceof SentinelayerApiError || error instanceof CredentialDestinationRefused) {
579
594
  throw error;
580
595
  }
581
596
 
@@ -647,6 +662,7 @@ export async function requestJsonMutation(
647
662
  {
648
663
  method = "POST",
649
664
  headers = {},
665
+ credential = null,
650
666
  body,
651
667
  idempotencyKey = null,
652
668
  operationName,
@@ -675,6 +691,7 @@ export async function requestJsonMutation(
675
691
  return requestJson(url, {
676
692
  method: normalizedMethod,
677
693
  headers,
694
+ credential,
678
695
  body,
679
696
  idempotencyKey: resolvedIdempotencyKey,
680
697
  timeoutMs,
@@ -6,6 +6,14 @@ import open from "open";
6
6
 
7
7
  import { loadConfig } from "../config/service.js";
8
8
  import { scopedAdmissionAuth } from "./admission-scope.js";
9
+ import {
10
+ DEFAULT_API_URL,
11
+ assertTrustedApiUrl,
12
+ credentialFor,
13
+ isAuthenticated,
14
+ resolveTrustContext,
15
+ userCredential,
16
+ } from "./credential-destinations.js";
9
17
  import { SentinelayerApiError, requestJson, requestJsonMutation } from "./http.js";
10
18
  import {
11
19
  clearStoredSession,
@@ -15,7 +23,6 @@ import {
15
23
  } from "./session-store.js";
16
24
  import { authLoginHint } from "../ui/command-hints.js";
17
25
 
18
- const DEFAULT_API_URL = "https://api.sentinelayer.com";
19
26
  /** Default maximum wall-clock wait for browser-based CLI auth approval (ms). */
20
27
  export const DEFAULT_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
21
28
  /** Default lifetime for issued API tokens used by CLI sessions (days). */
@@ -57,12 +64,6 @@ function normalizePositiveNumber(rawValue, field, fallbackValue) {
57
64
  return normalized;
58
65
  }
59
66
 
60
- function toAuthHeader(token) {
61
- return {
62
- Authorization: `Bearer ${String(token || "").trim()}`,
63
- };
64
- }
65
-
66
67
  function normalizeUser(user = {}) {
67
68
  return {
68
69
  id: String(user.id || "").trim(),
@@ -171,7 +172,6 @@ function isNearExpiry(tokenExpiresAt, thresholdDays) {
171
172
  * @returns {Promise<string>}
172
173
  */
173
174
  export async function resolveApiUrl({
174
- cwd = process.cwd(),
175
175
  env = process.env,
176
176
  explicitApiUrl = "",
177
177
  homeDir,
@@ -180,19 +180,9 @@ export async function resolveApiUrl({
180
180
  if (overrideUrl) {
181
181
  return normalizeApiUrl(overrideUrl);
182
182
  }
183
-
184
- const envUrl = String(env.SENTINELAYER_API_URL || "").trim();
185
- if (envUrl) {
186
- return normalizeApiUrl(envUrl);
187
- }
188
-
189
- const config = await loadConfig({ cwd, env, homeDir });
190
- const configuredApiUrl = String(config.resolved.apiUrl || "").trim();
191
- if (configuredApiUrl) {
192
- return normalizeApiUrl(configuredApiUrl);
193
- }
194
-
195
- return normalizeApiUrl(DEFAULT_API_URL);
183
+ // The trust context's API (SENTINELAYER_API_URL, else the global config, else the default). A
184
+ // workspace .sentinelayer.yml does not choose the API.
185
+ return normalizeApiUrl((await resolveTrustContext({ env, homeDir })).apiOrigin);
196
186
  }
197
187
 
198
188
  async function startCliAuthSession({ apiUrl, challenge, ide, cliVersion, flowRequestId }) {
@@ -379,20 +369,21 @@ async function pollCliAuthSession({
379
369
  });
380
370
  }
381
371
 
382
- async function fetchCurrentUser({ apiUrl, token, flowRequestId }) {
372
+ async function fetchCurrentUser({ apiUrl, credential, flowRequestId }) {
383
373
  return requestAuthJson(
384
374
  flowRequestId,
385
375
  buildApiPath(apiUrl, "/api/v1/auth/me"),
386
376
  {
387
377
  method: "GET",
388
- headers: withFlowRequestHeaders(toAuthHeader(token), flowRequestId),
378
+ credential,
379
+ headers: withFlowRequestHeaders({}, flowRequestId),
389
380
  }
390
381
  );
391
382
  }
392
383
 
393
384
  async function issueApiToken({
394
385
  apiUrl,
395
- authToken,
386
+ credential,
396
387
  tokenLabel,
397
388
  tokenTtlDays,
398
389
  flowRequestId,
@@ -406,12 +397,8 @@ async function issueApiToken({
406
397
  {
407
398
  method: "POST",
408
399
  operationName: "issue-token",
409
- headers: withFlowRequestHeaders(
410
- {
411
- ...toAuthHeader(authToken),
412
- },
413
- flowRequestId
414
- ),
400
+ credential,
401
+ headers: withFlowRequestHeaders({}, flowRequestId),
415
402
  body: {
416
403
  label: String(tokenLabel || "").trim() || defaultTokenLabel(),
417
404
  scope: "cli",
@@ -422,7 +409,7 @@ async function issueApiToken({
422
409
  );
423
410
  }
424
411
 
425
- async function revokeApiToken({ apiUrl, authToken, tokenId }) {
412
+ async function revokeApiToken({ apiUrl, credential, tokenId }) {
426
413
  const normalizedTokenId = String(tokenId || "").trim();
427
414
  if (!normalizedTokenId) {
428
415
  return false;
@@ -430,9 +417,7 @@ async function revokeApiToken({ apiUrl, authToken, tokenId }) {
430
417
  await requestJsonMutation(buildApiPath(apiUrl, `/api/v1/auth/api-tokens/${encodeURIComponent(normalizedTokenId)}`), {
431
418
  method: "DELETE",
432
419
  operationName: "revoke-token",
433
- headers: {
434
- ...toAuthHeader(authToken),
435
- },
420
+ credential,
436
421
  });
437
422
  return true;
438
423
  }
@@ -443,6 +428,7 @@ async function rotateStoredApiTokenIfNeeded({
443
428
  tokenLabel,
444
429
  tokenTtlDays,
445
430
  homeDir,
431
+ context,
446
432
  }) {
447
433
  if (!session || !session.token || !session.tokenExpiresAt) {
448
434
  return { session, rotated: false };
@@ -453,7 +439,7 @@ async function rotateStoredApiTokenIfNeeded({
453
439
 
454
440
  const issued = await issueApiToken({
455
441
  apiUrl: session.apiUrl,
456
- authToken: session.token,
442
+ credential: await userCredential(session.token, { context, source: "session" }),
457
443
  tokenLabel,
458
444
  tokenTtlDays,
459
445
  });
@@ -475,7 +461,7 @@ async function rotateStoredApiTokenIfNeeded({
475
461
  try {
476
462
  await revokeApiToken({
477
463
  apiUrl: session.apiUrl,
478
- authToken: nextSession.token,
464
+ credential: await userCredential(nextSession.token, { context, source: "session" }),
479
465
  tokenId: session.tokenId,
480
466
  });
481
467
  } catch (error) {
@@ -536,6 +522,10 @@ export async function loginAndPersistSession({
536
522
  homeDir,
537
523
  } = {}) {
538
524
  const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
525
+ // The token this flow issues is the user's, so the API that issues it must be the configured
526
+ // one: refuse any other before the browser step. Resolution and trust share env and homeDir.
527
+ const context = await resolveTrustContext({ env, homeDir });
528
+ await assertTrustedApiUrl(apiUrl, { context });
539
529
  const challenge = generateChallenge();
540
530
  const flowRequestId = createFlowRequestId();
541
531
  const session = await startCliAuthSession({
@@ -574,13 +564,14 @@ export async function loginAndPersistSession({
574
564
  requestId: flowRequestId || null,
575
565
  });
576
566
  }
567
+ const approvalCredential = await userCredential(approvalToken, { context, source: "login_approval" });
577
568
 
578
569
  const user = normalizeUser(
579
- approval.user || (await fetchCurrentUser({ apiUrl, token: approvalToken, flowRequestId }))
570
+ approval.user || (await fetchCurrentUser({ apiUrl, credential: approvalCredential, flowRequestId }))
580
571
  );
581
572
  const issuedApiToken = await issueApiToken({
582
573
  apiUrl,
583
- authToken: approvalToken,
574
+ credential: approvalCredential,
584
575
  tokenLabel,
585
576
  tokenTtlDays,
586
577
  flowRequestId,
@@ -641,7 +632,7 @@ export async function loginAndPersistSession({
641
632
  * }} [options]
642
633
  * @returns {Promise<null | {
643
634
  * apiUrl: string,
644
- * token: string,
635
+ * credential: object,
645
636
  * source: "env" | "config" | "session",
646
637
  * user: {
647
638
  * id: string,
@@ -674,12 +665,15 @@ export async function resolveActiveAuthSession({
674
665
  if (scoped !== undefined) return scoped;
675
666
 
676
667
  const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
668
+ // Each token below is bound to the configured API by the one trust context; `apiUrl` may name
669
+ // another origin (--api-url, a project config), and then a request carrying it is refused.
670
+ const context = await resolveTrustContext({ env, homeDir });
677
671
 
678
672
  const envToken = String(env.SENTINELAYER_TOKEN || "").trim();
679
673
  if (envToken) {
680
674
  return {
681
675
  apiUrl,
682
- token: envToken,
676
+ credential: await userCredential(envToken, { context, source: "env" }),
683
677
  source: "env",
684
678
  user: null,
685
679
  aidenid: null,
@@ -692,12 +686,13 @@ export async function resolveActiveAuthSession({
692
686
  };
693
687
  }
694
688
 
689
+ // The user's own config only: a workspace .sentinelayer.yml does not choose whose token is used.
695
690
  const config = await loadConfig({ cwd, env, homeDir });
696
- const configuredToken = String(config.resolved.sentinelayerToken || "").trim();
691
+ const configuredToken = String(config.layers.global.sentinelayerToken || "").trim();
697
692
  if (configuredToken) {
698
693
  return {
699
694
  apiUrl,
700
- token: configuredToken,
695
+ credential: await userCredential(configuredToken, { context, source: "config" }),
701
696
  source: "config",
702
697
  user: null,
703
698
  aidenid: null,
@@ -725,6 +720,7 @@ export async function resolveActiveAuthSession({
725
720
  tokenLabel,
726
721
  tokenTtlDays,
727
722
  homeDir,
723
+ context,
728
724
  });
729
725
  active = rotateResult.session;
730
726
  rotated = rotateResult.rotated;
@@ -743,7 +739,7 @@ export async function resolveActiveAuthSession({
743
739
 
744
740
  return {
745
741
  apiUrl,
746
- token: active.token,
742
+ credential: await userCredential(active.token, { context, source: "session" }),
747
743
  source: "session",
748
744
  user: normalizeUser(active.user || {}),
749
745
  aidenid: active.aidenid || null,
@@ -829,7 +825,7 @@ export async function getAuthStatus({
829
825
  let remoteError = null;
830
826
  if (checkRemote) {
831
827
  try {
832
- remoteUser = normalizeUser(await fetchCurrentUser({ apiUrl: session.apiUrl, token: session.token }));
828
+ remoteUser = normalizeUser(await fetchCurrentUser({ apiUrl: session.apiUrl, credential: session.credential }));
833
829
  } catch (error) {
834
830
  remoteError =
835
831
  error instanceof SentinelayerApiError
@@ -938,7 +934,7 @@ export async function revokeAuthToken({
938
934
  autoRotate: false,
939
935
  homeDir,
940
936
  });
941
- if (!active || !active.token) {
937
+ if (!isAuthenticated(active)) {
942
938
  throw new SentinelayerApiError(`No active auth token found. Run \`${authLoginHint()}\` first.`, {
943
939
  status: 401,
944
940
  code: "AUTH_REQUIRED",
@@ -954,7 +950,7 @@ export async function revokeAuthToken({
954
950
 
955
951
  await revokeApiToken({
956
952
  apiUrl: active.apiUrl,
957
- authToken: active.token,
953
+ credential: active.credential,
958
954
  tokenId: targetTokenId,
959
955
  });
960
956
 
@@ -1020,7 +1016,7 @@ export async function logoutSession({
1020
1016
  try {
1021
1017
  await revokeApiToken({
1022
1018
  apiUrl: await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir }),
1023
- authToken: stored.token,
1019
+ credential: await userCredential(stored.token, { env, homeDir, source: "session" }),
1024
1020
  tokenId: stored.tokenId,
1025
1021
  });
1026
1022
  revokedRemote = true;
@@ -1043,15 +1039,15 @@ export async function logoutSession({
1043
1039
  *
1044
1040
  * @param {{
1045
1041
  * apiUrl: string,
1046
- * authToken: string,
1042
+ * credential: object,
1047
1043
  * runId: string,
1048
1044
  * afterEventId?: string | null
1049
- * }} [options]
1045
+ * }} [options] credential: the resolved auth's `credential` (src/auth/credential-destinations.js)
1050
1046
  * @returns {Promise<any>}
1051
1047
  */
1052
1048
  export async function listRuntimeRunEvents({
1053
1049
  apiUrl,
1054
- authToken,
1050
+ credential,
1055
1051
  runId,
1056
1052
  afterEventId = null,
1057
1053
  } = {}) {
@@ -1062,7 +1058,7 @@ export async function listRuntimeRunEvents({
1062
1058
  buildApiPath(apiUrl, `/api/v1/runtime/runs/${encodeURIComponent(String(runId || ""))}/events/list${query}`),
1063
1059
  {
1064
1060
  method: "GET",
1065
- headers: toAuthHeader(authToken),
1061
+ credential,
1066
1062
  }
1067
1063
  );
1068
1064
  }
@@ -1070,7 +1066,7 @@ export async function listRuntimeRunEvents({
1070
1066
  /**
1071
1067
  * Fetch runtime run status snapshot from the Sentinelayer API.
1072
1068
  *
1073
- * @param {{ apiUrl: string, authToken: string, runId: string }} [options]
1069
+ * @param {{ apiUrl: string, credential: object, runId: string }} [options]
1074
1070
  * @returns {Promise<any>}
1075
1071
  */
1076
1072
  /**
@@ -1079,13 +1075,13 @@ export async function listRuntimeRunEvents({
1079
1075
  */
1080
1076
  export async function fetchAidenIdCredentials({
1081
1077
  apiUrl = DEFAULT_API_URL,
1082
- token = "",
1078
+ auth = null,
1083
1079
  } = {}) {
1084
1080
  const response = await requestJson(
1085
1081
  buildApiPath(apiUrl, "/api/v1/aidenid/credentials"),
1086
1082
  {
1087
1083
  method: "GET",
1088
- headers: toAuthHeader(token),
1084
+ credential: await credentialFor(auth),
1089
1085
  }
1090
1086
  );
1091
1087
  return {
@@ -1099,14 +1095,14 @@ export async function fetchAidenIdCredentials({
1099
1095
 
1100
1096
  export async function getRuntimeRunStatus({
1101
1097
  apiUrl,
1102
- authToken,
1098
+ credential,
1103
1099
  runId,
1104
1100
  } = {}) {
1105
1101
  return requestJson(
1106
1102
  buildApiPath(apiUrl, `/api/v1/runtime/runs/${encodeURIComponent(String(runId || ""))}/status`),
1107
1103
  {
1108
1104
  method: "GET",
1109
- headers: toAuthHeader(authToken),
1105
+ credential,
1110
1106
  }
1111
1107
  );
1112
1108
  }