sentinelayer-cli 0.42.0 → 0.44.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 +47 -1
- package/package.json +1 -1
- package/src/commands/session.js +435 -12
- package/src/mcp/cli-command-tools.js +3 -0
- package/src/mcp/registry.js +4 -4
- package/src/mcp/session-stdio-server.js +76 -6
- package/src/session/admission-access.js +284 -0
- package/src/session/admission-auth.js +8 -1
- package/src/session/admission.js +60 -4
- package/src/session/control-events.js +2 -0
- package/src/session/reactions.js +258 -0
- package/src/session/sync.js +41 -11
|
@@ -13,6 +13,12 @@ import { eventMatchesAgent } from "../session/listener.js";
|
|
|
13
13
|
import { isSessionControlEvent } from "../session/control-events.js";
|
|
14
14
|
import { buildObservations } from "../session/recall/observations.js";
|
|
15
15
|
import { createMemoryService } from "../engram/index.js";
|
|
16
|
+
import { assertCanonicalAgentId, canonicalAgentId, withAgentAdmission } from "../session/admission-auth.js";
|
|
17
|
+
import {
|
|
18
|
+
SESSION_REACTION_TYPES,
|
|
19
|
+
isSessionReactionType,
|
|
20
|
+
submitSessionReaction,
|
|
21
|
+
} from "../session/reactions.js";
|
|
16
22
|
import {
|
|
17
23
|
createSessionMessageAction,
|
|
18
24
|
listSessionMessageActions,
|
|
@@ -44,9 +50,12 @@ const SESSION_MESSAGE_ACTION_TYPES = new Set([
|
|
|
44
50
|
"reply",
|
|
45
51
|
"like",
|
|
46
52
|
"dislike",
|
|
53
|
+
"unlike",
|
|
54
|
+
"undislike",
|
|
47
55
|
"disregard",
|
|
48
56
|
"view",
|
|
49
57
|
]);
|
|
58
|
+
const SESSION_REACT_TYPES = Object.freeze(["ack", ...SESSION_REACTION_TYPES]);
|
|
50
59
|
const SESSION_MESSAGE_ACTION_ALIASES = new Map([["comment", "reply"]]);
|
|
51
60
|
|
|
52
61
|
function normalizeString(value) {
|
|
@@ -701,6 +710,25 @@ async function runSessionAction({
|
|
|
701
710
|
const normalizedActionType = normalizeSessionMessageActionType(actionType || input.actionType || input.action_type);
|
|
702
711
|
const target = requireSessionActionTarget(input);
|
|
703
712
|
const normalizedNote = truncateText(note || input.note || input.message || input.text || "").text;
|
|
713
|
+
if (isSessionReactionType(normalizedActionType)) {
|
|
714
|
+
// The same path as the native CLI: canonical fail-closed admission, a fresh
|
|
715
|
+
// operation key per new intent (an explicit idempotencyKey is a retry), and a
|
|
716
|
+
// truthful outcome. The raw agent id is passed so a non-canonical spelling is
|
|
717
|
+
// refused rather than silently aliased.
|
|
718
|
+
return submitSessionReaction({
|
|
719
|
+
surface: "mcp",
|
|
720
|
+
sessionId,
|
|
721
|
+
agentId: normalizeString(input.agentId || input.agent_id || input.agent),
|
|
722
|
+
reaction: normalizedActionType,
|
|
723
|
+
...target,
|
|
724
|
+
note: normalizedNote,
|
|
725
|
+
idempotencyKey: normalizeString(input.idempotencyKey || input.idempotency_key),
|
|
726
|
+
targetPath,
|
|
727
|
+
timeoutMs: normalizePositiveInteger(input.timeoutMs || input.timeout_ms, 15_000),
|
|
728
|
+
dryRun: Boolean(input.dryRun || input.dry_run),
|
|
729
|
+
createSessionMessageActionFn,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
704
732
|
const idempotencyKey =
|
|
705
733
|
normalizeString(input.idempotencyKey || input.idempotency_key) ||
|
|
706
734
|
defaultActionIdempotencyKey({
|
|
@@ -858,6 +886,47 @@ async function persistSessionEvent({
|
|
|
858
886
|
};
|
|
859
887
|
}
|
|
860
888
|
|
|
889
|
+
// Local MCP tools that act AS an agent: post, raise attention, react, reply, act on a
|
|
890
|
+
// message, lease files, or advance that agent's read cursor. Each runs inside the
|
|
891
|
+
// agent's admission scope, the same canonical path native `sl session` actor commands
|
|
892
|
+
// use. A live stored admission is the only credential its requests carry; an expired,
|
|
893
|
+
// malformed or mis-bound one is refused before any request; a credential the API
|
|
894
|
+
// refuses (401/403) is final. With no stored admission a tool behaves as before.
|
|
895
|
+
export const SESSION_MCP_ACTOR_TOOLS = Object.freeze([
|
|
896
|
+
"poll_inbox",
|
|
897
|
+
"read_history",
|
|
898
|
+
"send_message",
|
|
899
|
+
"attention_request",
|
|
900
|
+
"session_action",
|
|
901
|
+
"session_react",
|
|
902
|
+
"session_reply",
|
|
903
|
+
"session_lock",
|
|
904
|
+
"session_unlock",
|
|
905
|
+
]);
|
|
906
|
+
|
|
907
|
+
function actorAgentId(toolName, input = {}) {
|
|
908
|
+
const raw =
|
|
909
|
+
normalizeString(input.agentId || input.agent_id || input.agent) ||
|
|
910
|
+
(toolName === "read_history" ? normalizeString(process.env.SENTINELAYER_AGENT_ID) : "");
|
|
911
|
+
if (!raw) return "";
|
|
912
|
+
// One canonical identity for authorisation and execution (see admission-auth.js).
|
|
913
|
+
assertCanonicalAgentId(raw, "agentId");
|
|
914
|
+
return canonicalAgentId(raw);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function routeActorToolsThroughAdmission(handlers) {
|
|
918
|
+
for (const name of SESSION_MCP_ACTOR_TOOLS) {
|
|
919
|
+
const run = handlers[name];
|
|
920
|
+
handlers[name] = async (input = {}) => {
|
|
921
|
+
const agentId = actorAgentId(name, input);
|
|
922
|
+
// No agent named: the tool's own validation reports it (read_history reads as the user).
|
|
923
|
+
if (!agentId) return run(input);
|
|
924
|
+
return withAgentAdmission(requireSessionId(input), agentId, () => run(input));
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
return handlers;
|
|
928
|
+
}
|
|
929
|
+
|
|
861
930
|
export function createSessionMcpToolHandlers({
|
|
862
931
|
targetPath = process.cwd(),
|
|
863
932
|
pollSessionEventsFn = pollSessionEvents,
|
|
@@ -897,7 +966,7 @@ export function createSessionMcpToolHandlers({
|
|
|
897
966
|
verified: false,
|
|
898
967
|
};
|
|
899
968
|
};
|
|
900
|
-
|
|
969
|
+
const handlers = {
|
|
901
970
|
async poll_inbox(input = {}) {
|
|
902
971
|
const sessionId = requireSessionId(input);
|
|
903
972
|
const agentId = requireAgentId(input);
|
|
@@ -1105,8 +1174,8 @@ export function createSessionMcpToolHandlers({
|
|
|
1105
1174
|
|
|
1106
1175
|
async session_react(input = {}) {
|
|
1107
1176
|
const reaction = normalizeSessionMessageActionType(input.reaction || input.actionType || input.action_type);
|
|
1108
|
-
if (!
|
|
1109
|
-
throw new Error(
|
|
1177
|
+
if (!SESSION_REACT_TYPES.includes(reaction)) {
|
|
1178
|
+
throw new Error(`reaction must be one of: ${SESSION_REACT_TYPES.join(", ")}.`);
|
|
1110
1179
|
}
|
|
1111
1180
|
return runSessionAction({
|
|
1112
1181
|
input,
|
|
@@ -1267,6 +1336,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1267
1336
|
});
|
|
1268
1337
|
},
|
|
1269
1338
|
};
|
|
1339
|
+
return routeActorToolsThroughAdmission(handlers);
|
|
1270
1340
|
}
|
|
1271
1341
|
|
|
1272
1342
|
export const SESSION_MCP_TOOLS = Object.freeze([
|
|
@@ -1419,7 +1489,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
|
|
|
1419
1489
|
name: "session_action",
|
|
1420
1490
|
title: "Record Senti Session Action",
|
|
1421
1491
|
description:
|
|
1422
|
-
"Record a low-noise message action such as ack, working_on, disregard, like, dislike, or reply; view advances the monotonic read cursor instead of appending an action.",
|
|
1492
|
+
"Record a low-noise message action such as ack, working_on, disregard, like, dislike, unlike, undislike, or reply; view advances the monotonic read cursor instead of appending an action.",
|
|
1423
1493
|
inputSchema: {
|
|
1424
1494
|
type: "object",
|
|
1425
1495
|
additionalProperties: false,
|
|
@@ -1442,7 +1512,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
|
|
|
1442
1512
|
name: "session_react",
|
|
1443
1513
|
title: "React To Senti Message",
|
|
1444
1514
|
description:
|
|
1445
|
-
"Acknowledge or react to a target session event with ack, like, or dislike.",
|
|
1515
|
+
"Acknowledge or react to a target session event with ack, like, or dislike, or retract your own like/dislike with unlike/undislike. Reactions report an outcome and an operation key; resend that key as idempotencyKey only to retry the same intent.",
|
|
1446
1516
|
inputSchema: {
|
|
1447
1517
|
type: "object",
|
|
1448
1518
|
additionalProperties: false,
|
|
@@ -1450,7 +1520,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
|
|
|
1450
1520
|
properties: {
|
|
1451
1521
|
sessionId: { type: "string", minLength: 1 },
|
|
1452
1522
|
agentId: { type: "string", minLength: 1 },
|
|
1453
|
-
reaction: { type: "string", enum: [
|
|
1523
|
+
reaction: { type: "string", enum: [...SESSION_REACT_TYPES] },
|
|
1454
1524
|
targetSequenceId: { type: "integer", minimum: 1 },
|
|
1455
1525
|
targetCursor: { type: "string" },
|
|
1456
1526
|
targetActionId: { type: "string" },
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Owner/delegator controls for the Agent Admission Contract.
|
|
2
|
+
//
|
|
3
|
+
// Agent-side key generation, approval polling, proof-of-possession claim, and
|
|
4
|
+
// credential storage remain in admission.js. This module is deliberately the
|
|
5
|
+
// human control plane only: inspect requests, approve/deny them, or revoke a
|
|
6
|
+
// live grant. Every mutation uses the same session CSRF + idempotency contract
|
|
7
|
+
// as the existing request/claim path.
|
|
8
|
+
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
|
|
11
|
+
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
12
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
13
|
+
import { hasStoredAdmissionCredentials } from "./admission.js";
|
|
14
|
+
import { currentAdmittedAgent } from "./admission-auth.js";
|
|
15
|
+
import {
|
|
16
|
+
SESSION_MUTATION_ORIGIN,
|
|
17
|
+
createSessionMutationHeaders,
|
|
18
|
+
createSessionMutationIdempotencyKey,
|
|
19
|
+
} from "./invitations.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Approve, deny, revoke and mode changes are the room owner's decisions, made with the
|
|
23
|
+
* owner's own session. They are unavailable while this process is an agent context:
|
|
24
|
+
* SENTINELAYER_AGENT_ID is set, an agent admission scope is in force, or this machine
|
|
25
|
+
* stores agent admission credentials. Checked before any credential lookup or request.
|
|
26
|
+
*/
|
|
27
|
+
export async function assertOwnerAccessContext({ env = process.env, homeDir } = {}) {
|
|
28
|
+
let why = "";
|
|
29
|
+
if (normalizeString(env.SENTINELAYER_AGENT_ID)) {
|
|
30
|
+
why = "SENTINELAYER_AGENT_ID is set";
|
|
31
|
+
} else if (currentAdmittedAgent()) {
|
|
32
|
+
why = "an agent admission is in use";
|
|
33
|
+
} else if (await hasStoredAdmissionCredentials({ homeDir })) {
|
|
34
|
+
why = "agent admission credentials are stored on this machine";
|
|
35
|
+
}
|
|
36
|
+
if (why) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Access approve, deny, revoke and mode are owner actions and are unavailable in an agent context (${why}). ` +
|
|
39
|
+
`Use the room's web dashboard, or run the command from an environment without agent credentials.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const ADMISSION_STATUSES = Object.freeze([
|
|
45
|
+
"pending",
|
|
46
|
+
"approved",
|
|
47
|
+
"active",
|
|
48
|
+
"denied",
|
|
49
|
+
"cancelled",
|
|
50
|
+
"expired",
|
|
51
|
+
"revoked",
|
|
52
|
+
"stopped",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const ROUTES = Object.freeze({
|
|
56
|
+
decision: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/decision",
|
|
57
|
+
revoke: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/revoke",
|
|
58
|
+
mode: "POST /api/v1/sessions/{session_id}/admission-mode",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function normalizeString(value) {
|
|
62
|
+
return String(value ?? "").trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function required(value, label) {
|
|
66
|
+
const normalized = normalizeString(value);
|
|
67
|
+
if (!normalized) throw new Error(`${label} is required.`);
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function authContext(targetPath, resolveAuthSession) {
|
|
72
|
+
const auth = await resolveAuthSession({
|
|
73
|
+
cwd: targetPath,
|
|
74
|
+
env: process.env,
|
|
75
|
+
autoRotate: false,
|
|
76
|
+
});
|
|
77
|
+
if (!auth?.token || !auth?.apiUrl) {
|
|
78
|
+
throw new Error("Not authenticated. Run `sl auth login` first.");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
token: auth.token,
|
|
82
|
+
apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, ""),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function admissionsUrl(apiUrl, sessionId, suffix = "") {
|
|
87
|
+
return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/admissions${suffix}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function boundedIdempotencyKey(value, operation) {
|
|
91
|
+
const explicit = normalizeString(value);
|
|
92
|
+
if (explicit.length > 128) throw new Error("--idempotency-key must be at most 128 characters.");
|
|
93
|
+
return explicit || createSessionMutationIdempotencyKey(operation);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function mutateAdmission(
|
|
97
|
+
sessionId,
|
|
98
|
+
suffix,
|
|
99
|
+
routeId,
|
|
100
|
+
body,
|
|
101
|
+
{
|
|
102
|
+
targetPath,
|
|
103
|
+
idempotencyKey,
|
|
104
|
+
operationName,
|
|
105
|
+
origin,
|
|
106
|
+
resolveAuthSession,
|
|
107
|
+
requestMutation,
|
|
108
|
+
},
|
|
109
|
+
) {
|
|
110
|
+
const key = boundedIdempotencyKey(idempotencyKey, operationName);
|
|
111
|
+
const auth = await authContext(targetPath, resolveAuthSession);
|
|
112
|
+
const result = await requestMutation(admissionsUrl(auth.apiUrl, sessionId, suffix), {
|
|
113
|
+
method: "POST",
|
|
114
|
+
operationName,
|
|
115
|
+
idempotencyKey: key,
|
|
116
|
+
headers: createSessionMutationHeaders({
|
|
117
|
+
bearerToken: auth.token,
|
|
118
|
+
sessionId,
|
|
119
|
+
routeId,
|
|
120
|
+
idempotencyKey: key,
|
|
121
|
+
origin,
|
|
122
|
+
}),
|
|
123
|
+
body,
|
|
124
|
+
});
|
|
125
|
+
return { idempotencyKey: key, result };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function listSessionAdmissions(
|
|
129
|
+
sessionId,
|
|
130
|
+
{
|
|
131
|
+
status = "",
|
|
132
|
+
targetPath = process.cwd(),
|
|
133
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
134
|
+
requestRead = requestJson,
|
|
135
|
+
} = {},
|
|
136
|
+
) {
|
|
137
|
+
const sid = required(sessionId, "session id");
|
|
138
|
+
const normalizedStatus = normalizeString(status).toLowerCase();
|
|
139
|
+
if (normalizedStatus && !ADMISSION_STATUSES.includes(normalizedStatus)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`--status must be one of: ${ADMISSION_STATUSES.join(", ")}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const auth = await authContext(targetPath, resolveAuthSession);
|
|
145
|
+
const query = normalizedStatus ? `?status=${encodeURIComponent(normalizedStatus)}` : "";
|
|
146
|
+
return requestRead(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
|
|
147
|
+
method: "GET",
|
|
148
|
+
headers: { Authorization: `Bearer ${auth.token}` },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function getSessionAdmission(
|
|
153
|
+
sessionId,
|
|
154
|
+
admissionId,
|
|
155
|
+
{
|
|
156
|
+
targetPath = process.cwd(),
|
|
157
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
158
|
+
requestRead = requestJson,
|
|
159
|
+
} = {},
|
|
160
|
+
) {
|
|
161
|
+
const sid = required(sessionId, "session id");
|
|
162
|
+
const aid = required(admissionId, "admission id");
|
|
163
|
+
const auth = await authContext(targetPath, resolveAuthSession);
|
|
164
|
+
return requestRead(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
|
|
165
|
+
method: "GET",
|
|
166
|
+
headers: { Authorization: `Bearer ${auth.token}` },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function decideSessionAdmission(
|
|
171
|
+
sessionId,
|
|
172
|
+
admissionId,
|
|
173
|
+
{
|
|
174
|
+
decision,
|
|
175
|
+
grantedActions,
|
|
176
|
+
ttlSeconds,
|
|
177
|
+
note,
|
|
178
|
+
targetPath = process.cwd(),
|
|
179
|
+
idempotencyKey = "",
|
|
180
|
+
origin = SESSION_MUTATION_ORIGIN,
|
|
181
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
182
|
+
requestMutation = requestJsonMutation,
|
|
183
|
+
} = {},
|
|
184
|
+
) {
|
|
185
|
+
const sid = required(sessionId, "session id");
|
|
186
|
+
const aid = required(admissionId, "admission id");
|
|
187
|
+
const normalizedDecision = normalizeString(decision).toLowerCase();
|
|
188
|
+
if (!["approve", "deny"].includes(normalizedDecision)) {
|
|
189
|
+
throw new Error("decision must be approve or deny.");
|
|
190
|
+
}
|
|
191
|
+
if (normalizedDecision === "deny" && (grantedActions !== undefined || ttlSeconds !== undefined)) {
|
|
192
|
+
throw new Error("A denied admission cannot include --scope or --ttl.");
|
|
193
|
+
}
|
|
194
|
+
const body = { decision: normalizedDecision };
|
|
195
|
+
if (grantedActions !== undefined) body.grantedActions = grantedActions;
|
|
196
|
+
if (ttlSeconds !== undefined) body.ttlSeconds = ttlSeconds;
|
|
197
|
+
const normalizedNote = normalizeString(note);
|
|
198
|
+
if (normalizedNote) body.note = normalizedNote;
|
|
199
|
+
await assertOwnerAccessContext();
|
|
200
|
+
return mutateAdmission(
|
|
201
|
+
sid,
|
|
202
|
+
`/${encodeURIComponent(aid)}/decision`,
|
|
203
|
+
ROUTES.decision,
|
|
204
|
+
body,
|
|
205
|
+
{
|
|
206
|
+
targetPath,
|
|
207
|
+
idempotencyKey,
|
|
208
|
+
operationName: `session.admission_${normalizedDecision}`,
|
|
209
|
+
origin,
|
|
210
|
+
resolveAuthSession,
|
|
211
|
+
requestMutation,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function revokeSessionAdmission(
|
|
217
|
+
sessionId,
|
|
218
|
+
admissionId,
|
|
219
|
+
{
|
|
220
|
+
reason,
|
|
221
|
+
targetPath = process.cwd(),
|
|
222
|
+
idempotencyKey = "",
|
|
223
|
+
origin = SESSION_MUTATION_ORIGIN,
|
|
224
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
225
|
+
requestMutation = requestJsonMutation,
|
|
226
|
+
} = {},
|
|
227
|
+
) {
|
|
228
|
+
const sid = required(sessionId, "session id");
|
|
229
|
+
const aid = required(admissionId, "admission id");
|
|
230
|
+
const normalizedReason = normalizeString(reason);
|
|
231
|
+
await assertOwnerAccessContext();
|
|
232
|
+
return mutateAdmission(
|
|
233
|
+
sid,
|
|
234
|
+
`/${encodeURIComponent(aid)}/revoke`,
|
|
235
|
+
ROUTES.revoke,
|
|
236
|
+
normalizedReason ? { reason: normalizedReason } : {},
|
|
237
|
+
{
|
|
238
|
+
targetPath,
|
|
239
|
+
idempotencyKey,
|
|
240
|
+
operationName: "session.admission_revoke",
|
|
241
|
+
origin,
|
|
242
|
+
resolveAuthSession,
|
|
243
|
+
requestMutation,
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function setSessionAdmissionMode(
|
|
249
|
+
sessionId,
|
|
250
|
+
mode,
|
|
251
|
+
{
|
|
252
|
+
targetPath = process.cwd(),
|
|
253
|
+
idempotencyKey = "",
|
|
254
|
+
origin = SESSION_MUTATION_ORIGIN,
|
|
255
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
256
|
+
requestMutation = requestJsonMutation,
|
|
257
|
+
} = {},
|
|
258
|
+
) {
|
|
259
|
+
const sid = required(sessionId, "session id");
|
|
260
|
+
const normalizedMode = normalizeString(mode).toLowerCase();
|
|
261
|
+
if (!["legacy", "required"].includes(normalizedMode)) {
|
|
262
|
+
throw new Error("mode must be legacy or required.");
|
|
263
|
+
}
|
|
264
|
+
await assertOwnerAccessContext();
|
|
265
|
+
const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
|
|
266
|
+
const auth = await authContext(targetPath, resolveAuthSession);
|
|
267
|
+
const result = await requestMutation(
|
|
268
|
+
`${auth.apiUrl}/api/v1/sessions/${encodeURIComponent(sid)}/admission-mode`,
|
|
269
|
+
{
|
|
270
|
+
method: "POST",
|
|
271
|
+
operationName: "session.admission_mode",
|
|
272
|
+
idempotencyKey: key,
|
|
273
|
+
headers: createSessionMutationHeaders({
|
|
274
|
+
bearerToken: auth.token,
|
|
275
|
+
sessionId: sid,
|
|
276
|
+
routeId: ROUTES.mode,
|
|
277
|
+
idempotencyKey: key,
|
|
278
|
+
origin,
|
|
279
|
+
}),
|
|
280
|
+
body: { mode: normalizedMode },
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
return { idempotencyKey: key, result };
|
|
284
|
+
}
|
|
@@ -153,13 +153,20 @@ export const SESSION_AGENT_PRINCIPAL = Object.freeze({
|
|
|
153
153
|
"ticket release": "actor",
|
|
154
154
|
"ticket submit": "actor",
|
|
155
155
|
"ticket events": "actor",
|
|
156
|
+
"access mode": "control",
|
|
157
|
+
"access request": "control",
|
|
158
|
+
"access list": "control",
|
|
159
|
+
"access status": "control",
|
|
160
|
+
"access approve": "control",
|
|
161
|
+
"access deny": "control",
|
|
162
|
+
"access revoke": "control",
|
|
156
163
|
kill: "target",
|
|
157
164
|
"stop-listener": "target",
|
|
158
165
|
leave: "target",
|
|
159
166
|
"guard-uninstall": "target",
|
|
160
167
|
join: "control",
|
|
161
168
|
});
|
|
162
|
-
const SESSION_COMMAND_GROUPS = new Set(["checkpoint", "ticket"]);
|
|
169
|
+
const SESSION_COMMAND_GROUPS = new Set(["access", "checkpoint", "ticket"]);
|
|
163
170
|
|
|
164
171
|
async function implicitAgent(sessionId, targetPath, env) {
|
|
165
172
|
// The same identity the command itself resolves when --agent is omitted
|
package/src/session/admission.js
CHANGED
|
@@ -63,7 +63,8 @@ const ROUTE = {
|
|
|
63
63
|
claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
|
|
64
64
|
cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
|
|
65
65
|
};
|
|
66
|
-
|
|
66
|
+
// Final admission statuses: polling stops, nothing is claimed, local request state is cleared.
|
|
67
|
+
const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked", "stopped"]);
|
|
67
68
|
const TTL_MIN_SECONDS = 300;
|
|
68
69
|
const TTL_MAX_SECONDS = 86_400;
|
|
69
70
|
|
|
@@ -242,6 +243,34 @@ export function admissionCredentialPath(sessionId, agentId, { homeDir } = {}) {
|
|
|
242
243
|
);
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Whether this machine stores ANY agent admission credential (usable or not, any
|
|
248
|
+
* session): `<home>/.sentinelayer/agents/<agent>/admissions/<session>.json`. Agent keys
|
|
249
|
+
* alone do not count. An unreadable directory counts as present, never as absence.
|
|
250
|
+
*/
|
|
251
|
+
export async function hasStoredAdmissionCredentials({ homeDir } = {}) {
|
|
252
|
+
const agentsDir = path.join(sentinelayerHome(homeDir), "agents");
|
|
253
|
+
let agents;
|
|
254
|
+
try {
|
|
255
|
+
agents = await fsp.readdir(agentsDir, { withFileTypes: true });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error && error.code === "ENOENT") return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
for (const agent of agents) {
|
|
261
|
+
if (!agent.isDirectory()) continue;
|
|
262
|
+
let files;
|
|
263
|
+
try {
|
|
264
|
+
files = await fsp.readdir(path.join(agentsDir, agent.name, "admissions"));
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error && error.code === "ENOENT") continue;
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
if (files.some((name) => name.endsWith(".json"))) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
245
274
|
/**
|
|
246
275
|
* What this machine holds for (session, agent):
|
|
247
276
|
* { state: "none" } -- never admitted here: the legacy path applies
|
|
@@ -471,12 +500,39 @@ export async function runAdmissionJoin(
|
|
|
471
500
|
headers: { Authorization: `Bearer ${auth.token}` },
|
|
472
501
|
});
|
|
473
502
|
state.status = polled.status;
|
|
474
|
-
|
|
475
|
-
|
|
503
|
+
// Identity readiness is a server capability: a server that gates the claim on
|
|
504
|
+
// AIdenID identity evidence reports `identityReady` (true or false) on every
|
|
505
|
+
// approved poll. A server that does not report it issues the claim challenge on
|
|
506
|
+
// approval, so the claim proceeds as soon as the admission is approved.
|
|
507
|
+
const reportsIdentityReadiness =
|
|
508
|
+
polled.status === "approved" && Object.prototype.hasOwnProperty.call(polled, "identityReady");
|
|
509
|
+
const claimable =
|
|
510
|
+
polled.status === "approved" && (!reportsIdentityReadiness || polled.identityReady === true);
|
|
511
|
+
if (reportsIdentityReadiness) {
|
|
512
|
+
const claimReady = claimable && polled?.claim?.domain === CLAIM_DOMAIN && polled?.claim?.preimage;
|
|
513
|
+
if (polled.identityReady === true && !claimReady) {
|
|
514
|
+
throw new Error("Identity-ready admission did not include a valid claim challenge.");
|
|
515
|
+
}
|
|
516
|
+
if (polled.identityReady !== true && polled?.claim) {
|
|
517
|
+
throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (claimable || TERMINAL.has(polled.status) || polled.status === "active") break;
|
|
521
|
+
const phase = polled.status === "approved" ? "identity" : "approval";
|
|
522
|
+
onPending({
|
|
523
|
+
admissionId: state.admissionId,
|
|
524
|
+
approveUrl: polled.approveUrl || state.approveUrl,
|
|
525
|
+
phase,
|
|
526
|
+
identityReady: polled.identityReady === true,
|
|
527
|
+
identity: polled.identity || null,
|
|
528
|
+
});
|
|
476
529
|
if (now() >= deadline) {
|
|
477
530
|
await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
478
531
|
return {
|
|
479
|
-
status: "pending",
|
|
532
|
+
status: polled.status === "approved" ? "approved" : "pending",
|
|
533
|
+
phase,
|
|
534
|
+
identityReady: polled.identityReady === true,
|
|
535
|
+
identity: polled.identity || null,
|
|
480
536
|
admissionId: state.admissionId,
|
|
481
537
|
approveUrl: polled.approveUrl || state.approveUrl,
|
|
482
538
|
waited: true,
|