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
|
@@ -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 {
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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") ||
|
|
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")
|
|
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(
|
package/src/session/admission.js
CHANGED
|
@@ -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
|
|
370
|
+
if (!isAuthenticated(auth) || !auth?.apiUrl) {
|
|
337
371
|
throw new Error("Not authenticated. Run `sl auth login` first.");
|
|
338
372
|
}
|
|
339
|
-
return {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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",
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
509
|
+
const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
|
|
509
510
|
const response = await request(leaseCollectionUrl(apiUrl, normalizedSessionId), {
|
|
510
511
|
method: "GET",
|
|
511
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
sessionId,
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
159
|
+
credential,
|
|
159
160
|
signal,
|
|
160
161
|
_sseFetch = fetch,
|
|
161
162
|
reconnectBackoffMs = DEFAULT_RECONNECT_BACKOFF_MS,
|
|
162
163
|
} = {}) {
|
|
163
|
-
if (!apiBaseUrl || !sessionId || !
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 &&
|
|
281
|
+
_remoteIterator || (apiBaseUrl && credential)
|
|
282
282
|
? _remoteIterator
|
|
283
283
|
? _remoteIterator
|
|
284
|
-
: watchRemoteStream({ apiBaseUrl, sessionId,
|
|
284
|
+
: watchRemoteStream({ apiBaseUrl, sessionId, credential, signal })
|
|
285
285
|
: null;
|
|
286
286
|
|
|
287
287
|
const seen = new Map();
|