sentinelayer-cli 0.9.0 → 0.9.2
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/package.json +1 -1
- package/src/agents/backend/tools/timeout-audit.js +33 -17
- package/src/agents/devtestbot/runner.js +11 -5
- package/src/commands/session.js +565 -32
- package/src/legacy-cli.js +1 -1
- package/src/scan/generator.js +1 -1
- package/src/session/coordination-guidance.js +2 -1
- package/src/session/event-identity.js +139 -0
- package/src/session/listener.js +96 -2
- package/src/session/live-source.js +11 -2
- package/src/session/mentions.js +130 -0
- package/src/session/remote-hydrate.js +223 -8
- package/src/session/store.js +63 -0
- package/src/session/stream.js +17 -7
- package/src/session/sync.js +375 -26
- package/src/session/title-sync.js +107 -0
package/src/session/sync.js
CHANGED
|
@@ -14,6 +14,7 @@ const SESSION_INGEST_LIMIT_PER_MINUTE = 500;
|
|
|
14
14
|
const HUMAN_MESSAGE_LIMIT_PER_MINUTE = 10;
|
|
15
15
|
const HUMAN_MESSAGE_MAX_LENGTH = 2_000;
|
|
16
16
|
const HUMAN_MESSAGE_FETCH_LIMIT = 50;
|
|
17
|
+
const SESSION_EVENT_FETCH_LIMIT = 200;
|
|
17
18
|
|
|
18
19
|
// Audit §2.9: crash-recovery contract for in-memory circuit state.
|
|
19
20
|
// Persist outbound/inbound circuit state to disk so a process restart
|
|
@@ -103,6 +104,96 @@ const inboundCircuit = {
|
|
|
103
104
|
const sessionIngestWindowBySessionId = new Map();
|
|
104
105
|
const humanRelayWindowBySessionId = new Map();
|
|
105
106
|
|
|
107
|
+
// Per-process record of agent identities for which we have already issued an
|
|
108
|
+
// auto-grant attempt against `POST /api/v1/sessions/agent-grants`. Server-side
|
|
109
|
+
// agent-identity enforcement (PR #478) returns 403 IDENTITY_FORGERY when the
|
|
110
|
+
// active user has not granted the `agent.id` carried on a session event. The
|
|
111
|
+
// CLI auto-grants on the first 403 and retries the event POST exactly once,
|
|
112
|
+
// but if the grant itself fails (e.g. 422 from a malformed agent_id), we MUST
|
|
113
|
+
// NOT loop. This Set is the loop-breaker: insertion order is preserved (Set
|
|
114
|
+
// semantics in V8) so an LRU-ish trim drops the oldest entries first when the
|
|
115
|
+
// cap is exceeded. SessionId-agnostic by design — once a user grants
|
|
116
|
+
// `agent.id="codex"`, the grant is global to their account.
|
|
117
|
+
const AUTO_GRANT_ATTEMPT_CACHE_MAX = 50;
|
|
118
|
+
const autoGrantAttemptedAgentIds = new Set();
|
|
119
|
+
|
|
120
|
+
// Reserved agent_id values that the API treats specially — granting these
|
|
121
|
+
// is either a no-op or rejected outright. Skip the auto-grant round-trip and
|
|
122
|
+
// return the original 403 cleanly.
|
|
123
|
+
const AUTO_GRANT_RESERVED_PREFIXES = ["human-"];
|
|
124
|
+
const AUTO_GRANT_RESERVED_EXACT = new Set(["", "cli-user", "unknown"]);
|
|
125
|
+
|
|
126
|
+
// API role enum (per PR #478 server contract). Anything not in this set
|
|
127
|
+
// (notably the legacy `orchestrator` role we still emit locally) falls back
|
|
128
|
+
// to `coder`. This is a forward-compat workaround pending an API enum
|
|
129
|
+
// extension that adds `orchestrator`.
|
|
130
|
+
const AUTO_GRANT_VALID_ROLES = new Set([
|
|
131
|
+
"auditor",
|
|
132
|
+
"coder",
|
|
133
|
+
"coordinator",
|
|
134
|
+
"observer",
|
|
135
|
+
"reviewer",
|
|
136
|
+
]);
|
|
137
|
+
const AUTO_GRANT_DEFAULT_ROLE = "coder";
|
|
138
|
+
|
|
139
|
+
function rememberAutoGrantAttempt(agentId) {
|
|
140
|
+
if (!agentId) return;
|
|
141
|
+
if (autoGrantAttemptedAgentIds.has(agentId)) {
|
|
142
|
+
// Refresh insertion order so the most recently used entry survives
|
|
143
|
+
// LRU eviction when the cap is hit.
|
|
144
|
+
autoGrantAttemptedAgentIds.delete(agentId);
|
|
145
|
+
autoGrantAttemptedAgentIds.add(agentId);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
autoGrantAttemptedAgentIds.add(agentId);
|
|
149
|
+
while (autoGrantAttemptedAgentIds.size > AUTO_GRANT_ATTEMPT_CACHE_MAX) {
|
|
150
|
+
const oldest = autoGrantAttemptedAgentIds.values().next().value;
|
|
151
|
+
if (oldest === undefined) break;
|
|
152
|
+
autoGrantAttemptedAgentIds.delete(oldest);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isReservedAgentIdForGrant(agentId) {
|
|
157
|
+
const id = normalizeString(agentId);
|
|
158
|
+
if (AUTO_GRANT_RESERVED_EXACT.has(id)) return true;
|
|
159
|
+
for (const prefix of AUTO_GRANT_RESERVED_PREFIXES) {
|
|
160
|
+
if (id.startsWith(prefix)) return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveGrantRole(rawRole) {
|
|
166
|
+
const normalized = normalizeString(rawRole).toLowerCase();
|
|
167
|
+
if (AUTO_GRANT_VALID_ROLES.has(normalized)) {
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
return AUTO_GRANT_DEFAULT_ROLE;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readResponseJsonSafely(response) {
|
|
174
|
+
if (!response || typeof response.json !== "function") {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return await response.json();
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isIdentityForgeryBody(body) {
|
|
185
|
+
if (!body || typeof body !== "object") return false;
|
|
186
|
+
const error = body.error;
|
|
187
|
+
if (!error || typeof error !== "object") return false;
|
|
188
|
+
return normalizeString(error.code) === "IDENTITY_FORGERY";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Test-only: clear the per-process auto-grant attempt cache so unit tests
|
|
192
|
+
// exercising the loop-breaker don't bleed state across runs.
|
|
193
|
+
export function __resetAutoGrantCacheForTests() {
|
|
194
|
+
autoGrantAttemptedAgentIds.clear();
|
|
195
|
+
}
|
|
196
|
+
|
|
106
197
|
const SECRET_LIKE_PATTERN =
|
|
107
198
|
/(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----|SENTINELAYER_TOKEN|AIDENID_API_KEY|NPM_TOKEN|xox[baprs]-[A-Za-z0-9-]+)/i;
|
|
108
199
|
|
|
@@ -130,6 +221,39 @@ function normalizePositiveInteger(value, fallbackValue) {
|
|
|
130
221
|
return Math.floor(normalized);
|
|
131
222
|
}
|
|
132
223
|
|
|
224
|
+
function eventTimestampMs(event = {}) {
|
|
225
|
+
for (const key of ["ts", "timestamp", "createdAt", "at"]) {
|
|
226
|
+
const epoch = Date.parse(normalizeString(event?.[key]));
|
|
227
|
+
if (Number.isFinite(epoch)) {
|
|
228
|
+
return epoch;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function eventSequenceNumber(event = {}) {
|
|
235
|
+
for (const key of ["sequenceId", "sequence_id", "sequence"]) {
|
|
236
|
+
const value = Number(event?.[key]);
|
|
237
|
+
if (Number.isFinite(value)) {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function chronologicalSessionEvents(events = []) {
|
|
245
|
+
return (Array.isArray(events) ? events : [])
|
|
246
|
+
.map((event, index) => ({ event, index }))
|
|
247
|
+
.sort((left, right) => {
|
|
248
|
+
const timeDiff = eventTimestampMs(left.event) - eventTimestampMs(right.event);
|
|
249
|
+
if (timeDiff !== 0) return timeDiff;
|
|
250
|
+
const sequenceDiff = eventSequenceNumber(left.event) - eventSequenceNumber(right.event);
|
|
251
|
+
if (sequenceDiff !== 0) return sequenceDiff;
|
|
252
|
+
return left.index - right.index;
|
|
253
|
+
})
|
|
254
|
+
.map((entry) => entry.event);
|
|
255
|
+
}
|
|
256
|
+
|
|
133
257
|
// Session-apiUrl allowlist. A session file is an untrusted input: if an
|
|
134
258
|
// attacker can write to .sentinelayer/sessions/<id>/meta.json (or trick a user
|
|
135
259
|
// into joining a crafted session), they can redirect every outbound fetch.
|
|
@@ -435,7 +559,11 @@ export async function syncSessionEventToApi(
|
|
|
435
559
|
};
|
|
436
560
|
}
|
|
437
561
|
|
|
438
|
-
if (
|
|
562
|
+
if (
|
|
563
|
+
event?.payload?.relayedFromApi ||
|
|
564
|
+
normalizeString(event?.cursor) ||
|
|
565
|
+
normalizeString(event?.sequenceId || event?.sequence_id)
|
|
566
|
+
) {
|
|
439
567
|
return { synced: false, reason: "relay_event_skip" };
|
|
440
568
|
}
|
|
441
569
|
|
|
@@ -456,35 +584,125 @@ export async function syncSessionEventToApi(
|
|
|
456
584
|
|
|
457
585
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
458
586
|
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events`;
|
|
587
|
+
const requestBody = JSON.stringify({
|
|
588
|
+
event,
|
|
589
|
+
source: "cli",
|
|
590
|
+
});
|
|
591
|
+
const requestInit = {
|
|
592
|
+
method: "POST",
|
|
593
|
+
headers: {
|
|
594
|
+
"Content-Type": "application/json",
|
|
595
|
+
Authorization: `Bearer ${session.token}`,
|
|
596
|
+
},
|
|
597
|
+
body: requestBody,
|
|
598
|
+
};
|
|
599
|
+
const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
459
600
|
|
|
460
601
|
try {
|
|
461
|
-
const response = await fetchImpl(
|
|
462
|
-
endpoint,
|
|
463
|
-
{
|
|
464
|
-
method: "POST",
|
|
465
|
-
headers: {
|
|
466
|
-
"Content-Type": "application/json",
|
|
467
|
-
Authorization: `Bearer ${session.token}`,
|
|
468
|
-
},
|
|
469
|
-
body: JSON.stringify({
|
|
470
|
-
event,
|
|
471
|
-
source: "cli",
|
|
472
|
-
}),
|
|
473
|
-
},
|
|
474
|
-
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
|
|
475
|
-
);
|
|
602
|
+
const response = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
|
|
476
603
|
|
|
477
|
-
if (
|
|
478
|
-
|
|
604
|
+
if (response && response.ok) {
|
|
605
|
+
recordCircuitSuccess(outboundCircuit);
|
|
479
606
|
return {
|
|
480
|
-
synced:
|
|
481
|
-
|
|
607
|
+
synced: true,
|
|
608
|
+
status: response.status,
|
|
482
609
|
};
|
|
483
610
|
}
|
|
484
|
-
|
|
611
|
+
|
|
612
|
+
// Handle PR #478 IDENTITY_FORGERY: server now requires the active user to
|
|
613
|
+
// have explicitly granted any `agent.id` posted to /events. Without this
|
|
614
|
+
// auto-grant, every CLI-driven agent post returns 403 and the user sees
|
|
615
|
+
// their agents "talking" locally while the API has zero record. We attempt
|
|
616
|
+
// the grant once per agentId per process, then retry the event POST once.
|
|
617
|
+
if (response && response.status === 403) {
|
|
618
|
+
const body = await readResponseJsonSafely(response);
|
|
619
|
+
if (isIdentityForgeryBody(body)) {
|
|
620
|
+
const agentId = normalizeString(event?.agent?.id);
|
|
621
|
+
if (!agentId || isReservedAgentIdForGrant(agentId)) {
|
|
622
|
+
// Reserved or empty — server enforcement is intentional, no grant
|
|
623
|
+
// attempt is meaningful. Treat as a normal 403 failure.
|
|
624
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
625
|
+
return { synced: false, reason: "api_403" };
|
|
626
|
+
}
|
|
627
|
+
if (autoGrantAttemptedAgentIds.has(agentId)) {
|
|
628
|
+
// We already tried to grant this identity in a prior event in this
|
|
629
|
+
// process and either it succeeded but the server still says no, or
|
|
630
|
+
// the grant failed. Either way, don't loop — surface the 403.
|
|
631
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
632
|
+
return { synced: false, reason: "api_403" };
|
|
633
|
+
}
|
|
634
|
+
rememberAutoGrantAttempt(agentId);
|
|
635
|
+
|
|
636
|
+
const grantRole = resolveGrantRole(event?.agent?.role);
|
|
637
|
+
const grantEndpoint = `${apiBaseUrl}/api/v1/sessions/agent-grants`;
|
|
638
|
+
let grantResponse = null;
|
|
639
|
+
try {
|
|
640
|
+
grantResponse = await fetchImpl(
|
|
641
|
+
grantEndpoint,
|
|
642
|
+
{
|
|
643
|
+
method: "POST",
|
|
644
|
+
headers: {
|
|
645
|
+
"Content-Type": "application/json",
|
|
646
|
+
Authorization: `Bearer ${session.token}`,
|
|
647
|
+
},
|
|
648
|
+
body: JSON.stringify({
|
|
649
|
+
agent_id: agentId,
|
|
650
|
+
role: grantRole,
|
|
651
|
+
}),
|
|
652
|
+
},
|
|
653
|
+
resolvedTimeoutMs
|
|
654
|
+
);
|
|
655
|
+
} catch (grantError) {
|
|
656
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
657
|
+
return {
|
|
658
|
+
synced: false,
|
|
659
|
+
reason: normalizeString(grantError?.message) || "grant_failed",
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const grantStatus = grantResponse ? grantResponse.status : 0;
|
|
664
|
+
const grantOk = Boolean(grantResponse && grantResponse.ok);
|
|
665
|
+
// Treat 409 (already granted) as success — idempotent on the server.
|
|
666
|
+
const grantIdempotent = grantStatus === 409;
|
|
667
|
+
if (!grantOk && !grantIdempotent) {
|
|
668
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
669
|
+
return {
|
|
670
|
+
synced: false,
|
|
671
|
+
reason: `grant_failed_${grantStatus || "no_response"}`,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Retry the original event POST exactly once.
|
|
676
|
+
let retryResponse;
|
|
677
|
+
try {
|
|
678
|
+
retryResponse = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
|
|
679
|
+
} catch (retryError) {
|
|
680
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
681
|
+
return {
|
|
682
|
+
synced: false,
|
|
683
|
+
reason: normalizeString(retryError?.message) || "sync_failed",
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
if (retryResponse && retryResponse.ok) {
|
|
687
|
+
recordCircuitSuccess(outboundCircuit);
|
|
688
|
+
return {
|
|
689
|
+
synced: true,
|
|
690
|
+
status: retryResponse.status,
|
|
691
|
+
autoGranted: true,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
695
|
+
return {
|
|
696
|
+
synced: false,
|
|
697
|
+
reason: `api_${retryResponse ? retryResponse.status : "no_response"}`,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
485
703
|
return {
|
|
486
|
-
synced:
|
|
487
|
-
|
|
704
|
+
synced: false,
|
|
705
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
488
706
|
};
|
|
489
707
|
} catch (error) {
|
|
490
708
|
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
@@ -622,6 +840,7 @@ export async function pollHumanMessages(
|
|
|
622
840
|
since = null,
|
|
623
841
|
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
624
842
|
limit = HUMAN_MESSAGE_FETCH_LIMIT,
|
|
843
|
+
forceCircuitProbe = false,
|
|
625
844
|
resolveAuthSession = resolveActiveAuthSession,
|
|
626
845
|
fetchImpl = fetchWithTimeout,
|
|
627
846
|
nowMs = Date.now,
|
|
@@ -638,7 +857,7 @@ export async function pollHumanMessages(
|
|
|
638
857
|
}
|
|
639
858
|
|
|
640
859
|
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
641
|
-
if (isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
860
|
+
if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
642
861
|
return {
|
|
643
862
|
ok: false,
|
|
644
863
|
reason: "circuit_breaker_open",
|
|
@@ -781,6 +1000,7 @@ export async function pollSessionEvents(
|
|
|
781
1000
|
since = null,
|
|
782
1001
|
limit = 200,
|
|
783
1002
|
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1003
|
+
forceCircuitProbe = false,
|
|
784
1004
|
resolveAuthSession = resolveActiveAuthSession,
|
|
785
1005
|
fetchImpl = fetchWithTimeout,
|
|
786
1006
|
nowMs = Date.now,
|
|
@@ -797,7 +1017,7 @@ export async function pollSessionEvents(
|
|
|
797
1017
|
}
|
|
798
1018
|
|
|
799
1019
|
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
800
|
-
if (isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
1020
|
+
if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
801
1021
|
return {
|
|
802
1022
|
ok: false,
|
|
803
1023
|
reason: "circuit_breaker_open",
|
|
@@ -836,7 +1056,10 @@ export async function pollSessionEvents(
|
|
|
836
1056
|
if (normalizedSince) {
|
|
837
1057
|
query.set("after", normalizedSince);
|
|
838
1058
|
}
|
|
839
|
-
query.set(
|
|
1059
|
+
query.set(
|
|
1060
|
+
"limit",
|
|
1061
|
+
String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, SESSION_EVENT_FETCH_LIMIT))))
|
|
1062
|
+
);
|
|
840
1063
|
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events?${query.toString()}`;
|
|
841
1064
|
|
|
842
1065
|
try {
|
|
@@ -889,6 +1112,131 @@ export async function pollSessionEvents(
|
|
|
889
1112
|
}
|
|
890
1113
|
}
|
|
891
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* Poll the latest durable session events page via the reverse-history endpoint.
|
|
1117
|
+
*
|
|
1118
|
+
* This powers `sl session read --remote --tail`: a forward cursor can be many
|
|
1119
|
+
* pages behind in long rooms, but a tail read must show the latest messages now.
|
|
1120
|
+
* The API returns newest-first for `/events/before`; callers get chronological
|
|
1121
|
+
* order so appending/displaying matches the local NDJSON stream.
|
|
1122
|
+
*/
|
|
1123
|
+
export async function pollSessionEventsBefore(
|
|
1124
|
+
sessionId,
|
|
1125
|
+
{
|
|
1126
|
+
targetPath = process.cwd(),
|
|
1127
|
+
beforeSequence = null,
|
|
1128
|
+
limit = 50,
|
|
1129
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1130
|
+
forceCircuitProbe = false,
|
|
1131
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1132
|
+
fetchImpl = fetchWithTimeout,
|
|
1133
|
+
nowMs = Date.now,
|
|
1134
|
+
} = {}
|
|
1135
|
+
) {
|
|
1136
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1137
|
+
if (!normalizedSessionId) {
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
reason: "invalid_session_id",
|
|
1141
|
+
events: [],
|
|
1142
|
+
cursor: null,
|
|
1143
|
+
beforeSequence: null,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
1148
|
+
if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
1149
|
+
return {
|
|
1150
|
+
ok: false,
|
|
1151
|
+
reason: "circuit_breaker_open",
|
|
1152
|
+
events: [],
|
|
1153
|
+
cursor: null,
|
|
1154
|
+
beforeSequence: null,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
let session = null;
|
|
1159
|
+
try {
|
|
1160
|
+
session = await resolveAuthSession({
|
|
1161
|
+
cwd: targetPath,
|
|
1162
|
+
env: process.env,
|
|
1163
|
+
autoRotate: false,
|
|
1164
|
+
});
|
|
1165
|
+
} catch {
|
|
1166
|
+
return {
|
|
1167
|
+
ok: false,
|
|
1168
|
+
reason: "no_session",
|
|
1169
|
+
events: [],
|
|
1170
|
+
cursor: null,
|
|
1171
|
+
beforeSequence: null,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
if (!session || !session.token) {
|
|
1175
|
+
return {
|
|
1176
|
+
ok: false,
|
|
1177
|
+
reason: "not_authenticated",
|
|
1178
|
+
events: [],
|
|
1179
|
+
cursor: null,
|
|
1180
|
+
beforeSequence: null,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1185
|
+
const query = new URLSearchParams();
|
|
1186
|
+
const normalizedBeforeSequence = Number(beforeSequence);
|
|
1187
|
+
if (Number.isFinite(normalizedBeforeSequence) && normalizedBeforeSequence > 0) {
|
|
1188
|
+
query.set("beforeSequence", String(Math.floor(normalizedBeforeSequence)));
|
|
1189
|
+
}
|
|
1190
|
+
query.set(
|
|
1191
|
+
"limit",
|
|
1192
|
+
String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, 50))))
|
|
1193
|
+
);
|
|
1194
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events/before?${query.toString()}`;
|
|
1195
|
+
|
|
1196
|
+
try {
|
|
1197
|
+
const response = await fetchImpl(
|
|
1198
|
+
endpoint,
|
|
1199
|
+
{
|
|
1200
|
+
method: "GET",
|
|
1201
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
1202
|
+
},
|
|
1203
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
|
|
1204
|
+
);
|
|
1205
|
+
if (!response || !response.ok) {
|
|
1206
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
1207
|
+
return {
|
|
1208
|
+
ok: false,
|
|
1209
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1210
|
+
events: [],
|
|
1211
|
+
cursor: null,
|
|
1212
|
+
beforeSequence: Number.isFinite(normalizedBeforeSequence) ? normalizedBeforeSequence : null,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
const payload = await response.json().catch(() => ({}));
|
|
1216
|
+
recordCircuitSuccess(inboundCircuit);
|
|
1217
|
+
|
|
1218
|
+
const events = chronologicalSessionEvents(payload?.events || []);
|
|
1219
|
+
const lastEvent = events[events.length - 1] || null;
|
|
1220
|
+
const firstEvent = events[0] || null;
|
|
1221
|
+
return {
|
|
1222
|
+
ok: true,
|
|
1223
|
+
reason: "",
|
|
1224
|
+
events,
|
|
1225
|
+
cursor: normalizeString(lastEvent?.cursor) || null,
|
|
1226
|
+
beforeSequence: eventSequenceNumber(firstEvent) || null,
|
|
1227
|
+
};
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
1230
|
+
return {
|
|
1231
|
+
ok: false,
|
|
1232
|
+
reason: normalizeString(error?.message) || "poll_failed",
|
|
1233
|
+
events: [],
|
|
1234
|
+
cursor: null,
|
|
1235
|
+
beforeSequence: Number.isFinite(normalizedBeforeSequence) ? normalizedBeforeSequence : null,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
892
1240
|
|
|
893
1241
|
/**
|
|
894
1242
|
* List sessions owned by the active user via `GET /api/v1/sessions`.
|
|
@@ -1052,6 +1400,7 @@ export function resetSessionSyncStateForTests() {
|
|
|
1052
1400
|
inboundCircuit.openedAtMs = 0;
|
|
1053
1401
|
sessionIngestWindowBySessionId.clear();
|
|
1054
1402
|
humanRelayWindowBySessionId.clear();
|
|
1403
|
+
autoGrantAttemptedAgentIds.clear();
|
|
1055
1404
|
// Tests that exercise the network path explicitly need the
|
|
1056
1405
|
// SENTINELAYER_SKIP_REMOTE_SYNC guard off — otherwise the function
|
|
1057
1406
|
// short-circuits before the mocked fetchImpl is ever called. Tests that
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import { SentinelayerApiError, requestJsonMutation } from "../auth/http.js";
|
|
4
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
5
|
+
import { recordSessionRemoteTitleSync } from "./store.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TITLE_SYNC_TIMEOUT_MS = 2_000;
|
|
8
|
+
const DEFAULT_TITLE_SYNC_RETRY_DELAY_MS = 200;
|
|
9
|
+
|
|
10
|
+
function normalizeString(value) {
|
|
11
|
+
return String(value || "").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function remoteSessionSyncDisabled(env = process.env) {
|
|
15
|
+
return String(env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeFailureReason(error) {
|
|
19
|
+
if (error instanceof SentinelayerApiError) {
|
|
20
|
+
return error.code || `api_${error.status || "error"}`;
|
|
21
|
+
}
|
|
22
|
+
return normalizeString(error?.message) || "sync_failed";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isTerminalTitleSyncError(error) {
|
|
26
|
+
return (
|
|
27
|
+
error instanceof SentinelayerApiError &&
|
|
28
|
+
(error.status === 422 || error.code === "INVALID_SESSION_TITLE")
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function pushSessionTitleToApi(
|
|
33
|
+
sessionId,
|
|
34
|
+
title,
|
|
35
|
+
{
|
|
36
|
+
targetPath,
|
|
37
|
+
env = process.env,
|
|
38
|
+
timeoutMs = DEFAULT_TITLE_SYNC_TIMEOUT_MS,
|
|
39
|
+
maxRetries = 1,
|
|
40
|
+
retryDelayMs = DEFAULT_TITLE_SYNC_RETRY_DELAY_MS,
|
|
41
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
42
|
+
requestMutation = requestJsonMutation,
|
|
43
|
+
recordRemoteTitleSync = recordSessionRemoteTitleSync,
|
|
44
|
+
} = {},
|
|
45
|
+
) {
|
|
46
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
47
|
+
const normalizedTitle = normalizeString(title);
|
|
48
|
+
if (!normalizedSessionId || !normalizedTitle) {
|
|
49
|
+
return { synced: false, reason: "invalid_input" };
|
|
50
|
+
}
|
|
51
|
+
if (remoteSessionSyncDisabled(env)) {
|
|
52
|
+
return { synced: false, reason: "remote_sync_disabled" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await recordRemoteTitleSync(normalizedSessionId, {
|
|
56
|
+
targetPath,
|
|
57
|
+
title: normalizedTitle,
|
|
58
|
+
pending: true,
|
|
59
|
+
failureReason: "pending",
|
|
60
|
+
}).catch(() => null);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const session = await resolveAuthSession({
|
|
64
|
+
cwd: targetPath,
|
|
65
|
+
env,
|
|
66
|
+
autoRotate: true,
|
|
67
|
+
});
|
|
68
|
+
if (!session?.token || !session?.apiUrl) {
|
|
69
|
+
await recordRemoteTitleSync(normalizedSessionId, {
|
|
70
|
+
targetPath,
|
|
71
|
+
title: normalizedTitle,
|
|
72
|
+
pending: true,
|
|
73
|
+
failureReason: "not_authenticated",
|
|
74
|
+
}).catch(() => null);
|
|
75
|
+
return { synced: false, reason: "not_authenticated" };
|
|
76
|
+
}
|
|
77
|
+
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
78
|
+
const result = await requestMutation(
|
|
79
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/title`,
|
|
80
|
+
{
|
|
81
|
+
method: "POST",
|
|
82
|
+
operationName: "session.set_title",
|
|
83
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
84
|
+
body: { title: normalizedTitle },
|
|
85
|
+
timeoutMs,
|
|
86
|
+
maxRetries,
|
|
87
|
+
retryDelayMs,
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
await recordRemoteTitleSync(normalizedSessionId, {
|
|
91
|
+
targetPath,
|
|
92
|
+
title: normalizedTitle,
|
|
93
|
+
pending: false,
|
|
94
|
+
}).catch(() => null);
|
|
95
|
+
return { synced: true, status: result?.status || 200 };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const reason = normalizeFailureReason(error);
|
|
98
|
+
const terminal = isTerminalTitleSyncError(error);
|
|
99
|
+
await recordRemoteTitleSync(normalizedSessionId, {
|
|
100
|
+
targetPath,
|
|
101
|
+
title: normalizedTitle,
|
|
102
|
+
pending: !terminal,
|
|
103
|
+
failureReason: reason,
|
|
104
|
+
}).catch(() => null);
|
|
105
|
+
return { synced: false, reason, terminal };
|
|
106
|
+
}
|
|
107
|
+
}
|