sentinelayer-cli 0.10.0 → 0.10.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/commands/session.js +39 -8
- package/src/legacy-cli.js +1 -1
- package/src/scan/generator.js +1 -1
- package/src/session/store.js +2 -1
- package/src/session/stream.js +122 -3
- package/src/session/sync.js +85 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -347,14 +347,37 @@ async function ensureLocalSessionForRemoteCommand(
|
|
|
347
347
|
}
|
|
348
348
|
const existingStatus = normalizeString(existing.status).toLowerCase();
|
|
349
349
|
const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
|
|
350
|
+
let verifiedRemoteSession = remoteSession;
|
|
351
|
+
let verifiedRemoteStatus = normalizeString(
|
|
352
|
+
verifiedRemoteSession?.archiveStatus || verifiedRemoteSession?.status,
|
|
353
|
+
).toLowerCase();
|
|
350
354
|
if (locallyClosedByStatus && !skipRemoteProbe) {
|
|
355
|
+
const verification = await verifyRemoteSession(sessionId, { targetPath }).catch((error) => ({
|
|
356
|
+
ok: false,
|
|
357
|
+
reason: normalizeString(error?.message) || "verify_failed",
|
|
358
|
+
}));
|
|
359
|
+
if (!verification?.ok) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`Session '${sessionId}' is ${existingStatus} locally and remote verification failed (${verification?.reason || "unknown"}).`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
verifiedRemoteSession = verification.session || verifiedRemoteSession;
|
|
365
|
+
verifiedRemoteStatus = normalizeString(
|
|
366
|
+
verifiedRemoteSession?.archiveStatus || verifiedRemoteSession?.status,
|
|
367
|
+
).toLowerCase();
|
|
368
|
+
}
|
|
369
|
+
if (
|
|
370
|
+
locallyClosedByStatus &&
|
|
371
|
+
verifiedRemoteStatus &&
|
|
372
|
+
!["active", "pending"].includes(verifiedRemoteStatus)
|
|
373
|
+
) {
|
|
351
374
|
throw new Error(
|
|
352
|
-
`Session '${sessionId}' is ${existingStatus} locally
|
|
375
|
+
`Session '${sessionId}' is ${existingStatus} locally and remote status is ${verifiedRemoteStatus}; refusing to reopen a closed session.`,
|
|
353
376
|
);
|
|
354
377
|
}
|
|
355
378
|
|
|
356
|
-
let access = { accessible: Boolean(skipRemoteProbe), reason: "" };
|
|
357
|
-
if (!skipRemoteProbe) {
|
|
379
|
+
let access = { accessible: Boolean(skipRemoteProbe || locallyClosedByStatus), reason: "" };
|
|
380
|
+
if (!skipRemoteProbe && !locallyClosedByStatus) {
|
|
358
381
|
access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
|
|
359
382
|
accessible: false,
|
|
360
383
|
reason: normalizeString(error?.message) || "probe_failed",
|
|
@@ -368,12 +391,13 @@ async function ensureLocalSessionForRemoteCommand(
|
|
|
368
391
|
|
|
369
392
|
const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
|
|
370
393
|
targetPath,
|
|
371
|
-
title,
|
|
394
|
+
title: title || normalizeString(verifiedRemoteSession?.title),
|
|
395
|
+
expiresAt: normalizeString(verifiedRemoteSession?.expiresAt),
|
|
372
396
|
lastInteractionAt:
|
|
373
|
-
normalizeString(
|
|
374
|
-
normalizeString(
|
|
375
|
-
normalizeString(
|
|
376
|
-
normalizeString(
|
|
397
|
+
normalizeString(verifiedRemoteSession?.lastInteractionAt) ||
|
|
398
|
+
normalizeString(verifiedRemoteSession?.lastActivityAt) ||
|
|
399
|
+
normalizeString(verifiedRemoteSession?.updatedAt) ||
|
|
400
|
+
normalizeString(verifiedRemoteSession?.createdAt),
|
|
377
401
|
});
|
|
378
402
|
return { materialized: false, refreshed: Boolean(refreshed), session: refreshed || existing };
|
|
379
403
|
}
|
|
@@ -396,6 +420,13 @@ async function ensureLocalSessionForRemoteCommand(
|
|
|
396
420
|
targetPath,
|
|
397
421
|
sessionId,
|
|
398
422
|
title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
|
|
423
|
+
createdAt: normalizeString(remoteSession?.createdAt),
|
|
424
|
+
expiresAt: normalizeString(remoteSession?.expiresAt),
|
|
425
|
+
lastInteractionAt:
|
|
426
|
+
normalizeString(remoteSession?.lastInteractionAt) ||
|
|
427
|
+
normalizeString(remoteSession?.lastActivityAt) ||
|
|
428
|
+
normalizeString(remoteSession?.updatedAt) ||
|
|
429
|
+
normalizeString(remoteSession?.createdAt),
|
|
399
430
|
});
|
|
400
431
|
return { materialized: true, refreshed: false, session: created };
|
|
401
432
|
}
|
package/src/legacy-cli.js
CHANGED
|
@@ -2316,7 +2316,7 @@ jobs:
|
|
|
2316
2316
|
fi
|
|
2317
2317
|
- name: Run Omar Gate
|
|
2318
2318
|
id: omar
|
|
2319
|
-
uses: mrrCarter/sentinelayer-v1-action@
|
|
2319
|
+
uses: mrrCarter/sentinelayer-v1-action@4cb3063e04e3b899981b25f6918b26f70d35a8d4
|
|
2320
2320
|
with:
|
|
2321
2321
|
sentinelayer_token: \${{ secrets.${normalizedSecret} }}${specIdBindingLine}
|
|
2322
2322
|
scan_mode: \${{ github.event_name == 'workflow_dispatch' && inputs.scan_mode || 'deep' }}
|
package/src/scan/generator.js
CHANGED
|
@@ -2,7 +2,7 @@ import YAML from "yaml";
|
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_SCAN_WORKFLOW_PATH = ".github/workflows/omar-gate.yml";
|
|
4
4
|
export const DEFAULT_SCAN_SECRET_NAME = "SENTINELAYER_TOKEN";
|
|
5
|
-
export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@
|
|
5
|
+
export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@4cb3063e04e3b899981b25f6918b26f70d35a8d4";
|
|
6
6
|
export const SUPPORTED_E2E_HINTS = Object.freeze(["auto", "yes", "no"]);
|
|
7
7
|
export const SUPPORTED_PLAYWRIGHT_MODES = Object.freeze(["auto", "off", "baseline", "audit"]);
|
|
8
8
|
|
package/src/session/store.js
CHANGED
|
@@ -537,6 +537,7 @@ export async function refreshSessionCacheForRemoteActivity(
|
|
|
537
537
|
targetPath = process.cwd(),
|
|
538
538
|
title = "",
|
|
539
539
|
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
540
|
+
expiresAt = "",
|
|
540
541
|
lastInteractionAt = "",
|
|
541
542
|
nowIso = new Date().toISOString(),
|
|
542
543
|
} = {}
|
|
@@ -563,7 +564,7 @@ export async function refreshSessionCacheForRemoteActivity(
|
|
|
563
564
|
const metadata = {
|
|
564
565
|
...loaded.metadata,
|
|
565
566
|
updatedAt: nowIso,
|
|
566
|
-
expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
|
|
567
|
+
expiresAt: normalizeIsoTimestamp(expiresAt, toIsoAfterSeconds(nowIso, normalizedTtlSeconds)),
|
|
567
568
|
ttlSeconds: normalizedTtlSeconds,
|
|
568
569
|
status: SESSION_STATUS_ACTIVE,
|
|
569
570
|
expiredAt: null,
|
package/src/session/stream.js
CHANGED
|
@@ -6,13 +6,15 @@ import { createAgentEvent, normalizeAgentEvent } from "../events/schema.js";
|
|
|
6
6
|
import { enrichEventWithMentions } from "./mentions.js";
|
|
7
7
|
import { resolveSessionPaths } from "./paths.js";
|
|
8
8
|
import { redactEventPayload } from "./redact.js";
|
|
9
|
-
import { syncSessionEventToApi } from "./sync.js";
|
|
9
|
+
import { fetchSessionFromApi, listSessionsFromApi, syncSessionEventToApi } from "./sync.js";
|
|
10
10
|
|
|
11
11
|
const DEFAULT_POLL_MS = 500;
|
|
12
12
|
const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
|
|
13
13
|
const DEFAULT_LOCK_STALE_MS = 30_000;
|
|
14
14
|
const DEFAULT_LOCK_POLL_MS = 25;
|
|
15
15
|
const DEFAULT_MAX_STREAM_EVENTS = 10_000;
|
|
16
|
+
const DEFAULT_REMOTE_REFRESH_TTL_SECONDS = 24 * 60 * 60;
|
|
17
|
+
const REMOTE_REFRESH_TIMEOUT_MS = 2_500;
|
|
16
18
|
|
|
17
19
|
function normalizeString(value) {
|
|
18
20
|
return String(value || "").trim();
|
|
@@ -41,6 +43,15 @@ function normalizePositiveInteger(value, fallbackValue) {
|
|
|
41
43
|
return Math.floor(normalized);
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
function toIsoAfterSeconds(baseIso, seconds) {
|
|
47
|
+
const baseEpoch = Date.parse(normalizeIsoTimestamp(baseIso, new Date().toISOString()));
|
|
48
|
+
const normalizedSeconds = normalizePositiveInteger(
|
|
49
|
+
seconds,
|
|
50
|
+
DEFAULT_REMOTE_REFRESH_TTL_SECONDS,
|
|
51
|
+
);
|
|
52
|
+
return new Date(baseEpoch + normalizedSeconds * 1000).toISOString();
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
async function readSessionMetadata(paths) {
|
|
45
56
|
try {
|
|
46
57
|
const raw = await fsp.readFile(paths.metadataPath, "utf-8");
|
|
@@ -64,6 +75,108 @@ async function writeSessionMetadata(paths, metadata = {}) {
|
|
|
64
75
|
await fsp.rename(tmpPath, paths.metadataPath);
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
function remoteStatusAllowsLocalRefresh(remoteSession = {}) {
|
|
79
|
+
const status = normalizeString(remoteSession.archiveStatus || remoteSession.status).toLowerCase();
|
|
80
|
+
return !status || status === "active" || status === "pending";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveRemoteSessionStatus(remoteSession = {}) {
|
|
84
|
+
return normalizeString(remoteSession.archiveStatus || remoteSession.status).toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveRemoteRefreshCandidate(sessionId, { targetPath = process.cwd() } = {}) {
|
|
88
|
+
const singletonResult = await fetchSessionFromApi(sessionId, {
|
|
89
|
+
targetPath,
|
|
90
|
+
timeoutMs: REMOTE_REFRESH_TIMEOUT_MS,
|
|
91
|
+
}).catch((error) => ({
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: normalizeString(error?.message) || "fetch_failed",
|
|
94
|
+
session: null,
|
|
95
|
+
}));
|
|
96
|
+
if (singletonResult?.ok && singletonResult.session) {
|
|
97
|
+
if (!remoteStatusAllowsLocalRefresh(singletonResult.session)) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: `remote_${resolveRemoteSessionStatus(singletonResult.session) || "closed"}`,
|
|
101
|
+
remoteSession: singletonResult.session,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, reason: "", remoteSession: singletonResult.session };
|
|
105
|
+
}
|
|
106
|
+
if (singletonResult?.status && singletonResult.status !== 404) {
|
|
107
|
+
return { ok: false, reason: singletonResult.reason || `api_${singletonResult.status}` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const listResult = await listSessionsFromApi({
|
|
111
|
+
targetPath,
|
|
112
|
+
includeArchived: true,
|
|
113
|
+
limit: 200,
|
|
114
|
+
timeoutMs: REMOTE_REFRESH_TIMEOUT_MS,
|
|
115
|
+
}).catch((error) => ({
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: normalizeString(error?.message) || "list_failed",
|
|
118
|
+
sessions: [],
|
|
119
|
+
}));
|
|
120
|
+
if (listResult?.ok) {
|
|
121
|
+
const remoteSession = (listResult.sessions || []).find(
|
|
122
|
+
(entry) => normalizeString(entry?.sessionId) === sessionId,
|
|
123
|
+
);
|
|
124
|
+
if (remoteSession) {
|
|
125
|
+
if (!remoteStatusAllowsLocalRefresh(remoteSession)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: `remote_${resolveRemoteSessionStatus(remoteSession) || "closed"}`,
|
|
129
|
+
remoteSession,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, reason: "", remoteSession };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { ok: false, reason: listResult?.reason || singletonResult?.reason || "remote_unavailable" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function refreshExpiredMetadataFromRemote(paths, metadata = {}, { targetPath = process.cwd() } = {}) {
|
|
140
|
+
const candidate = await resolveRemoteRefreshCandidate(paths.sessionId, { targetPath });
|
|
141
|
+
if (!candidate?.ok) {
|
|
142
|
+
return { refreshed: false, reason: candidate?.reason || "remote_unavailable", metadata };
|
|
143
|
+
}
|
|
144
|
+
const remoteSession = candidate.remoteSession || {};
|
|
145
|
+
const nowIso = new Date().toISOString();
|
|
146
|
+
const ttlSeconds = normalizePositiveInteger(
|
|
147
|
+
metadata.ttlSeconds,
|
|
148
|
+
DEFAULT_REMOTE_REFRESH_TTL_SECONDS,
|
|
149
|
+
);
|
|
150
|
+
const remoteExpiresAt = normalizeString(remoteSession.expiresAt);
|
|
151
|
+
const remoteExpiryEpoch = Date.parse(remoteExpiresAt);
|
|
152
|
+
const nowEpoch = Date.parse(nowIso);
|
|
153
|
+
const expiresAt =
|
|
154
|
+
Number.isFinite(remoteExpiryEpoch) && Number.isFinite(nowEpoch) && remoteExpiryEpoch > nowEpoch
|
|
155
|
+
? new Date(remoteExpiryEpoch).toISOString()
|
|
156
|
+
: toIsoAfterSeconds(nowIso, ttlSeconds);
|
|
157
|
+
const refreshed = {
|
|
158
|
+
...metadata,
|
|
159
|
+
updatedAt: nowIso,
|
|
160
|
+
expiresAt,
|
|
161
|
+
ttlSeconds,
|
|
162
|
+
status: "active",
|
|
163
|
+
expiredAt: null,
|
|
164
|
+
archivedAt: null,
|
|
165
|
+
archiveStatus: resolveRemoteSessionStatus(remoteSession) || "active",
|
|
166
|
+
title: normalizeString(remoteSession.title) || metadata.title || null,
|
|
167
|
+
lastInteractionAt: normalizeIsoTimestamp(
|
|
168
|
+
remoteSession.lastInteractionAt ||
|
|
169
|
+
remoteSession.lastActivityAt ||
|
|
170
|
+
remoteSession.updatedAt ||
|
|
171
|
+
remoteSession.createdAt ||
|
|
172
|
+
metadata.lastInteractionAt,
|
|
173
|
+
metadata.lastInteractionAt || metadata.createdAt || nowIso,
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
await writeSessionMetadata(paths, refreshed);
|
|
177
|
+
return { refreshed: true, reason: "", metadata: refreshed };
|
|
178
|
+
}
|
|
179
|
+
|
|
67
180
|
function isSessionExpired(metadata = {}, nowIso = new Date().toISOString()) {
|
|
68
181
|
const status = normalizeString(metadata.status).toLowerCase();
|
|
69
182
|
if (status === "expired" || status === "archived") {
|
|
@@ -227,12 +340,18 @@ export async function appendToStream(
|
|
|
227
340
|
{ targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS, syncRemote = true } = {}
|
|
228
341
|
) {
|
|
229
342
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
230
|
-
|
|
343
|
+
let metadata = await readSessionMetadata(paths);
|
|
231
344
|
if (!metadata) {
|
|
232
345
|
throw new Error(`Session '${paths.sessionId}' was not found.`);
|
|
233
346
|
}
|
|
234
347
|
if (isSessionExpired(metadata)) {
|
|
235
|
-
|
|
348
|
+
const refresh = await refreshExpiredMetadataFromRemote(paths, metadata, { targetPath });
|
|
349
|
+
if (!refresh.refreshed) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Session '${paths.sessionId}' is expired and does not accept new events (remote refresh failed: ${refresh.reason}).`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
metadata = refresh.metadata;
|
|
236
355
|
}
|
|
237
356
|
|
|
238
357
|
const rawEvent = materializeCanonicalEvent(paths.sessionId, event);
|
package/src/session/sync.js
CHANGED
|
@@ -1319,6 +1319,91 @@ export async function listSessionsFromApi({
|
|
|
1319
1319
|
};
|
|
1320
1320
|
}
|
|
1321
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Fetch one visible session row by id via `GET /api/v1/sessions/{id}`.
|
|
1324
|
+
*
|
|
1325
|
+
* The session-events read probe can prove membership, but it cannot prove the
|
|
1326
|
+
* remote session is still active. Callers that need to refresh local closed
|
|
1327
|
+
* metadata should use this status-bearing endpoint first, then fall back to
|
|
1328
|
+
* `listSessionsFromApi` only for older API deployments without singleton read.
|
|
1329
|
+
*
|
|
1330
|
+
* @param {string} sessionId
|
|
1331
|
+
* @param {object} [options]
|
|
1332
|
+
* @param {string} [options.targetPath]
|
|
1333
|
+
* @param {Function} [options.resolveAuthSession]
|
|
1334
|
+
* @param {Function} [options.fetchImpl]
|
|
1335
|
+
* @param {number} [options.timeoutMs]
|
|
1336
|
+
* @returns {Promise<{ok: boolean, reason: string, session: object|null, status?: number}>}
|
|
1337
|
+
*/
|
|
1338
|
+
export async function fetchSessionFromApi(
|
|
1339
|
+
sessionId,
|
|
1340
|
+
{
|
|
1341
|
+
targetPath = process.cwd(),
|
|
1342
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1343
|
+
fetchImpl = fetchWithTimeout,
|
|
1344
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1345
|
+
} = {},
|
|
1346
|
+
) {
|
|
1347
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1348
|
+
if (!normalizedSessionId) {
|
|
1349
|
+
return { ok: false, reason: "invalid_session_id", session: null };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
let session;
|
|
1353
|
+
try {
|
|
1354
|
+
session = await resolveAuthSession({
|
|
1355
|
+
cwd: targetPath,
|
|
1356
|
+
env: process.env,
|
|
1357
|
+
autoRotate: false,
|
|
1358
|
+
});
|
|
1359
|
+
} catch {
|
|
1360
|
+
return { ok: false, reason: "no_session", session: null };
|
|
1361
|
+
}
|
|
1362
|
+
if (!session || !session.token) {
|
|
1363
|
+
return { ok: false, reason: "not_authenticated", session: null, status: 401 };
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1367
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
|
|
1368
|
+
|
|
1369
|
+
let response;
|
|
1370
|
+
try {
|
|
1371
|
+
response = await fetchImpl(
|
|
1372
|
+
endpoint,
|
|
1373
|
+
{
|
|
1374
|
+
method: "GET",
|
|
1375
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
1376
|
+
},
|
|
1377
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
1378
|
+
);
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
return {
|
|
1381
|
+
ok: false,
|
|
1382
|
+
reason: normalizeString(err?.message) || "fetch_failed",
|
|
1383
|
+
session: null,
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
if (!response) {
|
|
1387
|
+
return { ok: false, reason: "no_response", session: null };
|
|
1388
|
+
}
|
|
1389
|
+
if (response.ok) {
|
|
1390
|
+
const body = await response.json().catch(() => ({}));
|
|
1391
|
+
const sessionPayload = body && body.session && typeof body.session === "object"
|
|
1392
|
+
? body.session
|
|
1393
|
+
: body && typeof body === "object"
|
|
1394
|
+
? body
|
|
1395
|
+
: null;
|
|
1396
|
+
return { ok: true, reason: "", session: sessionPayload, status: response.status };
|
|
1397
|
+
}
|
|
1398
|
+
if (response.status === 403) {
|
|
1399
|
+
return { ok: false, reason: "not_a_member", session: null, status: 403 };
|
|
1400
|
+
}
|
|
1401
|
+
if (response.status === 404) {
|
|
1402
|
+
return { ok: false, reason: "session_not_found", session: null, status: 404 };
|
|
1403
|
+
}
|
|
1404
|
+
return { ok: false, reason: `api_${response.status}`, session: null, status: response.status };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1322
1407
|
/**
|
|
1323
1408
|
* Probe whether a single session is visible to the active user.
|
|
1324
1409
|
*
|