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
|
@@ -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();
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// reactions.js -- the ONE path every like / dislike / unlike / undislike takes, from
|
|
2
|
+
// the native CLI (`sl session react|action`) and the local MCP server
|
|
3
|
+
// (`session_react`, `session_action`) alike.
|
|
4
|
+
//
|
|
5
|
+
// Three guarantees. Separate copies of this logic once broke each of them:
|
|
6
|
+
//
|
|
7
|
+
// 1. Authority. The write runs inside withAgentAdmission for (session, agent): an
|
|
8
|
+
// admitted agent acts on ITS admission credential, a tombstoned (expired,
|
|
9
|
+
// malformed, mis-bound) admission is refused before any request, and nothing
|
|
10
|
+
// falls back to the human's broader token or legacy agent grants.
|
|
11
|
+
// 2. Intent. The API replays a reused idempotency key instead of acting on it
|
|
12
|
+
// (sentinelayer-api#914 reaction contract), so a key derived from
|
|
13
|
+
// (type, target, actor) makes like -> unlike -> like a silent no-op. Each NEW
|
|
14
|
+
// intent gets a fresh operation key. An explicit key is a retry of the intent
|
|
15
|
+
// that minted it, and is sent unchanged.
|
|
16
|
+
// 3. Truth. Every result names its outcome and carries its operation key, so an
|
|
17
|
+
// ambiguous failure (the request may have landed) can be retried as the SAME
|
|
18
|
+
// intent instead of becoming a new one that could retract a newer reaction.
|
|
19
|
+
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import process from "node:process";
|
|
22
|
+
|
|
23
|
+
import { assertCanonicalAgentId, canonicalAgentId, withAgentAdmission } from "./admission-auth.js";
|
|
24
|
+
import { createSessionMessageAction } from "./sync.js";
|
|
25
|
+
|
|
26
|
+
export const SESSION_REACTION_TYPES = Object.freeze(["like", "dislike", "unlike", "undislike"]);
|
|
27
|
+
const REACTION_TYPES = new Set(SESSION_REACTION_TYPES);
|
|
28
|
+
const UNDO_TARGETS = new Map([
|
|
29
|
+
["unlike", "like"],
|
|
30
|
+
["undislike", "dislike"],
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// createSessionMessageAction failures that happen before any request leaves this machine.
|
|
34
|
+
const NOT_SENT_REASONS = new Set([
|
|
35
|
+
"invalid_input",
|
|
36
|
+
"remote_sync_disabled_env",
|
|
37
|
+
"circuit_breaker_open",
|
|
38
|
+
"no_session",
|
|
39
|
+
"not_authenticated",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every outcome a reaction can have. `ok` is true only for the first three.
|
|
44
|
+
* applied -- this request recorded a new reaction row
|
|
45
|
+
* no_op -- an equal reaction was already active; nothing changed. With
|
|
46
|
+
* `collapsedActionId` the server retained this request as evidence.
|
|
47
|
+
* replayed -- an explicit (retry) key had already been recorded; the server
|
|
48
|
+
* returned that original result and did not re-check current state
|
|
49
|
+
* not_active -- an undo with nothing of this actor's active to retract (409)
|
|
50
|
+
* unsupported -- a server without reaction undo rejected the action value (422)
|
|
51
|
+
* refused -- any other definitive refusal; nothing was recorded
|
|
52
|
+
* not_sent -- the request never left this machine; nothing was recorded
|
|
53
|
+
* unknown -- the request may or may not have been recorded (timeout, lost
|
|
54
|
+
* response, 5xx): retry with the SAME operation key
|
|
55
|
+
*/
|
|
56
|
+
export const SESSION_REACTION_OUTCOMES = Object.freeze([
|
|
57
|
+
"applied",
|
|
58
|
+
"no_op",
|
|
59
|
+
"replayed",
|
|
60
|
+
"not_active",
|
|
61
|
+
"unsupported",
|
|
62
|
+
"refused",
|
|
63
|
+
"not_sent",
|
|
64
|
+
"unknown",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function normalizeString(value) {
|
|
68
|
+
return String(value ?? "").trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isSessionReactionType(value) {
|
|
72
|
+
return REACTION_TYPES.has(normalizeString(value).toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The like/dislike an undo retracts, or "" for a non-undo. */
|
|
76
|
+
export function reactionUndoTarget(value) {
|
|
77
|
+
return UNDO_TARGETS.get(normalizeString(value).toLowerCase()) || "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function targetKey({ targetSequenceId, targetCursor, targetActionId }) {
|
|
81
|
+
if (normalizeString(targetActionId)) return `action:${normalizeString(targetActionId)}`;
|
|
82
|
+
if (targetSequenceId) return `seq:${targetSequenceId}`;
|
|
83
|
+
return `cursor:${normalizeString(targetCursor)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function targetLabel({ targetSequenceId, targetCursor, targetActionId }) {
|
|
87
|
+
if (normalizeString(targetActionId)) return `reply ${normalizeString(targetActionId)}`;
|
|
88
|
+
if (targetSequenceId) return `#${targetSequenceId}`;
|
|
89
|
+
return normalizeString(targetCursor) || "target";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A key for a NEW intent. Never derived from (type, target, actor) alone. */
|
|
93
|
+
export function newReactionOperationKey({ surface, reaction, agentId, ...target } = {}) {
|
|
94
|
+
return `${surface}:${reaction}:${targetKey(target)}:${agentId}:${randomBytes(8).toString("hex")}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The exact 422 a server without reaction undo (sentinelayer-api before #914) returns
|
|
99
|
+
* for this action: FastAPI's validation envelope around the route's action-type
|
|
100
|
+
* validator, a single entry echoing the value sent. Anything looser could relabel a
|
|
101
|
+
* different validation failure as "undo unsupported", so the match is exact.
|
|
102
|
+
*/
|
|
103
|
+
export function isUndoUnsupportedValidationError(result = {}, actionType = "") {
|
|
104
|
+
if (result.status !== 422) return false;
|
|
105
|
+
const detail = result.error?.detail;
|
|
106
|
+
if (!Array.isArray(detail) || detail.length !== 1) return false;
|
|
107
|
+
const [entry] = detail;
|
|
108
|
+
return (
|
|
109
|
+
entry?.type === "value_error" &&
|
|
110
|
+
Array.isArray(entry.loc) &&
|
|
111
|
+
entry.loc.length === 2 &&
|
|
112
|
+
entry.loc[0] === "body" &&
|
|
113
|
+
entry.loc[1] === "actionType" &&
|
|
114
|
+
entry.msg === "Value error, unsupported message action type" &&
|
|
115
|
+
entry.input === actionType
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function classify(reaction, result, retry) {
|
|
120
|
+
const status = Number.isInteger(result?.status) ? result.status : null;
|
|
121
|
+
if (result?.ok && result.action) {
|
|
122
|
+
if (normalizeString(result.collapsedActionId)) return "no_op";
|
|
123
|
+
if (result.duplicate) return retry ? "replayed" : "no_op";
|
|
124
|
+
return "applied";
|
|
125
|
+
}
|
|
126
|
+
if (status === null) {
|
|
127
|
+
return NOT_SENT_REASONS.has(normalizeString(result?.reason)) ? "not_sent" : "unknown";
|
|
128
|
+
}
|
|
129
|
+
if (status >= 500) return "unknown";
|
|
130
|
+
if (isUndoUnsupportedValidationError(result, reaction)) return "unsupported";
|
|
131
|
+
if (status === 409 && reactionUndoTarget(reaction) && result.error?.error?.code === "REACTION_NOT_ACTIVE") {
|
|
132
|
+
return "not_active";
|
|
133
|
+
}
|
|
134
|
+
if (status >= 400) return "refused";
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function describe(outcome, { reaction, agentId, target, operationKey, reason, errorCode, collapsedActionId }) {
|
|
139
|
+
const where = targetLabel(target);
|
|
140
|
+
switch (outcome) {
|
|
141
|
+
case "applied":
|
|
142
|
+
return `${agentId} ${reaction} recorded on ${where}.`;
|
|
143
|
+
case "no_op":
|
|
144
|
+
return (
|
|
145
|
+
`No change: ${agentId} already has an active ${reaction} on ${where}.` +
|
|
146
|
+
(collapsedActionId ? ` This request is retained as evidence ${collapsedActionId}.` : "")
|
|
147
|
+
);
|
|
148
|
+
case "replayed":
|
|
149
|
+
return (
|
|
150
|
+
`Replayed: operation ${operationKey} was already recorded. The server returned its ` +
|
|
151
|
+
`original result and did not re-check the current state.`
|
|
152
|
+
);
|
|
153
|
+
case "not_active":
|
|
154
|
+
return `Nothing to undo: ${agentId} has no active ${reactionUndoTarget(reaction)} on ${where}.`;
|
|
155
|
+
case "unsupported":
|
|
156
|
+
return `This server doesn't support undo yet (no '${reaction}' action). Nothing was changed.`;
|
|
157
|
+
case "refused":
|
|
158
|
+
return `The server refused this ${reaction} (${errorCode || reason}). Nothing was recorded.`;
|
|
159
|
+
case "not_sent":
|
|
160
|
+
return `Not sent (${reason}). Nothing was recorded.`;
|
|
161
|
+
default:
|
|
162
|
+
return (
|
|
163
|
+
`Outcome unknown (${reason || "no response"}): the server may have recorded this ${reaction}. ` +
|
|
164
|
+
`To retry this same intent, resend it with idempotency key ${operationKey}; ` +
|
|
165
|
+
`without that key it is a new intent.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Submit one reaction. Throws only for invalid input or a refused admission (before
|
|
172
|
+
* any request); every server answer, network failure and timeout is an outcome.
|
|
173
|
+
*/
|
|
174
|
+
export async function submitSessionReaction({
|
|
175
|
+
surface,
|
|
176
|
+
sessionId,
|
|
177
|
+
agentId,
|
|
178
|
+
reaction,
|
|
179
|
+
targetSequenceId = null,
|
|
180
|
+
targetCursor = "",
|
|
181
|
+
targetActionId = "",
|
|
182
|
+
note = "",
|
|
183
|
+
idempotencyKey = "",
|
|
184
|
+
targetPath = process.cwd(),
|
|
185
|
+
timeoutMs = 15_000,
|
|
186
|
+
dryRun = false,
|
|
187
|
+
createSessionMessageActionFn = createSessionMessageAction,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const sid = normalizeString(sessionId);
|
|
190
|
+
const type = normalizeString(reaction).toLowerCase();
|
|
191
|
+
if (!sid) throw new Error("session id is required.");
|
|
192
|
+
if (!REACTION_TYPES.has(type)) {
|
|
193
|
+
throw new Error(`reaction must be one of: ${SESSION_REACTION_TYPES.join(", ")}.`);
|
|
194
|
+
}
|
|
195
|
+
// One canonical identity for authorisation and execution (see admission-auth.js).
|
|
196
|
+
assertCanonicalAgentId(agentId, "agentId");
|
|
197
|
+
const agent = canonicalAgentId(agentId);
|
|
198
|
+
if (!agent) throw new Error("agentId is required.");
|
|
199
|
+
const target = {
|
|
200
|
+
targetSequenceId: targetSequenceId || null,
|
|
201
|
+
targetCursor: normalizeString(targetCursor),
|
|
202
|
+
targetActionId: normalizeString(targetActionId),
|
|
203
|
+
};
|
|
204
|
+
if (!target.targetSequenceId && !target.targetCursor && !target.targetActionId) {
|
|
205
|
+
throw new Error("Provide a target sequence, cursor, or action id.");
|
|
206
|
+
}
|
|
207
|
+
const explicitKey = normalizeString(idempotencyKey);
|
|
208
|
+
const operationKey =
|
|
209
|
+
explicitKey || newReactionOperationKey({ surface, reaction: type, agentId: agent, ...target });
|
|
210
|
+
const base = {
|
|
211
|
+
sessionId: sid,
|
|
212
|
+
agentId: agent,
|
|
213
|
+
actionType: type,
|
|
214
|
+
...target,
|
|
215
|
+
note: normalizeString(note),
|
|
216
|
+
operationKey,
|
|
217
|
+
idempotencyKey: operationKey,
|
|
218
|
+
retry: Boolean(explicitKey),
|
|
219
|
+
};
|
|
220
|
+
if (dryRun) return { ok: true, dryRun: true, outcome: "dry_run", ...base };
|
|
221
|
+
|
|
222
|
+
const result = await withAgentAdmission(sid, agent, () =>
|
|
223
|
+
createSessionMessageActionFn(sid, {
|
|
224
|
+
actionType: type,
|
|
225
|
+
targetPath,
|
|
226
|
+
...target,
|
|
227
|
+
note: base.note,
|
|
228
|
+
metadata: { source: surface, agentId: agent },
|
|
229
|
+
idempotencyKey: operationKey,
|
|
230
|
+
timeoutMs,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
const outcome = classify(type, result, base.retry);
|
|
234
|
+
const collapsedActionId = normalizeString(result?.collapsedActionId) || null;
|
|
235
|
+
const status = Number.isInteger(result?.status) ? result.status : null;
|
|
236
|
+
const reason = normalizeString(result?.reason);
|
|
237
|
+
const errorCode = normalizeString(result?.error?.error?.code) || null;
|
|
238
|
+
return {
|
|
239
|
+
ok: outcome === "applied" || outcome === "no_op" || outcome === "replayed",
|
|
240
|
+
outcome,
|
|
241
|
+
...base,
|
|
242
|
+
duplicate: Boolean(result?.duplicate),
|
|
243
|
+
collapsedActionId,
|
|
244
|
+
status,
|
|
245
|
+
reason,
|
|
246
|
+
errorCode,
|
|
247
|
+
message: describe(outcome, {
|
|
248
|
+
reaction: type,
|
|
249
|
+
agentId: agent,
|
|
250
|
+
target,
|
|
251
|
+
operationKey,
|
|
252
|
+
reason,
|
|
253
|
+
errorCode,
|
|
254
|
+
collapsedActionId,
|
|
255
|
+
}),
|
|
256
|
+
action: result?.ok && result.action ? result.action : null,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// route-classes.js -- the ONE classification of every local MCP session tool ("mcp:<tool>")
|
|
2
|
+
// and every `sl session` command the MCP CLI bridge can invoke ("cli:session <path>").
|
|
3
|
+
//
|
|
4
|
+
// In an agent context it decides what a route may do when the session it acts in stores NO
|
|
5
|
+
// admission on this machine (when the session stores one, every route binds to it or is
|
|
6
|
+
// refused; see runInSessionToolContext in src/mcp/session-stdio-server.js and
|
|
7
|
+
// assertDispatchMatchesScope in src/session/admission-auth.js):
|
|
8
|
+
//
|
|
9
|
+
// legacy-data-plane a compatibility list for rooms that don't require admission:
|
|
10
|
+
// posting, replying, reading/polling, message actions and reactions,
|
|
11
|
+
// attention requests. Runs as before; the server's admission mode
|
|
12
|
+
// decides whether a room accepts it.
|
|
13
|
+
// control leases, locks, guards, tickets, checkpoints, joining and leaving,
|
|
14
|
+
// listeners, room setup and everything else that is not plain
|
|
15
|
+
// data-plane traffic. Refused unless the session's admission binds it.
|
|
16
|
+
// owner room owner actions. Refused in an agent context.
|
|
17
|
+
// exempt-local sends no request (a local store or built-in constants only).
|
|
18
|
+
//
|
|
19
|
+
// A route missing from this table is treated as unclassified and refused like control. A
|
|
20
|
+
// unit test derives every MCP tool and every bridge-invokable command from the real handlers
|
|
21
|
+
// and command tree and fails when one is missing here.
|
|
22
|
+
|
|
23
|
+
export const SESSION_ROUTE_CLASSES = Object.freeze({
|
|
24
|
+
// ---- local MCP session tools
|
|
25
|
+
"mcp:poll_inbox": "legacy-data-plane",
|
|
26
|
+
"mcp:read_history": "legacy-data-plane",
|
|
27
|
+
"mcp:send_message": "legacy-data-plane",
|
|
28
|
+
"mcp:attention_request": "legacy-data-plane",
|
|
29
|
+
"mcp:session_action": "legacy-data-plane",
|
|
30
|
+
"mcp:session_react": "legacy-data-plane",
|
|
31
|
+
"mcp:session_reply": "legacy-data-plane",
|
|
32
|
+
"mcp:session_lock": "control",
|
|
33
|
+
"mcp:session_unlock": "control",
|
|
34
|
+
"mcp:session_locks": "control",
|
|
35
|
+
"mcp:memory.write": "exempt-local",
|
|
36
|
+
"mcp:memory.recall": "exempt-local",
|
|
37
|
+
"mcp:memory.summarize": "exempt-local",
|
|
38
|
+
|
|
39
|
+
// ---- `sl session` commands invokable through the MCP CLI bridge
|
|
40
|
+
"cli:session say": "legacy-data-plane",
|
|
41
|
+
"cli:session post-agent": "legacy-data-plane",
|
|
42
|
+
"cli:session observe": "legacy-data-plane",
|
|
43
|
+
"cli:session reply": "legacy-data-plane",
|
|
44
|
+
"cli:session comment": "legacy-data-plane",
|
|
45
|
+
"cli:session action": "legacy-data-plane",
|
|
46
|
+
"cli:session react": "legacy-data-plane",
|
|
47
|
+
"cli:session read": "legacy-data-plane",
|
|
48
|
+
"cli:session history": "legacy-data-plane",
|
|
49
|
+
"cli:session search": "legacy-data-plane",
|
|
50
|
+
"cli:session pins": "legacy-data-plane",
|
|
51
|
+
"cli:session lock": "control",
|
|
52
|
+
"cli:session unlock": "control",
|
|
53
|
+
"cli:session locks": "control",
|
|
54
|
+
"cli:session renew": "control",
|
|
55
|
+
"cli:session guard": "control",
|
|
56
|
+
"cli:session guard-hook": "control",
|
|
57
|
+
"cli:session guard-install": "control",
|
|
58
|
+
"cli:session guard-uninstall": "control",
|
|
59
|
+
"cli:session checkpoint create": "control",
|
|
60
|
+
"cli:session checkpoint generate": "control",
|
|
61
|
+
"cli:session checkpoint list": "control",
|
|
62
|
+
"cli:session checkpoint show": "control",
|
|
63
|
+
"cli:session ticket claim": "control",
|
|
64
|
+
"cli:session ticket events": "control",
|
|
65
|
+
"cli:session ticket list": "control",
|
|
66
|
+
"cli:session ticket release": "control",
|
|
67
|
+
"cli:session ticket renew": "control",
|
|
68
|
+
"cli:session ticket submit": "control",
|
|
69
|
+
"cli:session join": "control",
|
|
70
|
+
"cli:session leave": "control",
|
|
71
|
+
"cli:session stop-listener": "control",
|
|
72
|
+
"cli:session listeners": "control",
|
|
73
|
+
"cli:session set-title": "control",
|
|
74
|
+
"cli:session start": "control",
|
|
75
|
+
"cli:session ensure": "control",
|
|
76
|
+
"cli:session continue": "control",
|
|
77
|
+
"cli:session inject-guide": "control",
|
|
78
|
+
"cli:session setup-guides": "control",
|
|
79
|
+
"cli:session sync": "control",
|
|
80
|
+
"cli:session status": "control",
|
|
81
|
+
"cli:session list": "control",
|
|
82
|
+
"cli:session usage": "control",
|
|
83
|
+
"cli:session recall": "control",
|
|
84
|
+
"cli:session recap now": "control",
|
|
85
|
+
"cli:session edit": "control",
|
|
86
|
+
"cli:session wake codex": "control",
|
|
87
|
+
"cli:session wake codex-notify": "control",
|
|
88
|
+
"cli:session wake daemon": "control",
|
|
89
|
+
"cli:session access approve": "owner",
|
|
90
|
+
"cli:session access deny": "owner",
|
|
91
|
+
"cli:session access revoke": "owner",
|
|
92
|
+
"cli:session access mode": "owner",
|
|
93
|
+
"cli:session access request": "owner",
|
|
94
|
+
"cli:session access list": "owner",
|
|
95
|
+
"cli:session access status": "owner",
|
|
96
|
+
"cli:session actions": "exempt-local",
|
|
97
|
+
"cli:session templates": "exempt-local",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** The compatibility list for rooms that don't require admission, as one named view of the table. */
|
|
101
|
+
export const LEGACY_DATA_PLANE_TOOLS = Object.freeze(
|
|
102
|
+
Object.keys(SESSION_ROUTE_CLASSES).filter((route) => SESSION_ROUTE_CLASSES[route] === "legacy-data-plane"),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
export function sessionRouteClass(route) {
|
|
106
|
+
return SESSION_ROUTE_CLASSES[route] || "unclassified";
|
|
107
|
+
}
|