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.
Files changed (38) hide show
  1. package/bin/create-sentinelayer.js +2 -2
  2. package/bin/sentinelayer-cli.js +2 -2
  3. package/bin/sl.js +2 -2
  4. package/package.json +1 -1
  5. package/src/agents/agent-id-path.js +57 -0
  6. package/src/agents/jules/tools/runtime-audit.js +2 -1
  7. package/src/ai/aidenid.js +11 -10
  8. package/src/ai/client.js +2 -1
  9. package/src/audit/orchestrator.js +7 -1
  10. package/src/audit/registry.js +9 -1
  11. package/src/auth/credential-destinations.js +57 -13
  12. package/src/auth/http.js +4 -0
  13. package/src/auth/service.js +4 -3
  14. package/src/board/evidence-resolvers.js +15 -3
  15. package/src/cli-bootstrap.js +20 -0
  16. package/src/cli.js +3 -1
  17. package/src/commands/ai/identity-lifecycle.js +4 -3
  18. package/src/commands/chat.js +4 -1
  19. package/src/commands/session.js +51 -17
  20. package/src/legacy-cli.js +4 -11
  21. package/src/mcp/session-stdio-server.js +13 -9
  22. package/src/net/gh-api-path.js +68 -0
  23. package/src/net/url-path.js +41 -0
  24. package/src/review/dd-report-email-client.js +2 -3
  25. package/src/scan/gh-secrets.js +4 -1
  26. package/src/session/admission-access.js +17 -8
  27. package/src/session/admission.js +12 -5
  28. package/src/session/agent-registry.js +4 -5
  29. package/src/session/checkpoints.js +4 -3
  30. package/src/session/file-locks.js +8 -6
  31. package/src/session/invitations.js +2 -1
  32. package/src/session/listener-process.js +118 -32
  33. package/src/session/live-source.js +2 -3
  34. package/src/session/paths.js +51 -2
  35. package/src/session/store.js +3 -3
  36. package/src/session/sync.js +34 -26
  37. package/src/session/tickets.js +13 -6
  38. package/src/session/title-sync.js +2 -1
@@ -76,6 +76,7 @@ import {
76
76
  import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
77
77
  import {
78
78
  getListenerProcessStatus,
79
+ LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
79
80
  removeListenerPidRecord,
80
81
  requestListenerProcessStop,
81
82
  summarizeLocalListenerProcesses,
@@ -198,6 +199,7 @@ import {
198
199
  import createSentid from "../session/wake/sentid.js";
199
200
  import { authLoginHint, preferredCliCommand } from "../ui/command-hints.js";
200
201
  import { parseCsvTokens } from "./ai/shared.js";
202
+ import { urlPathSegment } from "../net/url-path.js";
201
203
 
202
204
  function shouldEmitJson(options, command) {
203
205
  const local = Boolean(options && options.json);
@@ -1515,7 +1517,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1515
1517
  if (!apiUrl) {
1516
1518
  return { ok: false, reason: "no_api_url" };
1517
1519
  }
1518
- const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1520
+ const endpoint = `${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}`;
1519
1521
  const credential = await credentialFor(auth);
1520
1522
  const firstAttempt = await fetchRemoteSessionDetail(endpoint, credential);
1521
1523
  const detail = firstAttempt.retryable
@@ -3301,7 +3303,7 @@ export function registerSessionCommand(program) {
3301
3303
  }
3302
3304
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
3303
3305
  const result = await requestJsonMutation(
3304
- `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/title`,
3306
+ `${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/title`,
3305
3307
  {
3306
3308
  method: "POST",
3307
3309
  operationName: "session.set_title",
@@ -5148,16 +5150,17 @@ export function registerSessionCommand(program) {
5148
5150
  let localProcessScan = {
5149
5151
  ok: false,
5150
5152
  reason: listeners.length > 0 ? "not_scanned" : "no_listener_rows",
5151
- processCount: 0,
5152
- duplicateProcessCount: 0,
5153
+ // Not scanned, so no count: null, never 0, so a reader that skips `ok` can't read "no duplicates".
5154
+ processCount: null,
5155
+ duplicateProcessCount: null,
5153
5156
  agents: [],
5154
5157
  };
5155
5158
  if (listeners.length > 0) {
5156
- try {
5157
- const summary = await summarizeLocalListenerProcesses(
5158
- normalizedSessionId,
5159
- listeners.map((row) => row.agentId),
5160
- );
5159
+ const summary = await summarizeLocalListenerProcesses(
5160
+ normalizedSessionId,
5161
+ listeners.map((row) => row.agentId),
5162
+ );
5163
+ if (summary.ok) {
5161
5164
  const byAgent = new Map(summary.agents.map((entry) => [entry.agentId, entry]));
5162
5165
  listeners = listeners.map((row) => {
5163
5166
  const local = byAgent.get(row.agentId) || {};
@@ -5175,12 +5178,13 @@ export function registerSessionCommand(program) {
5175
5178
  duplicateProcessCount: Number(summary.duplicateProcessCount || 0),
5176
5179
  agents: summary.agents,
5177
5180
  };
5178
- } catch (error) {
5181
+ } else {
5182
+ // The rows stay unannotated: an unread process table is not "no local listeners".
5179
5183
  localProcessScan = {
5180
5184
  ok: false,
5181
- reason: normalizeString(error?.message) || "process_scan_failed",
5182
- processCount: 0,
5183
- duplicateProcessCount: 0,
5185
+ reason: summary.reason,
5186
+ processCount: null,
5187
+ duplicateProcessCount: null,
5184
5188
  agents: [],
5185
5189
  };
5186
5190
  }
@@ -5230,6 +5234,11 @@ export function registerSessionCommand(program) {
5230
5234
  else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
5231
5235
  else console.log(pc.gray(` ${line}`));
5232
5236
  }
5237
+ if (!localProcessScan.ok) {
5238
+ console.log(pc.yellow(
5239
+ `Local listener processes: unknown. The local process check did not complete (${localProcessScan.reason}), so duplicate local listeners were not checked.`,
5240
+ ));
5241
+ }
5233
5242
  const duplicateLocalRows = listeners.filter((row) => Number(row.localDuplicateProcessCount || 0) > 0);
5234
5243
  if (duplicateLocalRows.length > 0) {
5235
5244
  const summary = duplicateLocalRows
@@ -5483,9 +5492,23 @@ export function registerSessionCommand(program) {
5483
5492
  const singletonEnabled = maxPolls === null && !options.allowDuplicate;
5484
5493
  let listenerPidRecordActive = false;
5485
5494
  if (singletonEnabled) {
5486
- const existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
5495
+ let existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
5487
5496
  targetPath,
5488
5497
  });
5498
+ if (existingListener.state === "unknown") {
5499
+ // A busy machine can need longer than the first limit: check once more, slower.
5500
+ existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
5501
+ targetPath,
5502
+ scanTimeoutMs: LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
5503
+ });
5504
+ }
5505
+ if (existingListener.state === "unknown") {
5506
+ // Starting without an answer could add a second listener, so refuse, with or
5507
+ // without --force: --force replaces the listener the check finds.
5508
+ throw new Error(
5509
+ `Could not confirm whether a session listener is already running for session ${normalizedSessionId} as ${agentId}: the local process check did not complete (${existingListener.reason}), including one retry with a longer time limit. No listener was started. Run the command again, or use --allow-duplicate to start without this check (it can then run alongside an existing listener). --force does not skip the check: it stops the listener the check finds, then starts this one.`,
5510
+ );
5511
+ }
5489
5512
  if (existingListener.running && !options.force) {
5490
5513
  throw new Error(
5491
5514
  `A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${existingListener.pid}). Use --force to take over or --allow-duplicate if you intentionally need more than one.`,
@@ -5493,8 +5516,19 @@ export function registerSessionCommand(program) {
5493
5516
  }
5494
5517
  if (existingListener.running && options.force) {
5495
5518
  const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId);
5519
+ if (!stopMatchesResult.ok) {
5520
+ // The listener being replaced is known (a verified pid record or a scan moments
5521
+ // ago), so stopping it and starting this one never adds a listener. Only the
5522
+ // cleanup of extra copies is skipped, and that is said rather than assumed.
5523
+ console.error(
5524
+ pc.yellow(
5525
+ `The local process check did not complete (${stopMatchesResult.reason}); replacing listener pid ${existingListener.pid} only. Other copies of this listener, if any, were not checked.`,
5526
+ ),
5527
+ );
5528
+ }
5529
+ const scannedStops = stopMatchesResult.ok ? stopMatchesResult.results : [];
5496
5530
  const stoppedPids = new Set(
5497
- stopMatchesResult.results
5531
+ scannedStops
5498
5532
  .filter((result) => result.stopped)
5499
5533
  .map((result) => Number(result.pid)),
5500
5534
  );
@@ -5503,7 +5537,7 @@ export function registerSessionCommand(program) {
5503
5537
  explicitStopResult = await requestListenerProcessStop(existingListener.pid);
5504
5538
  }
5505
5539
  const failedStops = [
5506
- ...stopMatchesResult.failed,
5540
+ ...scannedStops.filter((result) => !result.stopped),
5507
5541
  ...(explicitStopResult && !explicitStopResult.stopped
5508
5542
  ? [{ pid: existingListener.pid, ...explicitStopResult }]
5509
5543
  : []),
@@ -8744,7 +8778,7 @@ export function registerSessionCommand(program) {
8744
8778
  try {
8745
8779
  result = await postAdminSessionMutation({
8746
8780
  session: apiSession,
8747
- pathSuffix: `/api/v1/admin/sessions/${encodeURIComponent(normalizedSessionId)}/kill`,
8781
+ pathSuffix: `/api/v1/admin/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/kill`,
8748
8782
  operationName: "session-admin-kill",
8749
8783
  body: { reason },
8750
8784
  });
package/src/legacy-cli.js CHANGED
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env node
2
-
3
1
  import crypto from "node:crypto";
4
2
  import fs from "node:fs";
5
3
  import fsp from "node:fs/promises";
@@ -8,7 +6,6 @@ import process from "node:process";
8
6
  import { spawnSync } from "node:child_process";
9
7
  import { createInterface } from "node:readline/promises";
10
8
  import { stdin as input, stdout as output } from "node:process";
11
- import { pathToFileURL } from "node:url";
12
9
 
13
10
  import open from "open";
14
11
  import pc from "picocolors";
@@ -31,6 +28,7 @@ import { normalizeAgentEvent } from "./events/schema.js";
31
28
  import { collectCodebaseIngest, formatIngestSummary } from "./ingest/engine.js";
32
29
  import { getExpressTemplate, getPackageJsonTemplate, buildReadmeContent } from "./scaffold/templates.js";
33
30
  import { generateScaffold } from "./scaffold/generator.js";
31
+ import { isGhRepoSlug } from "./net/gh-api-path.js";
34
32
  import {
35
33
  getCoordinationEtiquetteItems,
36
34
  renderCoordinationNumberedList,
@@ -117,8 +115,10 @@ function normalizeRepoSlug(value) {
117
115
  return String(value || "").trim().replace(/\.git$/i, "");
118
116
  }
119
117
 
118
+ // Exactly owner/repo as GitHub names them (src/net/gh-api-path.js): the slug goes into gh API
119
+ // paths and --repo values, and into the clone and remote URLs.
120
120
  function isValidRepoSlug(value) {
121
- return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizeRepoSlug(value));
121
+ return isGhRepoSlug(normalizeRepoSlug(value));
122
122
  }
123
123
 
124
124
  function getRepoNameFromSlug(value) {
@@ -3761,10 +3761,3 @@ export async function runLegacyCliWithErrorHandling(rawArgs = process.argv.slice
3761
3761
  }
3762
3762
  }
3763
3763
 
3764
- const invokedAsEntrypoint =
3765
- process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
3766
-
3767
- if (invokedAsEntrypoint) {
3768
- runLegacyCliWithErrorHandling();
3769
- }
3770
-
@@ -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
- function requireSessionId(input = {}) {
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/${encodeURIComponent(
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,
@@ -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 /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizeRepoSlug(value));
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, suffix = "") {
88
- return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/admissions${suffix}`;
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
- suffix,
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)(admissionsUrl(auth.apiUrl, sessionId, suffix), {
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)(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
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
- `/${encodeURIComponent(aid)}/decision`,
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
- `/${encodeURIComponent(aid)}/revoke`,
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/${encodeURIComponent(sid)}/admission-mode`,
279
+ `${auth.apiUrl}/api/v1/sessions/${urlPathSegment(sid, { label: "sessionId" })}/admission-mode`,
271
280
  {
272
281
  method: "POST",
273
282
  operationName: "session.admission_mode",
@@ -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
- function admissionUrl(apiUrl, sessionId, suffix = "") {
377
- return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/admissions${suffix}`;
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)(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
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
- admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}/claim`),
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
- admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}/cancel`),
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
- const normalizedAgentId = normalizeString(agentId);
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/${encodeURIComponent(normalizedSessionId)}/checkpoints?${params.toString()}`,
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/${encodeURIComponent(normalizedSessionId)}/checkpoints`,
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/${encodeURIComponent(normalizedSessionId)}/checkpoints/generate`,
553
+ `${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/checkpoints/generate`,
553
554
  {
554
555
  operationName: "session-checkpoint-generate",
555
556
  credential,