sentinelayer-cli 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -16
- 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/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 +14 -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 +166 -51
- package/src/commands/spec.js +2 -2
- package/src/commands/watch.js +4 -3
- package/src/config/service.js +37 -0
- 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 +160 -7
- package/src/mcp/registry.js +6 -5
- package/src/mcp/session-stdio-server.js +157 -8
- 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/review/ai-review.js +2 -2
- package/src/review/dd-report-email-client.js +4 -5
- package/src/session/admission-access.js +41 -10
- package/src/session/admission-auth.js +95 -16
- package/src/session/admission.js +91 -21
- package/src/session/checkpoints.js +11 -10
- package/src/session/control-events.js +2 -0
- 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/reactions.js +258 -0
- package/src/session/route-classes.js +107 -0
- package/src/session/sync.js +110 -66
- package/src/session/tickets.js +21 -19
- package/src/session/title-sync.js +4 -3
- package/src/telemetry/sync.js +8 -9
|
@@ -8,14 +8,40 @@
|
|
|
8
8
|
|
|
9
9
|
import process from "node:process";
|
|
10
10
|
|
|
11
|
+
import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
|
|
11
12
|
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
12
13
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
14
|
+
import { hasStoredAdmissionCredentials } from "./admission.js";
|
|
15
|
+
import { currentAdmittedAgent } from "./admission-auth.js";
|
|
13
16
|
import {
|
|
14
17
|
SESSION_MUTATION_ORIGIN,
|
|
15
18
|
createSessionMutationHeaders,
|
|
16
19
|
createSessionMutationIdempotencyKey,
|
|
17
20
|
} from "./invitations.js";
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Approve, deny, revoke and mode changes are the room owner's decisions, made with the
|
|
24
|
+
* owner's own session. They are unavailable while this process is an agent context:
|
|
25
|
+
* SENTINELAYER_AGENT_ID is set, an agent admission scope is in force, or this machine
|
|
26
|
+
* stores agent admission credentials. Checked before any credential lookup or request.
|
|
27
|
+
*/
|
|
28
|
+
export async function assertOwnerAccessContext({ env = process.env, homeDir } = {}) {
|
|
29
|
+
let why = "";
|
|
30
|
+
if (normalizeString(env.SENTINELAYER_AGENT_ID)) {
|
|
31
|
+
why = "SENTINELAYER_AGENT_ID is set";
|
|
32
|
+
} else if (currentAdmittedAgent()) {
|
|
33
|
+
why = "an agent admission is in use";
|
|
34
|
+
} else if (await hasStoredAdmissionCredentials({ homeDir })) {
|
|
35
|
+
why = "agent admission credentials are stored on this machine";
|
|
36
|
+
}
|
|
37
|
+
if (why) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Access approve, deny, revoke and mode are owner actions and are unavailable in an agent context (${why}). ` +
|
|
40
|
+
`Use the room's web dashboard, or run the command from an environment without agent credentials.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
export const ADMISSION_STATUSES = Object.freeze([
|
|
20
46
|
"pending",
|
|
21
47
|
"approved",
|
|
@@ -49,11 +75,11 @@ async function authContext(targetPath, resolveAuthSession) {
|
|
|
49
75
|
env: process.env,
|
|
50
76
|
autoRotate: false,
|
|
51
77
|
});
|
|
52
|
-
if (!auth
|
|
78
|
+
if (!isAuthenticated(auth) || !auth?.apiUrl) {
|
|
53
79
|
throw new Error("Not authenticated. Run `sl auth login` first.");
|
|
54
80
|
}
|
|
55
81
|
return {
|
|
56
|
-
|
|
82
|
+
credential: await credentialFor(auth),
|
|
57
83
|
apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, ""),
|
|
58
84
|
};
|
|
59
85
|
}
|
|
@@ -84,12 +110,13 @@ async function mutateAdmission(
|
|
|
84
110
|
) {
|
|
85
111
|
const key = boundedIdempotencyKey(idempotencyKey, operationName);
|
|
86
112
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
87
|
-
const result = await requestMutation(admissionsUrl(auth.apiUrl, sessionId, suffix), {
|
|
113
|
+
const result = await checkedTransport(requestMutation)(admissionsUrl(auth.apiUrl, sessionId, suffix), {
|
|
88
114
|
method: "POST",
|
|
89
115
|
operationName,
|
|
90
116
|
idempotencyKey: key,
|
|
117
|
+
credential: auth.credential,
|
|
91
118
|
headers: createSessionMutationHeaders({
|
|
92
|
-
|
|
119
|
+
credential: auth.credential,
|
|
93
120
|
sessionId,
|
|
94
121
|
routeId,
|
|
95
122
|
idempotencyKey: key,
|
|
@@ -118,9 +145,9 @@ export async function listSessionAdmissions(
|
|
|
118
145
|
}
|
|
119
146
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
120
147
|
const query = normalizedStatus ? `?status=${encodeURIComponent(normalizedStatus)}` : "";
|
|
121
|
-
return requestRead(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
|
|
148
|
+
return checkedTransport(requestRead)(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
|
|
122
149
|
method: "GET",
|
|
123
|
-
|
|
150
|
+
credential: auth.credential,
|
|
124
151
|
});
|
|
125
152
|
}
|
|
126
153
|
|
|
@@ -136,9 +163,9 @@ export async function getSessionAdmission(
|
|
|
136
163
|
const sid = required(sessionId, "session id");
|
|
137
164
|
const aid = required(admissionId, "admission id");
|
|
138
165
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
139
|
-
return requestRead(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
|
|
166
|
+
return checkedTransport(requestRead)(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
|
|
140
167
|
method: "GET",
|
|
141
|
-
|
|
168
|
+
credential: auth.credential,
|
|
142
169
|
});
|
|
143
170
|
}
|
|
144
171
|
|
|
@@ -171,6 +198,7 @@ export async function decideSessionAdmission(
|
|
|
171
198
|
if (ttlSeconds !== undefined) body.ttlSeconds = ttlSeconds;
|
|
172
199
|
const normalizedNote = normalizeString(note);
|
|
173
200
|
if (normalizedNote) body.note = normalizedNote;
|
|
201
|
+
await assertOwnerAccessContext();
|
|
174
202
|
return mutateAdmission(
|
|
175
203
|
sid,
|
|
176
204
|
`/${encodeURIComponent(aid)}/decision`,
|
|
@@ -202,6 +230,7 @@ export async function revokeSessionAdmission(
|
|
|
202
230
|
const sid = required(sessionId, "session id");
|
|
203
231
|
const aid = required(admissionId, "admission id");
|
|
204
232
|
const normalizedReason = normalizeString(reason);
|
|
233
|
+
await assertOwnerAccessContext();
|
|
205
234
|
return mutateAdmission(
|
|
206
235
|
sid,
|
|
207
236
|
`/${encodeURIComponent(aid)}/revoke`,
|
|
@@ -234,16 +263,18 @@ export async function setSessionAdmissionMode(
|
|
|
234
263
|
if (!["legacy", "required"].includes(normalizedMode)) {
|
|
235
264
|
throw new Error("mode must be legacy or required.");
|
|
236
265
|
}
|
|
266
|
+
await assertOwnerAccessContext();
|
|
237
267
|
const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
|
|
238
268
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
239
|
-
const result = await requestMutation(
|
|
269
|
+
const result = await checkedTransport(requestMutation)(
|
|
240
270
|
`${auth.apiUrl}/api/v1/sessions/${encodeURIComponent(sid)}/admission-mode`,
|
|
241
271
|
{
|
|
242
272
|
method: "POST",
|
|
243
273
|
operationName: "session.admission_mode",
|
|
244
274
|
idempotencyKey: key,
|
|
275
|
+
credential: auth.credential,
|
|
245
276
|
headers: createSessionMutationHeaders({
|
|
246
|
-
|
|
277
|
+
credential: auth.credential,
|
|
247
278
|
sessionId: sid,
|
|
248
279
|
routeId: ROUTES.mode,
|
|
249
280
|
idempotencyKey: key,
|
|
@@ -25,7 +25,12 @@ import path from "node:path";
|
|
|
25
25
|
import process from "node:process";
|
|
26
26
|
|
|
27
27
|
import { admittedAgentScope } from "../auth/admission-scope.js";
|
|
28
|
-
import {
|
|
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";
|
|
@@ -63,7 +64,8 @@ const ROUTE = {
|
|
|
63
64
|
claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
|
|
64
65
|
cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
|
|
65
66
|
};
|
|
66
|
-
|
|
67
|
+
// Final admission statuses: polling stops, nothing is claimed, local request state is cleared.
|
|
68
|
+
const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked", "stopped"]);
|
|
67
69
|
const TTL_MIN_SECONDS = 300;
|
|
68
70
|
const TTL_MAX_SECONDS = 86_400;
|
|
69
71
|
|
|
@@ -242,6 +244,67 @@ export function admissionCredentialPath(sessionId, agentId, { homeDir } = {}) {
|
|
|
242
244
|
);
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Whether this machine stores ANY agent admission credential (usable or not, any
|
|
249
|
+
* session): `<home>/.sentinelayer/agents/<agent>/admissions/<session>.json`. Agent keys
|
|
250
|
+
* alone do not count. An unreadable directory counts as present, never as absence.
|
|
251
|
+
*/
|
|
252
|
+
export async function hasStoredAdmissionCredentials({ homeDir } = {}) {
|
|
253
|
+
const agentsDir = path.join(sentinelayerHome(homeDir), "agents");
|
|
254
|
+
let agents;
|
|
255
|
+
try {
|
|
256
|
+
agents = await fsp.readdir(agentsDir, { withFileTypes: true });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error && error.code === "ENOENT") return false;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
for (const agent of agents) {
|
|
262
|
+
if (!agent.isDirectory()) continue;
|
|
263
|
+
let files;
|
|
264
|
+
try {
|
|
265
|
+
files = await fsp.readdir(path.join(agentsDir, agent.name, "admissions"));
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error && error.code === "ENOENT") continue;
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (files.some((name) => name.endsWith(".json"))) return true;
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The agents for which this machine stores an admission credential file for this
|
|
277
|
+
* session, usable or not (`<home>/.sentinelayer/agents/<agent>/admissions/<session>.json`).
|
|
278
|
+
* An unreadable agents directory is reported as a stored entry, never as absence.
|
|
279
|
+
*/
|
|
280
|
+
export async function storedAdmissionAgentsForSession(sessionId, { homeDir } = {}) {
|
|
281
|
+
let sessionFile;
|
|
282
|
+
try {
|
|
283
|
+
sessionFile = `${safeSegment(sessionId, "sessionId")}.json`;
|
|
284
|
+
} catch {
|
|
285
|
+
return []; // not a storable session id, so nothing can be stored for it
|
|
286
|
+
}
|
|
287
|
+
const agentsDir = path.join(sentinelayerHome(homeDir), "agents");
|
|
288
|
+
let agents;
|
|
289
|
+
try {
|
|
290
|
+
agents = await fsp.readdir(agentsDir, { withFileTypes: true });
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error && error.code === "ENOENT") return [];
|
|
293
|
+
return ["(unreadable)"];
|
|
294
|
+
}
|
|
295
|
+
const found = [];
|
|
296
|
+
for (const agent of agents) {
|
|
297
|
+
if (!agent.isDirectory()) continue;
|
|
298
|
+
try {
|
|
299
|
+
await fsp.access(path.join(agentsDir, agent.name, "admissions", sessionFile));
|
|
300
|
+
found.push(agent.name.replace(/%3a/g, ":"));
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (!(error && error.code === "ENOENT")) found.push(agent.name.replace(/%3a/g, ":"));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return found;
|
|
306
|
+
}
|
|
307
|
+
|
|
245
308
|
/**
|
|
246
309
|
* What this machine holds for (session, agent):
|
|
247
310
|
* { state: "none" } -- never admitted here: the legacy path applies
|
|
@@ -304,10 +367,10 @@ function requestFingerprint({ apiUrl, agentId, publicKey, goal, actions, ttlSeco
|
|
|
304
367
|
|
|
305
368
|
async function authContext({ targetPath, resolveAuthSession }) {
|
|
306
369
|
const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
|
|
307
|
-
if (!auth
|
|
370
|
+
if (!isAuthenticated(auth) || !auth?.apiUrl) {
|
|
308
371
|
throw new Error("Not authenticated. Run `sl auth login` first.");
|
|
309
372
|
}
|
|
310
|
-
return {
|
|
373
|
+
return { credential: await credentialFor(auth), apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, "") };
|
|
311
374
|
}
|
|
312
375
|
|
|
313
376
|
function admissionUrl(apiUrl, sessionId, suffix = "") {
|
|
@@ -316,12 +379,13 @@ function admissionUrl(apiUrl, sessionId, suffix = "") {
|
|
|
316
379
|
|
|
317
380
|
async function mutate(auth, sessionId, routeId, url, body, { requestMutation, operationName, origin }) {
|
|
318
381
|
const idempotencyKey = createSessionMutationIdempotencyKey(operationName);
|
|
319
|
-
return requestMutation(url, {
|
|
382
|
+
return checkedTransport(requestMutation)(url, {
|
|
320
383
|
method: "POST",
|
|
321
384
|
operationName,
|
|
322
385
|
idempotencyKey,
|
|
386
|
+
credential: auth.credential,
|
|
323
387
|
headers: createSessionMutationHeaders({
|
|
324
|
-
|
|
388
|
+
credential: auth.credential,
|
|
325
389
|
sessionId,
|
|
326
390
|
routeId,
|
|
327
391
|
idempotencyKey,
|
|
@@ -466,23 +530,29 @@ export async function runAdmissionJoin(
|
|
|
466
530
|
const deadline = now() + Math.max(0, waitTimeoutMs);
|
|
467
531
|
let polled;
|
|
468
532
|
for (;;) {
|
|
469
|
-
polled = await requestRead(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
|
|
533
|
+
polled = await checkedTransport(requestRead)(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
|
|
470
534
|
method: "GET",
|
|
471
|
-
|
|
535
|
+
credential: auth.credential,
|
|
472
536
|
});
|
|
473
537
|
state.status = polled.status;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
538
|
+
// Identity readiness is a server capability: a server that gates the claim on
|
|
539
|
+
// AIdenID identity evidence reports `identityReady` (true or false) on every
|
|
540
|
+
// approved poll. A server that does not report it issues the claim challenge on
|
|
541
|
+
// approval, so the claim proceeds as soon as the admission is approved.
|
|
542
|
+
const reportsIdentityReadiness =
|
|
543
|
+
polled.status === "approved" && Object.prototype.hasOwnProperty.call(polled, "identityReady");
|
|
544
|
+
const claimable =
|
|
545
|
+
polled.status === "approved" && (!reportsIdentityReadiness || polled.identityReady === true);
|
|
546
|
+
if (reportsIdentityReadiness) {
|
|
547
|
+
const claimReady = claimable && polled?.claim?.domain === CLAIM_DOMAIN && polled?.claim?.preimage;
|
|
548
|
+
if (polled.identityReady === true && !claimReady) {
|
|
549
|
+
throw new Error("Identity-ready admission did not include a valid claim challenge.");
|
|
550
|
+
}
|
|
551
|
+
if (polled.identityReady !== true && polled?.claim) {
|
|
552
|
+
throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
|
|
553
|
+
}
|
|
484
554
|
}
|
|
485
|
-
if (
|
|
555
|
+
if (claimable || TERMINAL.has(polled.status) || polled.status === "active") break;
|
|
486
556
|
const phase = polled.status === "approved" ? "identity" : "approval";
|
|
487
557
|
onPending({
|
|
488
558
|
admissionId: state.admissionId,
|
|
@@ -600,13 +670,13 @@ export async function fetchOwnAdmissionReceipt(
|
|
|
600
670
|
) {
|
|
601
671
|
const sid = normalizeString(sessionId);
|
|
602
672
|
const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
|
|
603
|
-
if (!auth
|
|
673
|
+
if (!isAuthenticated(auth) || auth.source !== "session_admission") {
|
|
604
674
|
throw new Error("The admission receipt must be fetched with the admission credential.");
|
|
605
675
|
}
|
|
606
676
|
const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
|
|
607
|
-
return requestRead(admissionUrl(apiUrl, sid, "/self"), {
|
|
677
|
+
return checkedTransport(requestRead)(admissionUrl(apiUrl, sid, "/self"), {
|
|
608
678
|
method: "GET",
|
|
609
|
-
|
|
679
|
+
credential: await credentialFor(auth),
|
|
610
680
|
});
|
|
611
681
|
}
|
|
612
682
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
|
|
4
|
+
import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
|
|
4
5
|
import { SentinelayerApiError, requestJson, requestJsonMutation } from "../auth/http.js";
|
|
5
6
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
6
7
|
import { pollSessionEventsBefore } from "./sync.js";
|
|
@@ -419,12 +420,12 @@ async function resolveCheckpointApi({
|
|
|
419
420
|
env: process.env,
|
|
420
421
|
autoRotate: false,
|
|
421
422
|
});
|
|
422
|
-
if (!auth
|
|
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,
|