sentinelayer-cli 0.45.0 → 0.46.1
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 +2 -1
- 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/agents/shared-tools/shell.js +9 -0
- 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.js +10 -0
- package/src/commands/ai/identity-lifecycle.js +4 -3
- package/src/commands/chat.js +4 -1
- package/src/commands/session.js +4 -3
- package/src/daemon/ast-parser-layer.js +8 -3
- package/src/daemon/callgraph-overlay.js +7 -2
- package/src/legacy-cli.js +4 -1
- 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/program-lookup.js +54 -0
- package/src/review/dd-report-email-client.js +2 -3
- package/src/review/local-review.js +11 -5
- 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 +6 -7
- 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,7 +7,7 @@ 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";
|
|
@@ -244,13 +244,12 @@ export function resolveGlobalListenerPidPath(
|
|
|
244
244
|
agentId,
|
|
245
245
|
{ homeDir = os.homedir() } = {},
|
|
246
246
|
) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
".sentinelayer",
|
|
250
|
-
|
|
251
|
-
normalizeListenerProcessKey(sessionId),
|
|
252
|
-
`${normalizeListenerProcessKey(agentId)}.json`,
|
|
247
|
+
// The id is held to the session-id rule as given; its directory is the lower-cased key.
|
|
248
|
+
const sessionDir = resolveSessionChildDir(
|
|
249
|
+
path.join(path.resolve(String(homeDir || os.homedir())), ".sentinelayer", GLOBAL_LISTENER_DIR_NAME),
|
|
250
|
+
normalizeListenerProcessKey(normalizeSessionId(sessionId)),
|
|
253
251
|
);
|
|
252
|
+
return path.join(sessionDir, `${normalizeListenerProcessKey(agentId)}.json`);
|
|
254
253
|
}
|
|
255
254
|
|
|
256
255
|
async function readPidRecordAtPath(pidPath) {
|
|
@@ -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
|
|
package/src/session/sync.js
CHANGED
|
@@ -11,6 +11,7 @@ import { isSessionControlEvent } from "./control-events.js";
|
|
|
11
11
|
import { createSessionMutationHeaders } from "./invitations.js";
|
|
12
12
|
import { messageRevision } from "./message-edits.js";
|
|
13
13
|
import { installTestEgressGuard } from "../net/test-egress-guard.js";
|
|
14
|
+
import { isUrlPathSegment, urlPathSegment } from "../net/url-path.js";
|
|
14
15
|
|
|
15
16
|
// No-op outside tests; inside a test process (or a child it spawned) every fetch
|
|
16
17
|
// to a non-loopback host is refused before a socket opens.
|
|
@@ -1023,7 +1024,7 @@ export async function syncSessionEventToApi(
|
|
|
1023
1024
|
}
|
|
1024
1025
|
|
|
1025
1026
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1026
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
1027
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/events`;
|
|
1027
1028
|
const requestBody = JSON.stringify({
|
|
1028
1029
|
event,
|
|
1029
1030
|
source: "cli",
|
|
@@ -1221,7 +1222,7 @@ async function syncSessionAuxPayload(
|
|
|
1221
1222
|
}
|
|
1222
1223
|
|
|
1223
1224
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1224
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
1225
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}${pathSuffix}`;
|
|
1225
1226
|
try {
|
|
1226
1227
|
const { response } = await fetchJsonWithFullTimeout(
|
|
1227
1228
|
endpoint,
|
|
@@ -1351,7 +1352,7 @@ export async function pollHumanMessages(
|
|
|
1351
1352
|
query.set("since", normalizedSince);
|
|
1352
1353
|
}
|
|
1353
1354
|
query.set("limit", String(Math.max(1, Math.min(HUMAN_MESSAGE_FETCH_LIMIT, normalizePositiveInteger(limit, HUMAN_MESSAGE_FETCH_LIMIT)))));
|
|
1354
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
1355
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/human-messages?${query.toString()}`;
|
|
1355
1356
|
|
|
1356
1357
|
try {
|
|
1357
1358
|
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
@@ -1526,7 +1527,7 @@ export async function pollSessionEvents(
|
|
|
1526
1527
|
"limit",
|
|
1527
1528
|
String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, SESSION_EVENT_FETCH_LIMIT))))
|
|
1528
1529
|
);
|
|
1529
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
1530
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/events?${query.toString()}`;
|
|
1530
1531
|
|
|
1531
1532
|
try {
|
|
1532
1533
|
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
@@ -1681,7 +1682,7 @@ export async function fetchSessionPresence(
|
|
|
1681
1682
|
}
|
|
1682
1683
|
|
|
1683
1684
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1684
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
1685
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/presence`;
|
|
1685
1686
|
try {
|
|
1686
1687
|
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
1687
1688
|
endpoint,
|
|
@@ -1832,7 +1833,7 @@ export async function renewSessionPresence(
|
|
|
1832
1833
|
}
|
|
1833
1834
|
|
|
1834
1835
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1835
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
1836
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/presence`;
|
|
1836
1837
|
const body = {
|
|
1837
1838
|
agentId: normalizedAgentId,
|
|
1838
1839
|
state: normalizeString(state).toLowerCase() || "idle",
|
|
@@ -1955,7 +1956,7 @@ export async function requestSessionListenerStop(
|
|
|
1955
1956
|
const normalizedIdempotencyKey =
|
|
1956
1957
|
normalizeString(idempotencyKey) || `sl-listener-stop-${randomUUID()}`;
|
|
1957
1958
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1958
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
1959
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/listener-controls/stop`;
|
|
1959
1960
|
const body = normalizedTargetAgentId
|
|
1960
1961
|
? { targetAgentId: normalizedTargetAgentId }
|
|
1961
1962
|
: { broadcast: true };
|
|
@@ -2080,7 +2081,7 @@ export async function updateSessionReadCursor(
|
|
|
2080
2081
|
}
|
|
2081
2082
|
|
|
2082
2083
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
2083
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
2084
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/read-cursor`;
|
|
2084
2085
|
const body = {
|
|
2085
2086
|
targetSequenceId:
|
|
2086
2087
|
Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0
|
|
@@ -2225,7 +2226,7 @@ export async function streamSessionEvents(
|
|
|
2225
2226
|
query.set("after", normalizedSince);
|
|
2226
2227
|
}
|
|
2227
2228
|
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
2228
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
2229
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/stream${suffix}`;
|
|
2229
2230
|
const controller = new AbortController();
|
|
2230
2231
|
const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
2231
2232
|
const normalizedIdleTimeoutMs = normalizePositiveInteger(idleTimeoutMs, 0);
|
|
@@ -2450,7 +2451,7 @@ export async function pollSessionEventsBefore(
|
|
|
2450
2451
|
"limit",
|
|
2451
2452
|
String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, 50))))
|
|
2452
2453
|
);
|
|
2453
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
2454
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/events/before?${query.toString()}`;
|
|
2454
2455
|
|
|
2455
2456
|
try {
|
|
2456
2457
|
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
@@ -2571,7 +2572,7 @@ export async function listSessionMessageActions(
|
|
|
2571
2572
|
"limit",
|
|
2572
2573
|
String(Math.max(1, Math.min(SESSION_ACTION_FETCH_LIMIT, normalizePositiveInteger(limit, 200))))
|
|
2573
2574
|
);
|
|
2574
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
2575
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/actions?${query.toString()}`;
|
|
2575
2576
|
|
|
2576
2577
|
try {
|
|
2577
2578
|
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
@@ -2668,7 +2669,7 @@ export async function fetchSessionUsageLedger(
|
|
|
2668
2669
|
"limit",
|
|
2669
2670
|
String(Math.max(1, Math.min(500, normalizePositiveInteger(limit, 500))))
|
|
2670
2671
|
);
|
|
2671
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
2672
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/usage?${query.toString()}`;
|
|
2672
2673
|
|
|
2673
2674
|
try {
|
|
2674
2675
|
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
@@ -2847,7 +2848,10 @@ export async function editSessionMessage(sessionId, {
|
|
|
2847
2848
|
let revision = expectedRevision === null || expectedRevision === undefined ? null : Number(expectedRevision);
|
|
2848
2849
|
const validSequence = Number.isSafeInteger(sequence) && sequence > 0;
|
|
2849
2850
|
const mutationKey = normalizeString(idempotencyKey) || `sl-message-edit-${randomUUID()}`;
|
|
2850
|
-
|
|
2851
|
+
// Every id is checked here, before the request try below: an id that is not one URL path
|
|
2852
|
+
// segment sends nothing and is never counted against the outbound breaker.
|
|
2853
|
+
const idsAreSegments = isUrlPathSegment(sid) && [replyId, messageId].every((id) => !id || isUrlPathSegment(id));
|
|
2854
|
+
if (!idsAreSegments || typeof text !== "string" || !text.trim() || (!replyId && !messageId && !validSequence) ||
|
|
2851
2855
|
(revision !== null && (!Number.isSafeInteger(revision) || revision <= 0)) || mutationKey.length > 128) {
|
|
2852
2856
|
return { ok: false, reason: "invalid_input" };
|
|
2853
2857
|
}
|
|
@@ -2858,7 +2862,11 @@ export async function editSessionMessage(sessionId, {
|
|
|
2858
2862
|
try { auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false }); }
|
|
2859
2863
|
catch { return { ok: false, reason: "no_session" }; }
|
|
2860
2864
|
if (!isAuthenticated(auth)) return { ok: false, reason: "not_authenticated" };
|
|
2861
|
-
const base = `${resolveApiBaseUrl(auth)}/api/v1/sessions/${
|
|
2865
|
+
const base = `${resolveApiBaseUrl(auth)}/api/v1/sessions/${urlPathSegment(sid, { label: "sessionId" })}`;
|
|
2866
|
+
// The edit target: a reply by its id, or a message by its id (checked above, or from the lookup).
|
|
2867
|
+
const targetUrl = (id) => (replyId
|
|
2868
|
+
? `${base}/replies/${urlPathSegment(replyId, { label: "replyId" })}`
|
|
2869
|
+
: `${base}/messages/${urlPathSegment(id, { label: "messageId" })}`);
|
|
2862
2870
|
const credential = await credentialFor(auth);
|
|
2863
2871
|
const failure = (response, payload) => {
|
|
2864
2872
|
const code = normalizeString(payload?.detail?.code || payload?.error?.code || payload?.code);
|
|
@@ -2869,21 +2877,23 @@ export async function editSessionMessage(sessionId, {
|
|
|
2869
2877
|
try {
|
|
2870
2878
|
// Sequence resolution and omitted-revision lookup are one authenticated GET.
|
|
2871
2879
|
if ((!replyId && !messageId) || revision === null) {
|
|
2872
|
-
const
|
|
2873
|
-
?
|
|
2880
|
+
const lookupUrl = replyId || messageId
|
|
2881
|
+
? targetUrl(messageId)
|
|
2882
|
+
: `${base}/messages/by-sequence/${urlPathSegment(sequence, { label: "targetSequenceId" })}`;
|
|
2874
2883
|
const authorQuery = normalizeString(agentId) ? `?agentId=${encodeURIComponent(normalizeString(agentId))}` : "";
|
|
2875
|
-
const { response, payload } = await fetchJsonWithFullTimeout(`${
|
|
2884
|
+
const { response, payload } = await fetchJsonWithFullTimeout(`${lookupUrl}${authorQuery}`, { method: "GET", credential, signal }, timeoutMs, fetchImpl, { readErrorBody: true });
|
|
2876
2885
|
if (!response?.ok || payload?.ok === false) return failure(response, payload);
|
|
2877
2886
|
const current = replyId ? payload?.reply : payload?.event;
|
|
2878
2887
|
const fetchedRevision = Number(current?.messageRevision ?? 1);
|
|
2879
|
-
|
|
2888
|
+
// A message id from the lookup is held to the same rule before it is used in a URL.
|
|
2889
|
+
if (!current?.id || (replyId ? current.id !== replyId : !isUrlPathSegment(current.id)) ||
|
|
2890
|
+
!Number.isSafeInteger(fetchedRevision) || fetchedRevision <= 0) return { ok: false, reason: "invalid_edit_target" };
|
|
2880
2891
|
if (current.canEdit === false) return { ok: false, reason: "MESSAGE_EDIT_FORBIDDEN", status: 403 };
|
|
2881
2892
|
if (!replyId) messageId = current.id;
|
|
2882
2893
|
if (revision === null) revision = fetchedRevision;
|
|
2883
2894
|
}
|
|
2884
|
-
const resource = replyId ? `replies/${encodeURIComponent(replyId)}` : `messages/${encodeURIComponent(messageId)}`;
|
|
2885
2895
|
const body = { text, expectedRevision: revision, ...(normalizeString(agentId) ? { agentId: normalizeString(agentId) } : {}) };
|
|
2886
|
-
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
2896
|
+
const { response, payload } = await fetchJsonWithFullTimeout(targetUrl(messageId), {
|
|
2887
2897
|
method: "PATCH",
|
|
2888
2898
|
headers: {
|
|
2889
2899
|
...createSessionMutationHeaders({ credential, sessionId: sid, routeId: replyId
|
|
@@ -2985,7 +2995,7 @@ export async function createSessionMessageAction(
|
|
|
2985
2995
|
}
|
|
2986
2996
|
|
|
2987
2997
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
2988
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
2998
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/actions`;
|
|
2989
2999
|
const body = {
|
|
2990
3000
|
actionType: normalizedActionType,
|
|
2991
3001
|
metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {},
|
|
@@ -3143,7 +3153,7 @@ export async function searchSessionEvents(
|
|
|
3143
3153
|
"limit",
|
|
3144
3154
|
String(Math.max(1, Math.min(SESSION_SEARCH_FETCH_LIMIT, normalizePositiveInteger(limit, 20))))
|
|
3145
3155
|
);
|
|
3146
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
3156
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/events/search?${queryParams.toString()}`;
|
|
3147
3157
|
|
|
3148
3158
|
try {
|
|
3149
3159
|
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
@@ -3389,7 +3399,7 @@ export async function fetchSessionFromApi(
|
|
|
3389
3399
|
}
|
|
3390
3400
|
|
|
3391
3401
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
3392
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
3402
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}`;
|
|
3393
3403
|
|
|
3394
3404
|
let response;
|
|
3395
3405
|
let body;
|
|
@@ -3468,9 +3478,7 @@ export async function probeSessionAccess(
|
|
|
3468
3478
|
}
|
|
3469
3479
|
|
|
3470
3480
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
3471
|
-
const endpoint = `${apiBaseUrl}/api/v1/sessions/${
|
|
3472
|
-
normalizedSessionId,
|
|
3473
|
-
)}/events?limit=1`;
|
|
3481
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/events?limit=1`;
|
|
3474
3482
|
|
|
3475
3483
|
let response;
|
|
3476
3484
|
try {
|
package/src/session/tickets.js
CHANGED
|
@@ -22,6 +22,7 @@ import process from "node:process";
|
|
|
22
22
|
import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
|
|
23
23
|
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
24
24
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
25
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
25
26
|
|
|
26
27
|
function normalizeString(value) {
|
|
27
28
|
return String(value ?? "").trim();
|
|
@@ -75,8 +76,14 @@ async function auth(targetPath, resolveAuthSession) {
|
|
|
75
76
|
return { credential: await credentialFor(session), apiUrl: normalizeString(session.apiUrl).replace(/\/+$/, "") };
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
/** The room's tickets URL; `route` is a fixed collection route ("/events"). */
|
|
80
|
+
function ticketsUrl(apiUrl, sessionId, route = "") {
|
|
81
|
+
return `${apiUrl}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/tickets${route}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One ticket's URL: the raw ticket id is encoded here; `route` is a fixed route ("/claim"). */
|
|
85
|
+
function ticketUrl(apiUrl, sessionId, ticketId, route) {
|
|
86
|
+
return `${ticketsUrl(apiUrl, sessionId)}/${urlPathSegment(ticketId, { label: "ticketId" })}${route}`;
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/** The Idempotency-Key for this logical write: reused until its response is stored. */
|
|
@@ -226,7 +233,7 @@ export async function claimTicket(
|
|
|
226
233
|
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
227
234
|
const { credential, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
228
235
|
const { key } = await operationKey(statePath, "claim", String(expectedVersion ?? "any"));
|
|
229
|
-
const response = await mutate(
|
|
236
|
+
const response = await mutate(ticketUrl(apiUrl, sessionId, ticketId, "/claim"), {
|
|
230
237
|
credential, key, operation: "claim", requestMutation,
|
|
231
238
|
body: expectedVersion == null ? {} : { expectedVersion: Number(expectedVersion) },
|
|
232
239
|
});
|
|
@@ -262,7 +269,7 @@ export async function renewTicketLease(
|
|
|
262
269
|
const held = await heldLease(statePath);
|
|
263
270
|
const { credential, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
264
271
|
const { key } = await operationKey(statePath, "renew", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
265
|
-
const response = await mutate(
|
|
272
|
+
const response = await mutate(ticketUrl(apiUrl, sessionId, ticketId, "/lease/renew"), {
|
|
266
273
|
credential, key, operation: "renew", requestMutation, body: { leaseId: held.leaseId, fence: held.fence },
|
|
267
274
|
});
|
|
268
275
|
await writeState(statePath, leaseState(response));
|
|
@@ -278,7 +285,7 @@ export async function releaseTicketLease(
|
|
|
278
285
|
const held = await heldLease(statePath);
|
|
279
286
|
const { credential, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
280
287
|
const { key } = await operationKey(statePath, "release", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
281
|
-
const response = await mutate(
|
|
288
|
+
const response = await mutate(ticketUrl(apiUrl, sessionId, ticketId, "/lease/release"), {
|
|
282
289
|
credential, key, operation: "release", requestMutation,
|
|
283
290
|
body: { leaseId: held.leaseId, fence: held.fence, expectedVersion: held.ticketVersion, ...(reason ? { reason } : {}) },
|
|
284
291
|
});
|
|
@@ -303,7 +310,7 @@ export async function submitTicket(
|
|
|
303
310
|
const held = await heldLease(statePath);
|
|
304
311
|
const { credential, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
305
312
|
const { key } = await operationKey(statePath, "submit", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
306
|
-
const response = await mutate(
|
|
313
|
+
const response = await mutate(ticketUrl(apiUrl, sessionId, ticketId, "/submit"), {
|
|
307
314
|
credential, key, operation: "submit", requestMutation,
|
|
308
315
|
body: {
|
|
309
316
|
leaseId: held.leaseId,
|
|
@@ -4,6 +4,7 @@ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/creden
|
|
|
4
4
|
import { SentinelayerApiError, requestJsonMutation } from "../auth/http.js";
|
|
5
5
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
6
6
|
import { recordSessionRemoteTitleSync } from "./store.js";
|
|
7
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TITLE_SYNC_TIMEOUT_MS = 2_000;
|
|
9
10
|
const DEFAULT_TITLE_SYNC_RETRY_DELAY_MS = 200;
|
|
@@ -78,7 +79,7 @@ export async function pushSessionTitleToApi(
|
|
|
78
79
|
}
|
|
79
80
|
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
80
81
|
const result = await checkedTransport(requestMutation)(
|
|
81
|
-
`${apiUrl}/api/v1/sessions/${
|
|
82
|
+
`${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/title`,
|
|
82
83
|
{
|
|
83
84
|
method: "POST",
|
|
84
85
|
operationName: "session.set_title",
|