sentinelayer-cli 0.43.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 +22 -13
- package/package.json +1 -1
- package/src/commands/session.js +133 -6
- 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 +28 -0
- package/src/session/admission.js +47 -12
- package/src/session/control-events.js +2 -0
- package/src/session/reactions.js +258 -0
- package/src/session/sync.js +41 -11
package/README.md
CHANGED
|
@@ -164,11 +164,13 @@ sl session action <session-id> working_on --target-sequence <n>
|
|
|
164
164
|
```
|
|
165
165
|
|
|
166
166
|
For a room that requires agent admission, the agent declares its goal, requested
|
|
167
|
-
actions, and TTL. The CLI creates an agent-held Ed25519 identity
|
|
168
|
-
human decision
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
actions, and TTL. The CLI creates an agent-held Ed25519 identity and waits for a
|
|
168
|
+
human decision. When the API reports identity readiness on the approved
|
|
169
|
+
admission, the CLI also waits for the minted AIdenID email and verified signed
|
|
170
|
+
purpose receipt before it claims; when the API does not report it, the approved
|
|
171
|
+
grant is claimed directly. The claim uses proof of possession, then the agent
|
|
172
|
+
joins. The private key and scoped credential are stored locally and are never
|
|
173
|
+
printed:
|
|
172
174
|
|
|
173
175
|
```bash
|
|
174
176
|
# Agent: request and wait for the owner's decision (maximum grant TTL is 24h).
|
|
@@ -190,20 +192,27 @@ sl session access approve <session-id> <admission-id> \
|
|
|
190
192
|
# sl session access revoke <session-id> <admission-id> --reason "work complete"
|
|
191
193
|
```
|
|
192
194
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
`session access list --json` reports the `email`, `purpose`, and Jev status the
|
|
196
|
+
API returns; the CLI does not invent successful issuance. On an API that gates
|
|
197
|
+
the claim on identity evidence, approval queues the AIdenID email and
|
|
198
|
+
purpose-receipt work and the claim challenge is withheld until both are ready.
|
|
199
|
+
On an API that does not, approval issues the claim challenge directly.
|
|
197
200
|
The agent must re-run `session join` with the same goal, scope, and TTL after an
|
|
198
201
|
asynchronous request so it can claim and verify its live session-bound receipt.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
A room's admission mode is whatever the API stored for it; `session access list`
|
|
203
|
+
shows it, and an owner changes it with `session access mode <session-id>
|
|
204
|
+
required` (or `legacy`).
|
|
205
|
+
|
|
206
|
+
The owner actions (`approve`, `deny`, `revoke`, `mode`) run on the owner's own
|
|
207
|
+
session. They are unavailable in an agent context, meaning `SENTINELAYER_AGENT_ID`
|
|
208
|
+
is set or agent admission credentials are stored on the machine; owners use the
|
|
209
|
+
web dashboard from such machines. `session access` commands are not exposed
|
|
210
|
+
through the MCP CLI bridge.
|
|
202
211
|
|
|
203
212
|
Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
|
|
204
213
|
|
|
205
214
|
- session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
|
|
206
|
-
- low-noise message actions (`react ack|like|dislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
|
|
215
|
+
- low-noise message actions (`react ack|like|dislike|unlike|undislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
|
|
207
216
|
- agent lifecycle controls (join/ephemeral TTL presence/leave/kill); listener liveness never enters the durable transcript
|
|
208
217
|
- recap and context briefing for late-joining agents
|
|
209
218
|
- analytics + lineage artifacts at session closeout
|
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -111,6 +111,11 @@ import {
|
|
|
111
111
|
currentAdmittedAgent,
|
|
112
112
|
withAgentAdmission,
|
|
113
113
|
} from "../session/admission-auth.js";
|
|
114
|
+
import {
|
|
115
|
+
SESSION_REACTION_TYPES,
|
|
116
|
+
isSessionReactionType,
|
|
117
|
+
submitSessionReaction,
|
|
118
|
+
} from "../session/reactions.js";
|
|
114
119
|
import {
|
|
115
120
|
claimTicket,
|
|
116
121
|
listTickets,
|
|
@@ -564,10 +569,16 @@ const SESSION_MESSAGE_ACTION_TYPES = new Set([
|
|
|
564
569
|
"reply",
|
|
565
570
|
"like",
|
|
566
571
|
"dislike",
|
|
572
|
+
"unlike",
|
|
573
|
+
"undislike",
|
|
567
574
|
"disregard",
|
|
568
575
|
"view",
|
|
569
576
|
]);
|
|
570
577
|
|
|
578
|
+
// like/dislike/unlike/undislike all go through src/session/reactions.js, the path the
|
|
579
|
+
// local MCP server shares; `ack` keeps the plain message-action path.
|
|
580
|
+
const SESSION_REACT_COMMAND_TYPES = Object.freeze(["ack", ...SESSION_REACTION_TYPES]);
|
|
581
|
+
|
|
571
582
|
const SESSION_MESSAGE_ACTION_ALIASES = new Map([
|
|
572
583
|
["comment", "reply"],
|
|
573
584
|
]);
|
|
@@ -599,6 +610,16 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
|
|
|
599
610
|
command: "sl session react <id> dislike --target-sequence <n>",
|
|
600
611
|
description: "Negative lightweight feedback. Use --target-action-id <uuid> to react to a threaded reply.",
|
|
601
612
|
},
|
|
613
|
+
{
|
|
614
|
+
type: "unlike",
|
|
615
|
+
command: "sl session react <id> unlike --target-sequence <n>",
|
|
616
|
+
description: "Retract your own active like. Refused when you have none; needs a server with reaction undo.",
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
type: "undislike",
|
|
620
|
+
command: "sl session react <id> undislike --target-sequence <n>",
|
|
621
|
+
description: "Retract your own active dislike. Refused when you have none; needs a server with reaction undo.",
|
|
622
|
+
},
|
|
602
623
|
{
|
|
603
624
|
type: "disregard",
|
|
604
625
|
command: "sl session action <id> disregard --target-sequence <n>",
|
|
@@ -630,7 +651,7 @@ function shortSha256(value) {
|
|
|
630
651
|
|
|
631
652
|
function actionEventType(actionType) {
|
|
632
653
|
if (actionType === "reply") return "session_reply";
|
|
633
|
-
if (actionType
|
|
654
|
+
if (isSessionReactionType(actionType)) return "session_reaction";
|
|
634
655
|
return "session_action";
|
|
635
656
|
}
|
|
636
657
|
|
|
@@ -686,6 +707,7 @@ function buildSessionActionEvent(sessionId, action = {}) {
|
|
|
686
707
|
}),
|
|
687
708
|
);
|
|
688
709
|
const actorId = actionActorId(action);
|
|
710
|
+
const collapsedIntoActionId = normalizeString(action.collapsedIntoActionId ?? action.collapsed_into_action_id);
|
|
689
711
|
const event = createAgentEvent({
|
|
690
712
|
event: actionEventType(actionType),
|
|
691
713
|
agent: {
|
|
@@ -710,6 +732,8 @@ function buildSessionActionEvent(sessionId, action = {}) {
|
|
|
710
732
|
metadata: action.metadata && typeof action.metadata === "object" ? action.metadata : undefined,
|
|
711
733
|
message: actionDisplayMessage(action),
|
|
712
734
|
source: "session_action",
|
|
735
|
+
// A retained no-op reaction key (sentinelayer-api#914): evidence, not a reaction.
|
|
736
|
+
...(collapsedIntoActionId ? { collapsedIntoActionId } : {}),
|
|
713
737
|
},
|
|
714
738
|
});
|
|
715
739
|
event.eventId = `session-action-${id}`;
|
|
@@ -4282,6 +4306,21 @@ export function registerSessionCommand(program) {
|
|
|
4282
4306
|
);
|
|
4283
4307
|
}
|
|
4284
4308
|
const agentId = identity.agentId;
|
|
4309
|
+
if (isSessionReactionType(normalizedActionType)) {
|
|
4310
|
+
return runReactionCommand({
|
|
4311
|
+
sessionId: normalizedSessionId,
|
|
4312
|
+
reaction: normalizedActionType,
|
|
4313
|
+
agentId,
|
|
4314
|
+
targetPath,
|
|
4315
|
+
targetSequenceId,
|
|
4316
|
+
targetCursor,
|
|
4317
|
+
targetActionId,
|
|
4318
|
+
note,
|
|
4319
|
+
options,
|
|
4320
|
+
command,
|
|
4321
|
+
commandName,
|
|
4322
|
+
});
|
|
4323
|
+
}
|
|
4285
4324
|
const idempotencyKey =
|
|
4286
4325
|
normalizeString(options.idempotencyKey) ||
|
|
4287
4326
|
defaultActionIdempotencyKey({
|
|
@@ -4364,6 +4403,89 @@ export function registerSessionCommand(program) {
|
|
|
4364
4403
|
return payload;
|
|
4365
4404
|
}
|
|
4366
4405
|
|
|
4406
|
+
// like/dislike/unlike/undislike: the shared reaction path (src/session/reactions.js)
|
|
4407
|
+
// decides admission, the operation key and the outcome; this only mirrors and prints.
|
|
4408
|
+
// `--idempotency-key` is a RETRY of the intent that minted that key.
|
|
4409
|
+
async function runReactionCommand({
|
|
4410
|
+
sessionId,
|
|
4411
|
+
reaction,
|
|
4412
|
+
agentId,
|
|
4413
|
+
targetPath,
|
|
4414
|
+
targetSequenceId,
|
|
4415
|
+
targetCursor,
|
|
4416
|
+
targetActionId,
|
|
4417
|
+
note,
|
|
4418
|
+
options,
|
|
4419
|
+
command,
|
|
4420
|
+
commandName,
|
|
4421
|
+
}) {
|
|
4422
|
+
const outcome = await submitSessionReaction({
|
|
4423
|
+
surface: "cli",
|
|
4424
|
+
sessionId,
|
|
4425
|
+
agentId,
|
|
4426
|
+
reaction,
|
|
4427
|
+
targetSequenceId,
|
|
4428
|
+
targetCursor,
|
|
4429
|
+
targetActionId,
|
|
4430
|
+
note,
|
|
4431
|
+
idempotencyKey: options.idempotencyKey,
|
|
4432
|
+
targetPath,
|
|
4433
|
+
});
|
|
4434
|
+
let event = null;
|
|
4435
|
+
let localAppend = { appended: false, reason: "not_recorded" };
|
|
4436
|
+
if (outcome.action) {
|
|
4437
|
+
const appended = await appendActionEventIfMissing(
|
|
4438
|
+
sessionId,
|
|
4439
|
+
buildSessionActionEvent(sessionId, outcome.action),
|
|
4440
|
+
{ targetPath },
|
|
4441
|
+
);
|
|
4442
|
+
event = appended.event;
|
|
4443
|
+
localAppend = { appended: Boolean(appended.appended), reason: appended.reason || "" };
|
|
4444
|
+
}
|
|
4445
|
+
let evidenceEvent = null;
|
|
4446
|
+
if (outcome.collapsedActionId && outcome.action) {
|
|
4447
|
+
// Mirror the server's retained no-op row: this request's key, collapsed into
|
|
4448
|
+
// the active reaction it repeated.
|
|
4449
|
+
const appended = await appendActionEventIfMissing(
|
|
4450
|
+
sessionId,
|
|
4451
|
+
buildSessionActionEvent(sessionId, {
|
|
4452
|
+
id: outcome.collapsedActionId,
|
|
4453
|
+
actionType: reaction,
|
|
4454
|
+
targetSequenceId: outcome.action.targetSequenceId,
|
|
4455
|
+
targetCursor: outcome.action.targetCursor,
|
|
4456
|
+
targetActionId: outcome.action.targetActionId,
|
|
4457
|
+
actorKind: outcome.action.actorKind,
|
|
4458
|
+
actorId: outcome.action.actorId,
|
|
4459
|
+
actorRole: outcome.action.actorRole,
|
|
4460
|
+
idempotencyKey: outcome.operationKey,
|
|
4461
|
+
collapsedIntoActionId: outcome.action.id,
|
|
4462
|
+
}),
|
|
4463
|
+
{ targetPath },
|
|
4464
|
+
);
|
|
4465
|
+
evidenceEvent = appended.event;
|
|
4466
|
+
}
|
|
4467
|
+
const payload = {
|
|
4468
|
+
command: commandName,
|
|
4469
|
+
targetPath,
|
|
4470
|
+
...outcome,
|
|
4471
|
+
event,
|
|
4472
|
+
evidenceEvent,
|
|
4473
|
+
localAppend,
|
|
4474
|
+
};
|
|
4475
|
+
if (!outcome.ok) process.exitCode = 1;
|
|
4476
|
+
if (shouldEmitJson(options, command)) {
|
|
4477
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
4478
|
+
return payload;
|
|
4479
|
+
}
|
|
4480
|
+
if (!outcome.ok) {
|
|
4481
|
+
console.error(pc.yellow(outcome.message));
|
|
4482
|
+
return payload;
|
|
4483
|
+
}
|
|
4484
|
+
if (event) console.log(formatEventLine(event));
|
|
4485
|
+
if (outcome.outcome !== "applied" || !event) console.log(pc.gray(outcome.message));
|
|
4486
|
+
return payload;
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4367
4489
|
session
|
|
4368
4490
|
.command("actions")
|
|
4369
4491
|
.description("List supported low-noise message actions with examples")
|
|
@@ -4390,7 +4512,7 @@ export function registerSessionCommand(program) {
|
|
|
4390
4512
|
session
|
|
4391
4513
|
.command("action <sessionId> <actionType>")
|
|
4392
4514
|
.description(
|
|
4393
|
-
"Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, disregard, view)",
|
|
4515
|
+
"Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, unlike, undislike, disregard, view)",
|
|
4394
4516
|
)
|
|
4395
4517
|
.option("--target-sequence <n>", "Target event sequence id")
|
|
4396
4518
|
.option("--target-cursor <cursor>", "Target event cursor")
|
|
@@ -4406,18 +4528,23 @@ export function registerSessionCommand(program) {
|
|
|
4406
4528
|
|
|
4407
4529
|
session
|
|
4408
4530
|
.command("react <sessionId> <reaction>")
|
|
4409
|
-
.description(
|
|
4531
|
+
.description(
|
|
4532
|
+
"React to or acknowledge a target session event with ack, like, or dislike; retract your own like/dislike with unlike/undislike",
|
|
4533
|
+
)
|
|
4410
4534
|
.option("--target-sequence <n>", "Target event sequence id")
|
|
4411
4535
|
.option("--target-cursor <cursor>", "Target event cursor")
|
|
4412
4536
|
.option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
|
|
4413
4537
|
.option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
|
|
4414
|
-
.option(
|
|
4538
|
+
.option(
|
|
4539
|
+
"--idempotency-key <key>",
|
|
4540
|
+
"Retry an earlier reaction as the SAME intent by resending its operationKey (without it, each call is a new intent)",
|
|
4541
|
+
)
|
|
4415
4542
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
4416
4543
|
.option("--json", "Emit machine-readable output")
|
|
4417
4544
|
.action(async (sessionId, reaction, options, command) => {
|
|
4418
4545
|
const normalizedReaction = normalizeSessionMessageActionType(reaction);
|
|
4419
|
-
if (!
|
|
4420
|
-
throw new Error(
|
|
4546
|
+
if (!SESSION_REACT_COMMAND_TYPES.includes(normalizedReaction)) {
|
|
4547
|
+
throw new Error(`reaction must be one of: ${SESSION_REACT_COMMAND_TYPES.join(", ")}.`);
|
|
4421
4548
|
}
|
|
4422
4549
|
await runMessageActionCommand({
|
|
4423
4550
|
sessionId,
|
|
@@ -64,6 +64,9 @@ const SENSITIVE_COMMAND_PREFIXES = [
|
|
|
64
64
|
"ai.identity.target.",
|
|
65
65
|
"ai.identity.site.",
|
|
66
66
|
"ai.identity.legal-hold.",
|
|
67
|
+
// Room access administration (request/list/status/approve/deny/revoke/mode) belongs to
|
|
68
|
+
// the room owner's own session; operators use the direct CLI or the web dashboard.
|
|
69
|
+
"session.access.",
|
|
67
70
|
];
|
|
68
71
|
const REDACTION_MARKER = "[REDACTED]";
|
|
69
72
|
|
package/src/mcp/registry.js
CHANGED
|
@@ -655,7 +655,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
655
655
|
name: "session_action",
|
|
656
656
|
title: "Record Senti Session Action",
|
|
657
657
|
description:
|
|
658
|
-
"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.",
|
|
658
|
+
"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.",
|
|
659
659
|
input_schema: {
|
|
660
660
|
type: "object",
|
|
661
661
|
additionalProperties: false,
|
|
@@ -665,7 +665,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
665
665
|
agentId: { type: "string" },
|
|
666
666
|
actionType: {
|
|
667
667
|
type: "string",
|
|
668
|
-
enum: ["ack", "working_on", "reply", "like", "dislike", "disregard", "view"],
|
|
668
|
+
enum: ["ack", "working_on", "reply", "like", "dislike", "unlike", "undislike", "disregard", "view"],
|
|
669
669
|
},
|
|
670
670
|
targetSequenceId: { type: "integer", minimum: 1 },
|
|
671
671
|
targetCursor: { type: "string" },
|
|
@@ -702,7 +702,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
702
702
|
name: "session_react",
|
|
703
703
|
title: "React To Senti Message",
|
|
704
704
|
description:
|
|
705
|
-
"Acknowledge or react to a target session event with ack, like, or dislike.",
|
|
705
|
+
"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.",
|
|
706
706
|
input_schema: {
|
|
707
707
|
type: "object",
|
|
708
708
|
additionalProperties: false,
|
|
@@ -710,7 +710,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
710
710
|
properties: {
|
|
711
711
|
sessionId: { type: "string" },
|
|
712
712
|
agentId: { type: "string" },
|
|
713
|
-
reaction: { type: "string", enum: ["ack", "like", "dislike"] },
|
|
713
|
+
reaction: { type: "string", enum: ["ack", "like", "dislike", "unlike", "undislike"] },
|
|
714
714
|
targetSequenceId: { type: "integer", minimum: 1 },
|
|
715
715
|
targetCursor: { type: "string" },
|
|
716
716
|
targetActionId: { type: "string" },
|
|
@@ -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" },
|
|
@@ -10,12 +10,37 @@ import process from "node:process";
|
|
|
10
10
|
|
|
11
11
|
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
12
12
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
13
|
+
import { hasStoredAdmissionCredentials } from "./admission.js";
|
|
14
|
+
import { currentAdmittedAgent } from "./admission-auth.js";
|
|
13
15
|
import {
|
|
14
16
|
SESSION_MUTATION_ORIGIN,
|
|
15
17
|
createSessionMutationHeaders,
|
|
16
18
|
createSessionMutationIdempotencyKey,
|
|
17
19
|
} from "./invitations.js";
|
|
18
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
|
+
|
|
19
44
|
export const ADMISSION_STATUSES = Object.freeze([
|
|
20
45
|
"pending",
|
|
21
46
|
"approved",
|
|
@@ -171,6 +196,7 @@ export async function decideSessionAdmission(
|
|
|
171
196
|
if (ttlSeconds !== undefined) body.ttlSeconds = ttlSeconds;
|
|
172
197
|
const normalizedNote = normalizeString(note);
|
|
173
198
|
if (normalizedNote) body.note = normalizedNote;
|
|
199
|
+
await assertOwnerAccessContext();
|
|
174
200
|
return mutateAdmission(
|
|
175
201
|
sid,
|
|
176
202
|
`/${encodeURIComponent(aid)}/decision`,
|
|
@@ -202,6 +228,7 @@ export async function revokeSessionAdmission(
|
|
|
202
228
|
const sid = required(sessionId, "session id");
|
|
203
229
|
const aid = required(admissionId, "admission id");
|
|
204
230
|
const normalizedReason = normalizeString(reason);
|
|
231
|
+
await assertOwnerAccessContext();
|
|
205
232
|
return mutateAdmission(
|
|
206
233
|
sid,
|
|
207
234
|
`/${encodeURIComponent(aid)}/revoke`,
|
|
@@ -234,6 +261,7 @@ export async function setSessionAdmissionMode(
|
|
|
234
261
|
if (!["legacy", "required"].includes(normalizedMode)) {
|
|
235
262
|
throw new Error("mode must be legacy or required.");
|
|
236
263
|
}
|
|
264
|
+
await assertOwnerAccessContext();
|
|
237
265
|
const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
|
|
238
266
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
239
267
|
const result = await requestMutation(
|
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,18 +500,24 @@ export async function runAdmissionJoin(
|
|
|
471
500
|
headers: { Authorization: `Bearer ${auth.token}` },
|
|
472
501
|
});
|
|
473
502
|
state.status = polled.status;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
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
|
+
}
|
|
484
519
|
}
|
|
485
|
-
if (
|
|
520
|
+
if (claimable || TERMINAL.has(polled.status) || polled.status === "active") break;
|
|
486
521
|
const phase = polled.status === "approved" ? "identity" : "approval";
|
|
487
522
|
onPending({
|
|
488
523
|
admissionId: state.admissionId,
|
|
@@ -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
|
+
}
|
package/src/session/sync.js
CHANGED
|
@@ -19,6 +19,8 @@ const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
|
|
|
19
19
|
const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
|
|
20
20
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
21
21
|
const CIRCUIT_RESET_MS = 60_000;
|
|
22
|
+
// Message-action statuses whose body the caller needs to explain the refusal.
|
|
23
|
+
const MESSAGE_ACTION_REFUSAL_STATUSES = Object.freeze([409, 422]);
|
|
22
24
|
const SESSION_INGEST_LIMIT_PER_MINUTE = 500;
|
|
23
25
|
const HUMAN_MESSAGE_LIMIT_PER_MINUTE = 10;
|
|
24
26
|
const HUMAN_MESSAGE_MAX_LENGTH = 2_000;
|
|
@@ -374,6 +376,18 @@ function recordCircuitFailure(circuit, nowMs) {
|
|
|
374
376
|
persistCircuitState();
|
|
375
377
|
}
|
|
376
378
|
|
|
379
|
+
// 401 and 403 refuse one credential (an expired, revoked or narrower agent admission, or
|
|
380
|
+
// an identity the account has not granted). They say nothing about API health, so they
|
|
381
|
+
// never count toward the machine-wide outbound breaker; every other failure does.
|
|
382
|
+
function isCredentialRefusal(response) {
|
|
383
|
+
return Boolean(response) && (response.status === 401 || response.status === 403);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function recordOutboundFailure(response, nowMs) {
|
|
387
|
+
if (isCredentialRefusal(response)) return;
|
|
388
|
+
recordCircuitFailure(outboundCircuit, nowMs);
|
|
389
|
+
}
|
|
390
|
+
|
|
377
391
|
function recordCircuitSuccess(circuit) {
|
|
378
392
|
if (!circuit) {
|
|
379
393
|
return;
|
|
@@ -1033,18 +1047,23 @@ export async function syncSessionEventToApi(
|
|
|
1033
1047
|
if (response && response.status === 403) {
|
|
1034
1048
|
const body = forbiddenBody;
|
|
1035
1049
|
if (isIdentityForgeryBody(body)) {
|
|
1050
|
+
if (session.source === "session_admission") {
|
|
1051
|
+
// An admitted agent acts only on its admission: account-level agent grants
|
|
1052
|
+
// are neither created nor used for it.
|
|
1053
|
+
return { synced: false, reason: "api_403" };
|
|
1054
|
+
}
|
|
1036
1055
|
const agentId = normalizeString(event?.agent?.id);
|
|
1037
1056
|
if (!agentId || isReservedAgentIdForGrant(agentId)) {
|
|
1038
1057
|
// Reserved or empty — server enforcement is intentional, no grant
|
|
1039
1058
|
// attempt is meaningful. Treat as a normal 403 failure.
|
|
1040
|
-
|
|
1059
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1041
1060
|
return { synced: false, reason: "api_403" };
|
|
1042
1061
|
}
|
|
1043
1062
|
if (autoGrantAttemptedAgentIds.has(agentId)) {
|
|
1044
1063
|
// We already tried to grant this identity in a prior event in this
|
|
1045
1064
|
// process and either it succeeded but the server still says no, or
|
|
1046
1065
|
// the grant failed. Either way, don't loop — surface the 403.
|
|
1047
|
-
|
|
1066
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1048
1067
|
return { synced: false, reason: "api_403" };
|
|
1049
1068
|
}
|
|
1050
1069
|
rememberAutoGrantAttempt(agentId);
|
|
@@ -1081,7 +1100,7 @@ export async function syncSessionEventToApi(
|
|
|
1081
1100
|
// Treat 409 (already granted) as success — idempotent on the server.
|
|
1082
1101
|
const grantIdempotent = grantStatus === 409;
|
|
1083
1102
|
if (!grantOk && !grantIdempotent) {
|
|
1084
|
-
|
|
1103
|
+
recordOutboundFailure(grantResponse, normalizedNowMs);
|
|
1085
1104
|
return {
|
|
1086
1105
|
synced: false,
|
|
1087
1106
|
reason: `grant_failed_${grantStatus || "no_response"}`,
|
|
@@ -1109,7 +1128,7 @@ export async function syncSessionEventToApi(
|
|
|
1109
1128
|
autoGranted: true,
|
|
1110
1129
|
};
|
|
1111
1130
|
}
|
|
1112
|
-
|
|
1131
|
+
recordOutboundFailure(retryResponse, normalizedNowMs);
|
|
1113
1132
|
return {
|
|
1114
1133
|
synced: false,
|
|
1115
1134
|
reason: `api_${retryResponse ? retryResponse.status : "no_response"}`,
|
|
@@ -1117,7 +1136,7 @@ export async function syncSessionEventToApi(
|
|
|
1117
1136
|
}
|
|
1118
1137
|
}
|
|
1119
1138
|
|
|
1120
|
-
|
|
1139
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1121
1140
|
return {
|
|
1122
1141
|
synced: false,
|
|
1123
1142
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -1207,7 +1226,7 @@ async function syncSessionAuxPayload(
|
|
|
1207
1226
|
{ readSuccessBody: false },
|
|
1208
1227
|
);
|
|
1209
1228
|
if (!response || !response.ok) {
|
|
1210
|
-
|
|
1229
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1211
1230
|
return {
|
|
1212
1231
|
synced: false,
|
|
1213
1232
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -1843,7 +1862,7 @@ export async function renewSessionPresence(
|
|
|
1843
1862
|
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1844
1863
|
};
|
|
1845
1864
|
}
|
|
1846
|
-
|
|
1865
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1847
1866
|
return {
|
|
1848
1867
|
ok: false,
|
|
1849
1868
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -1958,7 +1977,7 @@ export async function requestSessionListenerStop(
|
|
|
1958
1977
|
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1959
1978
|
};
|
|
1960
1979
|
}
|
|
1961
|
-
|
|
1980
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1962
1981
|
return {
|
|
1963
1982
|
ok: false,
|
|
1964
1983
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -2083,7 +2102,7 @@ export async function updateSessionReadCursor(
|
|
|
2083
2102
|
updated: false,
|
|
2084
2103
|
};
|
|
2085
2104
|
}
|
|
2086
|
-
|
|
2105
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
2087
2106
|
return {
|
|
2088
2107
|
ok: false,
|
|
2089
2108
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -2990,12 +3009,20 @@ export async function createSessionMessageAction(
|
|
|
2990
3009
|
},
|
|
2991
3010
|
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
2992
3011
|
fetchImpl,
|
|
3012
|
+
{ errorBodyStatuses: MESSAGE_ACTION_REFUSAL_STATUSES },
|
|
2993
3013
|
);
|
|
2994
3014
|
if (!response || !response.ok) {
|
|
2995
|
-
|
|
3015
|
+
const status = response ? response.status : null;
|
|
3016
|
+
const refused = MESSAGE_ACTION_REFUSAL_STATUSES.includes(status);
|
|
3017
|
+
// A refusal (409 nothing-to-undo, 422 a server that predates the action) is an
|
|
3018
|
+
// answer about this request, not API health: it must not open the persisted
|
|
3019
|
+
// breaker that guards every other outbound write.
|
|
3020
|
+
if (!refused) recordOutboundFailure(response, normalizedNowMs);
|
|
2996
3021
|
return {
|
|
2997
3022
|
ok: false,
|
|
2998
|
-
reason: `api_${
|
|
3023
|
+
reason: `api_${status ?? "no_response"}`,
|
|
3024
|
+
status,
|
|
3025
|
+
error: refused && payload && typeof payload === "object" ? payload : null,
|
|
2999
3026
|
action: null,
|
|
3000
3027
|
};
|
|
3001
3028
|
}
|
|
@@ -3004,6 +3031,9 @@ export async function createSessionMessageAction(
|
|
|
3004
3031
|
ok: Boolean(payload?.ok ?? true),
|
|
3005
3032
|
reason: "",
|
|
3006
3033
|
duplicate: Boolean(payload?.duplicate),
|
|
3034
|
+
// A repeat reaction is a no-op whose key the server retains as a collapsed row
|
|
3035
|
+
// (sentinelayer-api#914): the id of that evidence row.
|
|
3036
|
+
collapsedActionId: normalizeString(payload?.collapsedActionId) || null,
|
|
3007
3037
|
action: payload?.action && typeof payload.action === "object" ? payload.action : null,
|
|
3008
3038
|
};
|
|
3009
3039
|
} catch (error) {
|