sentinelayer-cli 0.46.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/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/commands/ai/identity-lifecycle.js +4 -3
- package/src/commands/chat.js +4 -1
- package/src/commands/session.js +4 -3
- 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/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 +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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./cli-command-tools.js";
|
|
10
10
|
import { appendToStream, readStream } from "../session/stream.js";
|
|
11
11
|
import { listFileLocks, lockFile, unlockFile } from "../session/file-locks.js";
|
|
12
|
+
import { resolveSessionDir } from "../session/paths.js";
|
|
12
13
|
import { eventMatchesAgent } from "../session/listener.js";
|
|
13
14
|
import { isSessionControlEvent } from "../session/control-events.js";
|
|
14
15
|
import { buildObservations } from "../session/recall/observations.js";
|
|
@@ -594,11 +595,14 @@ async function readSessionHistoryWindow({
|
|
|
594
595
|
};
|
|
595
596
|
}
|
|
596
597
|
|
|
597
|
-
|
|
598
|
+
// A native tool's session id also names its local session directory, so it is held to the
|
|
599
|
+
// session-id rule here, before any request is sent or any file is read or written.
|
|
600
|
+
function requireSessionId(input = {}, { targetPath } = {}) {
|
|
598
601
|
const sessionId = normalizeString(input.sessionId || input.session_id || input.session);
|
|
599
602
|
if (!sessionId) {
|
|
600
603
|
throw new Error("sessionId is required.");
|
|
601
604
|
}
|
|
605
|
+
resolveSessionDir(sessionId, { targetPath });
|
|
602
606
|
return sessionId;
|
|
603
607
|
}
|
|
604
608
|
|
|
@@ -712,7 +716,7 @@ async function runSessionAction({
|
|
|
712
716
|
targetPath,
|
|
713
717
|
createSessionMessageActionFn,
|
|
714
718
|
} = {}) {
|
|
715
|
-
const sessionId = requireSessionId(input);
|
|
719
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
716
720
|
const agentId = requireAgentId(input);
|
|
717
721
|
const normalizedActionType = normalizeSessionMessageActionType(actionType || input.actionType || input.action_type);
|
|
718
722
|
const target = requireSessionActionTarget(input);
|
|
@@ -995,7 +999,7 @@ export function createSessionMcpToolHandlers({
|
|
|
995
999
|
};
|
|
996
1000
|
const handlers = {
|
|
997
1001
|
async poll_inbox(input = {}) {
|
|
998
|
-
const sessionId = requireSessionId(input);
|
|
1002
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
999
1003
|
const agentId = requireAgentId(input);
|
|
1000
1004
|
const cursor = normalizeString(input.cursor || input.after || input.since) || null;
|
|
1001
1005
|
const limit = normalizeLimit(input.limit);
|
|
@@ -1071,7 +1075,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1071
1075
|
},
|
|
1072
1076
|
|
|
1073
1077
|
async read_history(input = {}) {
|
|
1074
|
-
const sessionId = requireSessionId(input);
|
|
1078
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
1075
1079
|
const cursor = normalizeString(input.cursor || input.after || input.since) || null;
|
|
1076
1080
|
const beforeSequence = normalizeOptionalPositiveInteger(
|
|
1077
1081
|
input.beforeSequence || input.before_sequence,
|
|
@@ -1164,7 +1168,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1164
1168
|
},
|
|
1165
1169
|
|
|
1166
1170
|
async send_message(input = {}) {
|
|
1167
|
-
const sessionId = requireSessionId(input);
|
|
1171
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
1168
1172
|
const agentId = requireAgentId(input);
|
|
1169
1173
|
const recipients = normalizeRecipients(input.to || input.recipient || input.recipients);
|
|
1170
1174
|
const agent = buildAgentEnvelope(agentId, input);
|
|
@@ -1223,7 +1227,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1223
1227
|
},
|
|
1224
1228
|
|
|
1225
1229
|
async session_lock(input = {}) {
|
|
1226
|
-
const sessionId = requireSessionId(input);
|
|
1230
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
1227
1231
|
const agentId = requireAgentId(input);
|
|
1228
1232
|
const files = normalizeFileList(input.files || input.file || input.paths);
|
|
1229
1233
|
const intent = normalizeString(input.intent || input.reason);
|
|
@@ -1257,7 +1261,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1257
1261
|
},
|
|
1258
1262
|
|
|
1259
1263
|
async session_unlock(input = {}) {
|
|
1260
|
-
const sessionId = requireSessionId(input);
|
|
1264
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
1261
1265
|
const agentId = requireAgentId(input);
|
|
1262
1266
|
const files = normalizeFileList(input.files || input.file || input.paths);
|
|
1263
1267
|
const reason = normalizeString(input.reason || input.intent) || "manual_release";
|
|
@@ -1290,7 +1294,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1290
1294
|
},
|
|
1291
1295
|
|
|
1292
1296
|
async session_locks(input = {}) {
|
|
1293
|
-
const sessionId = requireSessionId(input);
|
|
1297
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
1294
1298
|
const locks = await listFileLocksFn(sessionId, {
|
|
1295
1299
|
targetPath,
|
|
1296
1300
|
});
|
|
@@ -1304,7 +1308,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1304
1308
|
},
|
|
1305
1309
|
|
|
1306
1310
|
async attention_request(input = {}) {
|
|
1307
|
-
const sessionId = requireSessionId(input);
|
|
1311
|
+
const sessionId = requireSessionId(input, { targetPath });
|
|
1308
1312
|
const agentId = requireAgentId(input);
|
|
1309
1313
|
const recipients = normalizeRecipients(input.to || input.recipient || input.recipients);
|
|
1310
1314
|
const agent = buildAgentEnvelope(agentId, {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// gh-api-path.js — the values the CLI puts into GitHub API paths that gh requests.
|
|
2
|
+
//
|
|
3
|
+
// gh sends every request with the signed-in user's GitHub token. The CLI builds `gh api` paths
|
|
4
|
+
// (repos/<owner>/<repo>/...) and passes --repo values that gh turns into paths of its own, so each
|
|
5
|
+
// value is checked for its role before any gh process starts:
|
|
6
|
+
//
|
|
7
|
+
// ghRepoSlug exactly "owner/repo". The owner starts with a letter or digit, then letters,
|
|
8
|
+
// digits, "-" or "_" (Enterprise Managed User names end in "_shortcode"), at most
|
|
9
|
+
// 39 characters. The repository is letters, digits, ".", "_" or "-", at most 100
|
|
10
|
+
// characters, and not "." or "..".
|
|
11
|
+
// ghCommitSha 7 to 40 hexadecimal characters.
|
|
12
|
+
// ghRefSegment a git branch, tag or ref name under git's ref-name rules, not starting with "-",
|
|
13
|
+
// percent-encoded as ONE path segment. A branch name may contain "/", and git also
|
|
14
|
+
// allows "#", "%", "{" and other characters that mean something in a URL or to gh;
|
|
15
|
+
// encoded, they stay inside the segment. GitHub's compare ({base}...{head}) and
|
|
16
|
+
// commits/{ref} endpoints accept a branch name with "/" encoded as %2F.
|
|
17
|
+
//
|
|
18
|
+
// Each returns the value to put in the path, or throws an Error saying what the value must be.
|
|
19
|
+
// These are GitHub-specific rather than a general one-segment encoder: a branch name may contain
|
|
20
|
+
// "/" and still be one segment once encoded, and a slug or a commit SHA has a shape of its own.
|
|
21
|
+
|
|
22
|
+
const OWNER = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
|
|
23
|
+
const REPO = /^[A-Za-z0-9._-]{1,100}$/;
|
|
24
|
+
const COMMIT_SHA = /^[0-9A-Fa-f]{7,40}$/;
|
|
25
|
+
// Control characters, space, and the other characters git does not allow in a ref name.
|
|
26
|
+
const REF_FORBIDDEN = /[\u0000-\u0020\u007f-\u009f~^:?*[\\]/;
|
|
27
|
+
|
|
28
|
+
/** True when `value` is exactly "owner/repo" as GitHub names them. */
|
|
29
|
+
export function isGhRepoSlug(value) {
|
|
30
|
+
if (typeof value !== "string") return false;
|
|
31
|
+
const parts = value.split("/");
|
|
32
|
+
if (parts.length !== 2) return false;
|
|
33
|
+
const [owner, repo] = parts;
|
|
34
|
+
return OWNER.test(owner) && REPO.test(repo) && repo !== "." && repo !== "..";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when `value` is a valid git branch, tag or ref name that does not start with "-". */
|
|
38
|
+
export function isGitRefName(value) {
|
|
39
|
+
if (typeof value !== "string" || !value || !value.isWellFormed()) return false;
|
|
40
|
+
if (value === "@" || value.includes("..") || value.includes("@{") || value.includes("//")) return false;
|
|
41
|
+
if (REF_FORBIDDEN.test(value)) return false;
|
|
42
|
+
if (value.startsWith("-") || value.startsWith("/") || value.endsWith("/") || value.endsWith(".")) return false;
|
|
43
|
+
return value.split("/").every((part) => !part.startsWith(".") && !part.endsWith(".lock"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `value` as an owner/repo path, or throws. */
|
|
47
|
+
export function ghRepoSlug(value, { label = "repo" } = {}) {
|
|
48
|
+
if (!isGhRepoSlug(value)) {
|
|
49
|
+
throw new Error(`${label} must be a GitHub repository in owner/repo form.`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `value` as a commit SHA path segment, or throws. */
|
|
55
|
+
export function ghCommitSha(value, { label = "sha" } = {}) {
|
|
56
|
+
if (typeof value !== "string" || !COMMIT_SHA.test(value)) {
|
|
57
|
+
throw new Error(`${label} must be a commit SHA of 7 to 40 hexadecimal characters.`);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `value` (a git branch, tag or ref name) percent-encoded as one path segment, or throws. */
|
|
63
|
+
export function ghRefSegment(value, { label = "ref" } = {}) {
|
|
64
|
+
if (!isGitRefName(value)) {
|
|
65
|
+
throw new Error(`${label} must be a valid git branch, tag or ref name.`);
|
|
66
|
+
}
|
|
67
|
+
return encodeURIComponent(value);
|
|
68
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// url-path.js — one identifier, one URL path segment.
|
|
2
|
+
//
|
|
3
|
+
// The CLI puts identifiers into API URL paths: session, admission, ticket, lease, checkpoint, run,
|
|
4
|
+
// token, message, reply, scan and AIdenID ids. urlPathSegment encodes each one as exactly one
|
|
5
|
+
// segment, and refuses a value that cannot be one: empty, "." or "..", or containing "/", "\", a
|
|
6
|
+
// control character or an unpaired surrogate. The URL builders that take ids (the session,
|
|
7
|
+
// ticket, admission and lease URL helpers) take the raw id and call it themselves.
|
|
8
|
+
//
|
|
9
|
+
// tests/unit.url-path-segments.test.mjs holds the census of src/ and says exactly which forms it
|
|
10
|
+
// checks. assertCredentialDestination (src/auth/credential-destinations.js) also checks the path
|
|
11
|
+
// of every URL it sends a credential to.
|
|
12
|
+
|
|
13
|
+
// C0 controls, DEL and C1 controls.
|
|
14
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
|
|
15
|
+
|
|
16
|
+
/** Whether urlPathSegment accepts `value`. Use it to refuse an id before any request work starts. */
|
|
17
|
+
export function isUrlPathSegment(value) {
|
|
18
|
+
const text = String(value ?? "");
|
|
19
|
+
return (
|
|
20
|
+
text !== "" &&
|
|
21
|
+
text !== "." &&
|
|
22
|
+
text !== ".." &&
|
|
23
|
+
!/[/\\]/.test(text) &&
|
|
24
|
+
!CONTROL_CHARACTER.test(text) &&
|
|
25
|
+
text.isWellFormed()
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `value` encoded as exactly one URL path segment. Refuses an empty value, "." or "..", and a
|
|
31
|
+
* value containing "/", "\", a control character or an unpaired surrogate. "%" is encoded, so
|
|
32
|
+
* "%2e%2e" is sent as "%252e%252e" and stays one literal segment.
|
|
33
|
+
*/
|
|
34
|
+
export function urlPathSegment(value, { label = "identifier" } = {}) {
|
|
35
|
+
if (!isUrlPathSegment(value)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`${label} must be a single URL path segment: not empty, "." or "..", and without "/", "\\" or control characters.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return encodeURIComponent(String(value ?? ""));
|
|
41
|
+
}
|
|
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
|
|
4
4
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
5
5
|
import { requestJson } from "../auth/http.js";
|
|
6
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
6
7
|
|
|
7
8
|
export const DD_REPORT_EMAIL_TIMEOUT_MS = 10_000;
|
|
8
9
|
|
|
@@ -108,9 +109,7 @@ export async function sendDdReportEmail({
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const apiUrl = normalizeString(session.apiUrl) || "https://api.sentinelayer.com";
|
|
111
|
-
const endpoint = `${apiUrl.replace(/\/+$/, "")}/api/v1/runs/${
|
|
112
|
-
normalizedRunId,
|
|
113
|
-
)}/send-report-email`;
|
|
112
|
+
const endpoint = `${apiUrl.replace(/\/+$/, "")}/api/v1/runs/${urlPathSegment(normalizedRunId, { label: "runId" })}/send-report-email`;
|
|
114
113
|
const idempotencyKey = buildReportEmailIdempotencyKey({
|
|
115
114
|
runId: normalizedRunId,
|
|
116
115
|
to: normalizedTo,
|
package/src/scan/gh-secrets.js
CHANGED
|
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
|
|
5
|
+
import { isGhRepoSlug } from "../net/gh-api-path.js";
|
|
6
|
+
|
|
5
7
|
function getGhCommand() {
|
|
6
8
|
return String(process.env.SENTINELAYER_GH_BIN || "").trim() || "gh";
|
|
7
9
|
}
|
|
@@ -10,8 +12,9 @@ function normalizeRepoSlug(value) {
|
|
|
10
12
|
return String(value || "").trim().replace(/\.git$/i, "");
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
// Exactly owner/repo as GitHub names them: gh turns the --repo value into API paths.
|
|
13
16
|
function isValidRepoSlug(value) {
|
|
14
|
-
return
|
|
17
|
+
return isGhRepoSlug(normalizeRepoSlug(value));
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
function isValidSecretName(value) {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
createSessionMutationHeaders,
|
|
19
19
|
createSessionMutationIdempotencyKey,
|
|
20
20
|
} from "./invitations.js";
|
|
21
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Approve, deny, revoke and mode changes are the room owner's decisions, made with the
|
|
@@ -84,8 +85,13 @@ async function authContext(targetPath, resolveAuthSession) {
|
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function admissionsUrl(apiUrl, sessionId
|
|
88
|
-
return `${apiUrl}/api/v1/sessions/${
|
|
88
|
+
function admissionsUrl(apiUrl, sessionId) {
|
|
89
|
+
return `${apiUrl}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/admissions`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One admission's URL: the raw admission id is encoded here; `route` is a fixed route ("/revoke"). */
|
|
93
|
+
function admissionMemberUrl(apiUrl, sessionId, admissionId, route = "") {
|
|
94
|
+
return `${admissionsUrl(apiUrl, sessionId)}/${urlPathSegment(admissionId, { label: "admissionId" })}${route}`;
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
function boundedIdempotencyKey(value, operation) {
|
|
@@ -96,7 +102,8 @@ function boundedIdempotencyKey(value, operation) {
|
|
|
96
102
|
|
|
97
103
|
async function mutateAdmission(
|
|
98
104
|
sessionId,
|
|
99
|
-
|
|
105
|
+
admissionId,
|
|
106
|
+
route,
|
|
100
107
|
routeId,
|
|
101
108
|
body,
|
|
102
109
|
{
|
|
@@ -110,7 +117,7 @@ async function mutateAdmission(
|
|
|
110
117
|
) {
|
|
111
118
|
const key = boundedIdempotencyKey(idempotencyKey, operationName);
|
|
112
119
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
113
|
-
const result = await checkedTransport(requestMutation)(
|
|
120
|
+
const result = await checkedTransport(requestMutation)(admissionMemberUrl(auth.apiUrl, sessionId, admissionId, route), {
|
|
114
121
|
method: "POST",
|
|
115
122
|
operationName,
|
|
116
123
|
idempotencyKey: key,
|
|
@@ -163,7 +170,7 @@ export async function getSessionAdmission(
|
|
|
163
170
|
const sid = required(sessionId, "session id");
|
|
164
171
|
const aid = required(admissionId, "admission id");
|
|
165
172
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
166
|
-
return checkedTransport(requestRead)(
|
|
173
|
+
return checkedTransport(requestRead)(admissionMemberUrl(auth.apiUrl, sid, aid), {
|
|
167
174
|
method: "GET",
|
|
168
175
|
credential: auth.credential,
|
|
169
176
|
});
|
|
@@ -201,7 +208,8 @@ export async function decideSessionAdmission(
|
|
|
201
208
|
await assertOwnerAccessContext();
|
|
202
209
|
return mutateAdmission(
|
|
203
210
|
sid,
|
|
204
|
-
|
|
211
|
+
aid,
|
|
212
|
+
"/decision",
|
|
205
213
|
ROUTES.decision,
|
|
206
214
|
body,
|
|
207
215
|
{
|
|
@@ -233,7 +241,8 @@ export async function revokeSessionAdmission(
|
|
|
233
241
|
await assertOwnerAccessContext();
|
|
234
242
|
return mutateAdmission(
|
|
235
243
|
sid,
|
|
236
|
-
|
|
244
|
+
aid,
|
|
245
|
+
"/revoke",
|
|
237
246
|
ROUTES.revoke,
|
|
238
247
|
normalizedReason ? { reason: normalizedReason } : {},
|
|
239
248
|
{
|
|
@@ -267,7 +276,7 @@ export async function setSessionAdmissionMode(
|
|
|
267
276
|
const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
|
|
268
277
|
const auth = await authContext(targetPath, resolveAuthSession);
|
|
269
278
|
const result = await checkedTransport(requestMutation)(
|
|
270
|
-
`${auth.apiUrl}/api/v1/sessions/${
|
|
279
|
+
`${auth.apiUrl}/api/v1/sessions/${urlPathSegment(sid, { label: "sessionId" })}/admission-mode`,
|
|
271
280
|
{
|
|
272
281
|
method: "POST",
|
|
273
282
|
operationName: "session.admission_mode",
|
package/src/session/admission.js
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
createSessionMutationIdempotencyKey,
|
|
42
42
|
} from "./invitations.js";
|
|
43
43
|
import { resolveSessionPaths } from "./paths.js";
|
|
44
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
44
45
|
|
|
45
46
|
export const ADMISSION_ACTIONS = Object.freeze([
|
|
46
47
|
"session.read",
|
|
@@ -373,8 +374,14 @@ async function authContext({ targetPath, resolveAuthSession }) {
|
|
|
373
374
|
return { credential: await credentialFor(auth), apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, "") };
|
|
374
375
|
}
|
|
375
376
|
|
|
376
|
-
|
|
377
|
-
|
|
377
|
+
/** The session's admissions URL; `route` is a fixed collection route ("/self"). */
|
|
378
|
+
function admissionUrl(apiUrl, sessionId, route = "") {
|
|
379
|
+
return `${apiUrl}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/admissions${route}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** One admission's URL: the raw admission id is encoded here; `route` is a fixed route ("/claim"). */
|
|
383
|
+
function admissionMemberUrl(apiUrl, sessionId, admissionId, route = "") {
|
|
384
|
+
return `${admissionUrl(apiUrl, sessionId)}/${urlPathSegment(admissionId, { label: "admissionId" })}${route}`;
|
|
378
385
|
}
|
|
379
386
|
|
|
380
387
|
async function mutate(auth, sessionId, routeId, url, body, { requestMutation, operationName, origin }) {
|
|
@@ -530,7 +537,7 @@ export async function runAdmissionJoin(
|
|
|
530
537
|
const deadline = now() + Math.max(0, waitTimeoutMs);
|
|
531
538
|
let polled;
|
|
532
539
|
for (;;) {
|
|
533
|
-
polled = await checkedTransport(requestRead)(
|
|
540
|
+
polled = await checkedTransport(requestRead)(admissionMemberUrl(auth.apiUrl, sid, state.admissionId), {
|
|
534
541
|
method: "GET",
|
|
535
542
|
credential: auth.credential,
|
|
536
543
|
});
|
|
@@ -599,7 +606,7 @@ export async function runAdmissionJoin(
|
|
|
599
606
|
auth,
|
|
600
607
|
sid,
|
|
601
608
|
ROUTE.claim,
|
|
602
|
-
|
|
609
|
+
admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/claim"),
|
|
603
610
|
{ nonce: fields.nonce, signature: signature.toString("base64url") },
|
|
604
611
|
{ ...deps, operationName: "session.admission_claim" }
|
|
605
612
|
);
|
|
@@ -702,7 +709,7 @@ export async function cancelAdmission(
|
|
|
702
709
|
auth,
|
|
703
710
|
sid,
|
|
704
711
|
ROUTE.cancel,
|
|
705
|
-
|
|
712
|
+
admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/cancel"),
|
|
706
713
|
undefined,
|
|
707
714
|
{ requestMutation, origin, operationName: "session.admission_cancel" }
|
|
708
715
|
);
|
|
@@ -3,6 +3,7 @@ import fsp from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
|
|
6
|
+
import { resolveAgentIdPath } from "../agents/agent-id-path.js";
|
|
6
7
|
import { STUCK_THRESHOLDS } from "../agents/jules/pulse.js";
|
|
7
8
|
import { inferSessionAgentIdentity } from "./agent-identity.js";
|
|
8
9
|
import { resolveSessionPaths } from "./paths.js";
|
|
@@ -175,12 +176,10 @@ async function writeAgentSnapshot(snapshotPath, snapshot) {
|
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
// <session>/agents/<agentId>.json, refused before any read or write unless the id names one
|
|
180
|
+
// file directly under the agents directory.
|
|
178
181
|
function buildAgentSnapshotPath(paths, agentId) {
|
|
179
|
-
|
|
180
|
-
if (!normalizedAgentId) {
|
|
181
|
-
throw new Error("agentId is required.");
|
|
182
|
-
}
|
|
183
|
-
return path.join(paths.agentsDir, `${normalizedAgentId}.json`);
|
|
182
|
+
return resolveAgentIdPath(paths.agentsDir, normalizeString(agentId), ".json");
|
|
184
183
|
}
|
|
185
184
|
|
|
186
185
|
export function generateAgentId(modelName) {
|
|
@@ -5,6 +5,7 @@ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/creden
|
|
|
5
5
|
import { SentinelayerApiError, requestJson, requestJsonMutation } from "../auth/http.js";
|
|
6
6
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
7
7
|
import { pollSessionEventsBefore } from "./sync.js";
|
|
8
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
8
9
|
|
|
9
10
|
const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
|
|
10
11
|
const DEFAULT_CHECKPOINT_LIMIT = 100;
|
|
@@ -442,7 +443,7 @@ export async function listSessionCheckpoints(sessionId, {
|
|
|
442
443
|
const { apiUrl, credential } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
|
|
443
444
|
const params = new URLSearchParams({ limit: String(normalizeLimit(limit)) });
|
|
444
445
|
const response = await request(
|
|
445
|
-
`${apiUrl}/api/v1/sessions/${
|
|
446
|
+
`${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/checkpoints?${params.toString()}`,
|
|
446
447
|
{ method: "GET", credential },
|
|
447
448
|
);
|
|
448
449
|
const checkpoints = Array.isArray(response?.checkpoints) ? response.checkpoints : [];
|
|
@@ -522,7 +523,7 @@ export async function createSessionCheckpoint(sessionId, options = {}) {
|
|
|
522
523
|
const { body, idempotencyKey } = buildManualCheckpointPayload(normalizedSessionId, options);
|
|
523
524
|
const { apiUrl, credential } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
|
|
524
525
|
const response = await checkedTransport(requestMutation)(
|
|
525
|
-
`${apiUrl}/api/v1/sessions/${
|
|
526
|
+
`${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/checkpoints`,
|
|
526
527
|
{
|
|
527
528
|
operationName: "session-checkpoint-create",
|
|
528
529
|
credential,
|
|
@@ -549,7 +550,7 @@ export async function generateSessionCheckpoint(sessionId, options = {}) {
|
|
|
549
550
|
const { body, idempotencyKey } = buildGenerateCheckpointPayload(normalizedSessionId, options);
|
|
550
551
|
const { apiUrl, credential } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
|
|
551
552
|
const response = await checkedTransport(requestMutation)(
|
|
552
|
-
`${apiUrl}/api/v1/sessions/${
|
|
553
|
+
`${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/checkpoints/generate`,
|
|
553
554
|
{
|
|
554
555
|
operationName: "session-checkpoint-generate",
|
|
555
556
|
credential,
|
|
@@ -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
|
|