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.
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/agents/jules/tools/aidenid-email.js +7 -9
- package/src/agents/jules/tools/frontend-analyze.js +15 -3
- package/src/agents/jules/tools/runtime-audit.js +51 -21
- package/src/agents/shared-tools/shell.js +9 -0
- package/src/ai/aidenid.js +5 -4
- package/src/ai/proxy.js +15 -14
- package/src/audit/orchestrator.js +8 -5
- package/src/auth/admission-scope.js +5 -3
- package/src/auth/credential-destinations.js +221 -0
- package/src/auth/gate.js +4 -4
- package/src/auth/http.js +22 -5
- package/src/auth/service.js +52 -56
- package/src/cli.js +24 -6
- package/src/commands/ai/provision-governance.js +7 -6
- package/src/commands/init.js +25 -16
- package/src/commands/scan.js +6 -4
- package/src/commands/session.js +33 -45
- package/src/commands/spec.js +2 -2
- package/src/commands/watch.js +4 -3
- package/src/config/service.js +37 -0
- package/src/daemon/ast-parser-layer.js +8 -3
- package/src/daemon/callgraph-overlay.js +7 -2
- package/src/daemon/pulse.js +2 -1
- package/src/daemon/watchdog.js +117 -68
- package/src/legacy-cli.js +119 -39
- package/src/mcp/bridge-inputs.js +223 -0
- package/src/mcp/cli-command-tools.js +157 -7
- package/src/mcp/registry.js +2 -1
- package/src/mcp/session-stdio-server.js +114 -35
- package/src/mcp/smoke.js +27 -11
- package/src/mcp/token-service.js +7 -9
- package/src/memory/retrieval.js +14 -5
- package/src/pocket/ring-owner.js +13 -8
- package/src/program-lookup.js +54 -0
- package/src/review/ai-review.js +2 -2
- package/src/review/dd-report-email-client.js +4 -5
- package/src/review/local-review.js +11 -5
- package/src/session/admission-access.js +13 -10
- package/src/session/admission-auth.js +95 -16
- package/src/session/admission.js +44 -9
- package/src/session/checkpoints.js +11 -10
- package/src/session/file-locks.js +19 -18
- package/src/session/invitations.js +26 -19
- package/src/session/live-source.js +13 -13
- package/src/session/route-classes.js +107 -0
- package/src/session/sync.js +70 -56
- package/src/session/tickets.js +21 -19
- package/src/session/title-sync.js +4 -3
- 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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/auth/service.js
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
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,
|
|
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
|
-
|
|
378
|
+
credential,
|
|
379
|
+
headers: withFlowRequestHeaders({}, flowRequestId),
|
|
389
380
|
}
|
|
390
381
|
);
|
|
391
382
|
}
|
|
392
383
|
|
|
393
384
|
async function issueApiToken({
|
|
394
385
|
apiUrl,
|
|
395
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
570
|
+
approval.user || (await fetchCurrentUser({ apiUrl, credential: approvalCredential, flowRequestId }))
|
|
580
571
|
);
|
|
581
572
|
const issuedApiToken = await issueApiToken({
|
|
582
573
|
apiUrl,
|
|
583
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
691
|
+
const configuredToken = String(config.layers.global.sentinelayerToken || "").trim();
|
|
697
692
|
if (configuredToken) {
|
|
698
693
|
return {
|
|
699
694
|
apiUrl,
|
|
700
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1105
|
+
credential,
|
|
1110
1106
|
}
|
|
1111
1107
|
);
|
|
1112
1108
|
}
|