sentinelayer-cli 0.46.0 → 0.46.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/bin/create-sentinelayer.js +2 -2
- package/bin/sentinelayer-cli.js +2 -2
- package/bin/sl.js +2 -2
- package/package.json +1 -1
- package/src/agents/agent-id-path.js +57 -0
- package/src/agents/jules/tools/runtime-audit.js +2 -1
- package/src/ai/aidenid.js +11 -10
- package/src/ai/client.js +2 -1
- package/src/audit/orchestrator.js +7 -1
- package/src/audit/registry.js +9 -1
- package/src/auth/credential-destinations.js +57 -13
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +4 -3
- package/src/board/evidence-resolvers.js +15 -3
- package/src/cli-bootstrap.js +20 -0
- package/src/cli.js +3 -1
- package/src/commands/ai/identity-lifecycle.js +4 -3
- package/src/commands/chat.js +4 -1
- package/src/commands/session.js +51 -17
- package/src/legacy-cli.js +4 -11
- package/src/mcp/session-stdio-server.js +13 -9
- package/src/net/gh-api-path.js +68 -0
- package/src/net/url-path.js +41 -0
- package/src/review/dd-report-email-client.js +2 -3
- package/src/scan/gh-secrets.js +4 -1
- package/src/session/admission-access.js +17 -8
- package/src/session/admission.js +12 -5
- package/src/session/agent-registry.js +4 -5
- package/src/session/checkpoints.js +4 -3
- package/src/session/file-locks.js +8 -6
- package/src/session/invitations.js +2 -1
- package/src/session/listener-process.js +118 -32
- package/src/session/live-source.js +2 -3
- package/src/session/paths.js +51 -2
- package/src/session/store.js +3 -3
- package/src/session/sync.js +34 -26
- package/src/session/tickets.js +13 -6
- package/src/session/title-sync.js +2 -1
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
|
|
13
13
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
14
14
|
import { resolveSessionPaths } from "./paths.js";
|
|
15
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
15
16
|
|
|
16
17
|
const FILE_LEASE_CAPABILITY_SCHEMA_VERSION = "1.0.0";
|
|
17
18
|
const DEFAULT_FILE_LOCK_TTL_SECONDS = 300;
|
|
@@ -490,11 +491,12 @@ async function resolveLeaseApi({
|
|
|
490
491
|
}
|
|
491
492
|
|
|
492
493
|
function leaseCollectionUrl(apiUrl, sessionId) {
|
|
493
|
-
return `${apiUrl}/api/v1/sessions/${
|
|
494
|
+
return `${apiUrl}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/file-leases`;
|
|
494
495
|
}
|
|
495
496
|
|
|
496
|
-
|
|
497
|
-
|
|
497
|
+
/** One lease's URL: the raw lease id is encoded here; `route` is a fixed route ("/renew"). */
|
|
498
|
+
function leaseMemberUrl(apiUrl, sessionId, leaseId, route) {
|
|
499
|
+
return `${leaseCollectionUrl(apiUrl, sessionId)}/${urlPathSegment(leaseId, { label: "leaseId" })}${route}`;
|
|
498
500
|
}
|
|
499
501
|
|
|
500
502
|
async function listRemoteLeases(
|
|
@@ -794,7 +796,7 @@ export async function lockFile(
|
|
|
794
796
|
let compensated = false;
|
|
795
797
|
try {
|
|
796
798
|
const release = await checkedTransport(requestMutation)(
|
|
797
|
-
leaseMemberUrl(apiUrl, normalizedSessionId, lease.leaseId, "release"),
|
|
799
|
+
leaseMemberUrl(apiUrl, normalizedSessionId, lease.leaseId, "/release"),
|
|
798
800
|
{
|
|
799
801
|
method: "POST",
|
|
800
802
|
operationName: "session-file-lease-acquire-compensation",
|
|
@@ -850,7 +852,7 @@ async function releaseCapabilityClaim(
|
|
|
850
852
|
let response;
|
|
851
853
|
try {
|
|
852
854
|
response = await checkedTransport(requestMutation)(
|
|
853
|
-
leaseMemberUrl(apiUrl, sessionId, claim.leaseId, "release"),
|
|
855
|
+
leaseMemberUrl(apiUrl, sessionId, claim.leaseId, "/release"),
|
|
854
856
|
{
|
|
855
857
|
method: "POST",
|
|
856
858
|
operationName: "session-file-lease-release",
|
|
@@ -1010,7 +1012,7 @@ export async function renewFileLease(
|
|
|
1010
1012
|
|
|
1011
1013
|
const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
|
|
1012
1014
|
const response = await checkedTransport(requestMutation)(
|
|
1013
|
-
leaseMemberUrl(apiUrl, normalizedSessionId, claim.leaseId, "renew"),
|
|
1015
|
+
leaseMemberUrl(apiUrl, normalizedSessionId, claim.leaseId, "/renew"),
|
|
1014
1016
|
{
|
|
1015
1017
|
method: "POST",
|
|
1016
1018
|
operationName: "session-file-lease-renew",
|
|
@@ -8,6 +8,7 @@ import { requestJsonMutation } from "../auth/http.js";
|
|
|
8
8
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
9
9
|
import { renderCoordinationBulletList } from "./coordination-guidance.js";
|
|
10
10
|
import { resolveSessionPaths } from "./paths.js";
|
|
11
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
11
12
|
|
|
12
13
|
export const SESSION_INVITATION_ACCEPT_ROUTE_ID =
|
|
13
14
|
"POST /api/v1/sessions/{session_id}/invitations/accept";
|
|
@@ -128,7 +129,7 @@ export async function acceptSessionInvitation(
|
|
|
128
129
|
if (normalizedAgentId) body.agentId = normalizedAgentId;
|
|
129
130
|
|
|
130
131
|
const result = await checkedTransport(requestMutation)(
|
|
131
|
-
`${apiUrl}/api/v1/sessions/${
|
|
132
|
+
`${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/invitations/accept`,
|
|
132
133
|
{
|
|
133
134
|
method: "POST",
|
|
134
135
|
operationName: "session.invitation_accept",
|
|
@@ -7,12 +7,18 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
|
|
9
9
|
import { isProcessAlive } from "./daemon-spawn.js";
|
|
10
|
-
import { resolveSessionPaths } from "./paths.js";
|
|
10
|
+
import { normalizeSessionId, resolveSessionChildDir, resolveSessionPaths } from "./paths.js";
|
|
11
11
|
|
|
12
12
|
const LISTENER_DIR_NAME = "listeners";
|
|
13
13
|
const GLOBAL_LISTENER_DIR_NAME = "session-listeners";
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
15
|
|
|
16
|
+
// Time allowed for one scan of the process table. A busy machine can need longer than the
|
|
17
|
+
// first limit, so a caller that must not guess (the one-listener check in `session listen`)
|
|
18
|
+
// retries once with the longer one before it reports the check as incomplete.
|
|
19
|
+
export const LISTENER_PROCESS_SCAN_TIMEOUT_MS = 3_000;
|
|
20
|
+
export const LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS = 10_000;
|
|
21
|
+
|
|
16
22
|
function normalizeString(value) {
|
|
17
23
|
return String(value || "").trim();
|
|
18
24
|
}
|
|
@@ -88,7 +94,7 @@ function normalizeProcessRows(rows = []) {
|
|
|
88
94
|
.filter((row) => Number.isInteger(row.pid) && row.pid > 0 && row.commandLine);
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
async function listWindowsProcesses() {
|
|
97
|
+
async function listWindowsProcesses({ timeoutMs }) {
|
|
92
98
|
const command = [
|
|
93
99
|
"$ErrorActionPreference = 'Stop';",
|
|
94
100
|
"$rows = Get-CimInstance Win32_Process |",
|
|
@@ -99,7 +105,7 @@ async function listWindowsProcesses() {
|
|
|
99
105
|
const { stdout } = await execFileAsync(
|
|
100
106
|
"powershell.exe",
|
|
101
107
|
["-NoProfile", "-NonInteractive", "-Command", command],
|
|
102
|
-
{ windowsHide: true, timeout:
|
|
108
|
+
{ windowsHide: true, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
|
|
103
109
|
);
|
|
104
110
|
const text = normalizeString(stdout);
|
|
105
111
|
if (!text) return [];
|
|
@@ -107,9 +113,9 @@ async function listWindowsProcesses() {
|
|
|
107
113
|
return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
async function listPosixProcesses() {
|
|
116
|
+
async function listPosixProcesses({ timeoutMs }) {
|
|
111
117
|
const { stdout } = await execFileAsync("ps", ["-eo", "pid=,command="], {
|
|
112
|
-
timeout:
|
|
118
|
+
timeout: timeoutMs,
|
|
113
119
|
maxBuffer: 10 * 1024 * 1024,
|
|
114
120
|
});
|
|
115
121
|
return String(stdout || "")
|
|
@@ -121,30 +127,73 @@ async function listPosixProcesses() {
|
|
|
121
127
|
.filter(Boolean);
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
|
|
130
|
+
// Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
|
|
131
|
+
// missing tool, unreadable output). It never turns a failed read into an empty table.
|
|
132
|
+
async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
|
|
133
|
+
return process.platform === "win32"
|
|
134
|
+
? listWindowsProcesses({ timeoutMs })
|
|
135
|
+
: listPosixProcesses({ timeoutMs });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The probes `session listen` uses; replaced only through setListenerProcessProbesForTests.
|
|
139
|
+
let activeListProcessTable = listProcessTable;
|
|
140
|
+
let activeReadProcessCommandLine = readProcessCommandLine;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Test seam: replace the process-table scan and the per-pid command-line read that the
|
|
144
|
+
* listener commands use. Call with no argument to restore the real probes.
|
|
145
|
+
*/
|
|
146
|
+
export function setListenerProcessProbesForTests({ listProcesses = null, readCommandLine = null } = {}) {
|
|
147
|
+
activeListProcessTable = listProcesses || listProcessTable;
|
|
148
|
+
activeReadProcessCommandLine = readCommandLine || readProcessCommandLine;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function processScanFailureReason(error) {
|
|
152
|
+
const code = normalizeString(error?.code);
|
|
153
|
+
// execFile kills the scan at its timeout and reports it as killed with no code.
|
|
154
|
+
if (code === "ETIMEDOUT" || (!code && error?.killed)) return "process_scan_timeout";
|
|
155
|
+
if (error instanceof SyntaxError) return "process_scan_unreadable";
|
|
156
|
+
return code ? `process_scan_failed:${code}` : "process_scan_failed";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// One scan of the process table: { ok: true, rows } or { ok: false, reason }.
|
|
160
|
+
async function scanProcessTable(listProcesses, timeoutMs) {
|
|
161
|
+
let rows;
|
|
125
162
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
163
|
+
rows = await listProcesses({ timeoutMs });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return { ok: false, reason: processScanFailureReason(error) };
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(rows)) {
|
|
168
|
+
return { ok: false, reason: "process_scan_unreadable" };
|
|
131
169
|
}
|
|
170
|
+
return { ok: true, rows: normalizeProcessRows(rows) };
|
|
132
171
|
}
|
|
133
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Listener processes for this session and agent: { ok: true, matches } when the process
|
|
175
|
+
* table was read, { ok: false, reason } when it could not be. A failed scan has no
|
|
176
|
+
* `matches`, so it can never be read as "no listener".
|
|
177
|
+
*/
|
|
134
178
|
export async function listMatchingListenerProcesses(
|
|
135
179
|
sessionId,
|
|
136
180
|
agentId,
|
|
137
181
|
{
|
|
138
182
|
excludePid = process.pid,
|
|
139
|
-
|
|
183
|
+
timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
184
|
+
_listProcesses = activeListProcessTable,
|
|
140
185
|
} = {},
|
|
141
186
|
) {
|
|
142
|
-
const
|
|
187
|
+
const scan = await scanProcessTable(_listProcesses, timeoutMs);
|
|
188
|
+
if (!scan.ok) return scan;
|
|
143
189
|
const excluded = Number(excludePid);
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
matches: scan.rows.filter((row) =>
|
|
193
|
+
row.pid !== excluded &&
|
|
194
|
+
listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
|
|
195
|
+
),
|
|
196
|
+
};
|
|
148
197
|
}
|
|
149
198
|
|
|
150
199
|
export async function summarizeLocalListenerProcesses(
|
|
@@ -152,16 +201,20 @@ export async function summarizeLocalListenerProcesses(
|
|
|
152
201
|
agentIds = [],
|
|
153
202
|
{
|
|
154
203
|
excludePid = process.pid,
|
|
155
|
-
|
|
204
|
+
timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
205
|
+
_listProcesses = activeListProcessTable,
|
|
156
206
|
} = {},
|
|
157
207
|
) {
|
|
158
208
|
const normalizedSessionId = normalizeString(sessionId);
|
|
159
209
|
const uniqueAgentIds = [...new Set((Array.isArray(agentIds) ? agentIds : [])
|
|
160
210
|
.map((agentId) => normalizeString(agentId))
|
|
161
211
|
.filter(Boolean))];
|
|
162
|
-
const
|
|
212
|
+
const scan = await scanProcessTable(_listProcesses, timeoutMs);
|
|
213
|
+
if (!scan.ok) {
|
|
214
|
+
return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
|
|
215
|
+
}
|
|
163
216
|
const excluded = Number(excludePid);
|
|
164
|
-
const candidates = rows.filter((row) => row.pid !== excluded);
|
|
217
|
+
const candidates = scan.rows.filter((row) => row.pid !== excluded);
|
|
165
218
|
const agents = uniqueAgentIds.map((agentId) => {
|
|
166
219
|
const matches = candidates
|
|
167
220
|
.filter((row) => listenerCommandLineMatches(row.commandLine, {
|
|
@@ -178,6 +231,7 @@ export async function summarizeLocalListenerProcesses(
|
|
|
178
231
|
};
|
|
179
232
|
});
|
|
180
233
|
return {
|
|
234
|
+
ok: true,
|
|
181
235
|
sessionId: normalizedSessionId,
|
|
182
236
|
processCount: agents.reduce((sum, agent) => sum + agent.processCount, 0),
|
|
183
237
|
duplicateProcessCount: agents.reduce((sum, agent) => sum + agent.duplicateProcessCount, 0),
|
|
@@ -244,13 +298,12 @@ export function resolveGlobalListenerPidPath(
|
|
|
244
298
|
agentId,
|
|
245
299
|
{ homeDir = os.homedir() } = {},
|
|
246
300
|
) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
".sentinelayer",
|
|
250
|
-
|
|
251
|
-
normalizeListenerProcessKey(sessionId),
|
|
252
|
-
`${normalizeListenerProcessKey(agentId)}.json`,
|
|
301
|
+
// The id is held to the session-id rule as given; its directory is the lower-cased key.
|
|
302
|
+
const sessionDir = resolveSessionChildDir(
|
|
303
|
+
path.join(path.resolve(String(homeDir || os.homedir())), ".sentinelayer", GLOBAL_LISTENER_DIR_NAME),
|
|
304
|
+
normalizeListenerProcessKey(normalizeSessionId(sessionId)),
|
|
253
305
|
);
|
|
306
|
+
return path.join(sessionDir, `${normalizeListenerProcessKey(agentId)}.json`);
|
|
254
307
|
}
|
|
255
308
|
|
|
256
309
|
async function readPidRecordAtPath(pidPath) {
|
|
@@ -368,12 +421,13 @@ async function listenerStatusFromRecord(
|
|
|
368
421
|
sessionId,
|
|
369
422
|
agentId,
|
|
370
423
|
recordScope,
|
|
371
|
-
_readProcessCommandLine =
|
|
424
|
+
_readProcessCommandLine = activeReadProcessCommandLine,
|
|
372
425
|
} = {},
|
|
373
426
|
) {
|
|
374
427
|
const alive = isProcessAlive(record.pid);
|
|
375
428
|
if (!alive) {
|
|
376
429
|
return {
|
|
430
|
+
state: "not_running",
|
|
377
431
|
running: false,
|
|
378
432
|
pid: null,
|
|
379
433
|
stale: true,
|
|
@@ -385,6 +439,7 @@ async function listenerStatusFromRecord(
|
|
|
385
439
|
const commandLine = await _readProcessCommandLine(record.pid);
|
|
386
440
|
if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
|
|
387
441
|
return {
|
|
442
|
+
state: "not_running",
|
|
388
443
|
running: false,
|
|
389
444
|
pid: null,
|
|
390
445
|
stale: true,
|
|
@@ -397,6 +452,7 @@ async function listenerStatusFromRecord(
|
|
|
397
452
|
};
|
|
398
453
|
}
|
|
399
454
|
return {
|
|
455
|
+
state: "running",
|
|
400
456
|
running: true,
|
|
401
457
|
pid: Number(record.pid),
|
|
402
458
|
stale: false,
|
|
@@ -407,14 +463,21 @@ async function listenerStatusFromRecord(
|
|
|
407
463
|
};
|
|
408
464
|
}
|
|
409
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Whether a listener for this session and agent is running on this machine. `state` is
|
|
468
|
+
* "running", "not_running" or "unknown". A live pid record whose command line matches is
|
|
469
|
+
* "running" without a scan. Otherwise the process table decides, and when it cannot be read
|
|
470
|
+
* the answer is "unknown" (`running: null`, with `reason`), never "not_running".
|
|
471
|
+
*/
|
|
410
472
|
export async function getListenerProcessStatus(
|
|
411
473
|
sessionId,
|
|
412
474
|
agentId,
|
|
413
475
|
{
|
|
414
476
|
targetPath = process.cwd(),
|
|
415
477
|
homeDir = os.homedir(),
|
|
416
|
-
|
|
417
|
-
|
|
478
|
+
scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
479
|
+
_readProcessCommandLine = activeReadProcessCommandLine,
|
|
480
|
+
_listProcesses = activeListProcessTable,
|
|
418
481
|
} = {},
|
|
419
482
|
) {
|
|
420
483
|
const records = [
|
|
@@ -441,11 +504,24 @@ export async function getListenerProcessStatus(
|
|
|
441
504
|
}
|
|
442
505
|
staleStatuses.push(status);
|
|
443
506
|
}
|
|
444
|
-
const
|
|
507
|
+
const scan = await listMatchingListenerProcesses(sessionId, agentId, {
|
|
508
|
+
timeoutMs: scanTimeoutMs,
|
|
445
509
|
_listProcesses,
|
|
446
510
|
});
|
|
511
|
+
if (!scan.ok) {
|
|
512
|
+
return {
|
|
513
|
+
state: "unknown",
|
|
514
|
+
running: null,
|
|
515
|
+
pid: null,
|
|
516
|
+
reason: scan.reason,
|
|
517
|
+
record: staleStatuses[0]?.record || null,
|
|
518
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const matchingProcesses = scan.matches;
|
|
447
522
|
if (matchingProcesses.length > 0) {
|
|
448
523
|
return {
|
|
524
|
+
state: "running",
|
|
449
525
|
running: true,
|
|
450
526
|
pid: matchingProcesses[0].pid,
|
|
451
527
|
stale: false,
|
|
@@ -459,6 +535,7 @@ export async function getListenerProcessStatus(
|
|
|
459
535
|
}
|
|
460
536
|
if (staleStatuses.length === 0) {
|
|
461
537
|
return {
|
|
538
|
+
state: "not_running",
|
|
462
539
|
running: false,
|
|
463
540
|
pid: null,
|
|
464
541
|
stale: false,
|
|
@@ -469,6 +546,10 @@ export async function getListenerProcessStatus(
|
|
|
469
546
|
return staleStatuses[0];
|
|
470
547
|
}
|
|
471
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Stops every listener process the scan finds for this session and agent. When the process
|
|
551
|
+
* table cannot be read it stops nothing and returns { ok: false, reason }.
|
|
552
|
+
*/
|
|
472
553
|
export async function stopMatchingListenerProcesses(
|
|
473
554
|
sessionId,
|
|
474
555
|
agentId,
|
|
@@ -476,13 +557,17 @@ export async function stopMatchingListenerProcesses(
|
|
|
476
557
|
excludePid = process.pid,
|
|
477
558
|
timeoutMs = 3000,
|
|
478
559
|
pollIntervalMs = 100,
|
|
479
|
-
|
|
560
|
+
scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
561
|
+
_listProcesses = activeListProcessTable,
|
|
480
562
|
} = {},
|
|
481
563
|
) {
|
|
482
|
-
const
|
|
564
|
+
const scan = await listMatchingListenerProcesses(sessionId, agentId, {
|
|
483
565
|
excludePid,
|
|
566
|
+
timeoutMs: scanTimeoutMs,
|
|
484
567
|
_listProcesses,
|
|
485
568
|
});
|
|
569
|
+
if (!scan.ok) return scan;
|
|
570
|
+
const matches = scan.matches;
|
|
486
571
|
const results = [];
|
|
487
572
|
for (const match of matches) {
|
|
488
573
|
const result = await requestListenerProcessStop(match.pid, {
|
|
@@ -497,6 +582,7 @@ export async function stopMatchingListenerProcesses(
|
|
|
497
582
|
}
|
|
498
583
|
const failed = results.filter((result) => !result.stopped);
|
|
499
584
|
return {
|
|
585
|
+
ok: true,
|
|
500
586
|
matches,
|
|
501
587
|
results,
|
|
502
588
|
stoppedCount: results.filter((result) => result.stopped).length,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
sessionEventIdentityKeys,
|
|
28
28
|
sessionEventUpgradesExisting,
|
|
29
29
|
} from "./event-identity.js";
|
|
30
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
30
31
|
|
|
31
32
|
const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
|
|
32
33
|
const MAX_RECONNECT_BACKOFF_MS = 30_000;
|
|
@@ -162,9 +163,7 @@ export async function* watchRemoteStream({
|
|
|
162
163
|
reconnectBackoffMs = DEFAULT_RECONNECT_BACKOFF_MS,
|
|
163
164
|
} = {}) {
|
|
164
165
|
if (!apiBaseUrl || !sessionId || !credential) return;
|
|
165
|
-
const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/sessions/${
|
|
166
|
-
sessionId,
|
|
167
|
-
)}/stream`;
|
|
166
|
+
const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/stream`;
|
|
168
167
|
let backoff = reconnectBackoffMs;
|
|
169
168
|
// Highest server sequence actually DELIVERED to the consumer. SSE is a live push with no
|
|
170
169
|
// backlog: reconnecting without it silently drops everything published while we were away,
|
package/src/session/paths.js
CHANGED
|
@@ -1,20 +1,69 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// A session id names exactly one directory directly under <target>/.sentinelayer/sessions/.
|
|
5
|
+
// Every id in use fits: randomUUID() ids, run-derived billing ids such as
|
|
6
|
+
// omargate-<ms>-<hex>-<persona>-swarm-<n>-ai, chat ids such as 20261010-123456-k3j9x2, and API ids.
|
|
7
|
+
// No trailing '.': Windows drops it, so "abc." and "abc" would name the same directory.
|
|
8
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9_-])?$/;
|
|
9
|
+
// Windows device names, with or without an extension, are refused on every platform.
|
|
10
|
+
const RESERVED_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
11
|
+
|
|
12
|
+
export const SESSION_ID_RULE =
|
|
13
|
+
"sessionId must be 1-128 characters: letters, digits, '.', '_' or '-', starting with a letter or digit and not ending with '.'.";
|
|
14
|
+
export const SESSION_ID_RESERVED_RULE = "sessionId must not be a reserved device name.";
|
|
15
|
+
export const SESSION_ID_SEGMENT_RULE = "sessionId must name one directory directly under the sessions root.";
|
|
16
|
+
|
|
17
|
+
function sessionIdProblem(id) {
|
|
18
|
+
if (!SESSION_ID_PATTERN.test(id)) return SESSION_ID_RULE;
|
|
19
|
+
if (RESERVED_DEVICE_NAME.test(id)) return SESSION_ID_RESERVED_RULE;
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when `value` is a session id as stored on disk (no trimming). */
|
|
24
|
+
export function isValidSessionId(value) {
|
|
25
|
+
return typeof value === "string" && !sessionIdProblem(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The trimmed session id, or an error when it is empty, outside the allowlist or a device name. */
|
|
29
|
+
export function normalizeSessionId(sessionId) {
|
|
5
30
|
const normalized = String(sessionId || "").trim();
|
|
6
31
|
if (!normalized) {
|
|
7
32
|
throw new Error("sessionId is required.");
|
|
8
33
|
}
|
|
34
|
+
const problem = sessionIdProblem(normalized);
|
|
35
|
+
if (problem) {
|
|
36
|
+
throw new Error(problem);
|
|
37
|
+
}
|
|
9
38
|
return normalized;
|
|
10
39
|
}
|
|
11
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `<root>/<sessionId>`, refused unless the result is exactly one directory directly under `root`
|
|
43
|
+
* and the id passes normalizeSessionId. The one-directory check runs first, on the raw id, so it
|
|
44
|
+
* holds by itself whatever the allowlist admits.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveSessionChildDir(root, sessionId) {
|
|
47
|
+
const resolvedRoot = path.resolve(String(root || "."));
|
|
48
|
+
const raw = String(sessionId || "").trim();
|
|
49
|
+
if (!raw) {
|
|
50
|
+
throw new Error("sessionId is required.");
|
|
51
|
+
}
|
|
52
|
+
const dir = path.join(resolvedRoot, raw);
|
|
53
|
+
const relative = path.relative(resolvedRoot, dir);
|
|
54
|
+
if (!relative || relative === ".." || /[\\/]/.test(relative) || path.isAbsolute(relative)) {
|
|
55
|
+
throw new Error(SESSION_ID_SEGMENT_RULE);
|
|
56
|
+
}
|
|
57
|
+
normalizeSessionId(raw);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
12
61
|
export function resolveSessionsRoot({ targetPath = process.cwd() } = {}) {
|
|
13
62
|
return path.join(path.resolve(String(targetPath || ".")), ".sentinelayer", "sessions");
|
|
14
63
|
}
|
|
15
64
|
|
|
16
65
|
export function resolveSessionDir(sessionId, { targetPath = process.cwd() } = {}) {
|
|
17
|
-
return
|
|
66
|
+
return resolveSessionChildDir(resolveSessionsRoot({ targetPath }), sessionId);
|
|
18
67
|
}
|
|
19
68
|
|
|
20
69
|
export function resolveSessionPaths(sessionId, { targetPath = process.cwd() } = {}) {
|
package/src/session/store.js
CHANGED
|
@@ -6,7 +6,7 @@ import process from "node:process";
|
|
|
6
6
|
import { buildArtifactLineageIndex, verifyArtifactChain } from "../daemon/artifact-lineage.js";
|
|
7
7
|
import { readSessionCodebaseContext } from "./codebase-context.js";
|
|
8
8
|
import { computeSessionAnalytics } from "./analytics.js";
|
|
9
|
-
import { resolveSessionPaths, resolveSessionsRoot } from "./paths.js";
|
|
9
|
+
import { isValidSessionId, resolveSessionPaths, resolveSessionsRoot } from "./paths.js";
|
|
10
10
|
import { appendToStream, withSessionStreamLock } from "./stream.js";
|
|
11
11
|
|
|
12
12
|
const SESSION_SCHEMA_VERSION = "1.0.0";
|
|
@@ -637,7 +637,7 @@ export async function listActiveSessions({ targetPath = process.cwd() } = {}) {
|
|
|
637
637
|
|
|
638
638
|
const sessions = [];
|
|
639
639
|
for (const entry of entries) {
|
|
640
|
-
if (!entry.isDirectory()) continue;
|
|
640
|
+
if (!entry.isDirectory() || !isValidSessionId(entry.name)) continue;
|
|
641
641
|
const loaded = await loadMetadata(entry.name, { targetPath: resolvedTargetPath });
|
|
642
642
|
if (!loaded) continue;
|
|
643
643
|
if (isExpired(loaded.metadata)) continue;
|
|
@@ -677,7 +677,7 @@ export async function listAllSessions({ targetPath = process.cwd() } = {}) {
|
|
|
677
677
|
|
|
678
678
|
const sessions = [];
|
|
679
679
|
for (const entry of entries) {
|
|
680
|
-
if (!entry.isDirectory()) continue;
|
|
680
|
+
if (!entry.isDirectory() || !isValidSessionId(entry.name)) continue;
|
|
681
681
|
const loaded = await loadMetadata(entry.name, { targetPath: resolvedTargetPath });
|
|
682
682
|
if (!loaded) continue;
|
|
683
683
|
|