sentinelayer-cli 0.46.4 → 0.48.0

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 (137) hide show
  1. package/README.md +63 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +46 -8
  26. package/src/auth/service.js +34 -15
  27. package/src/auth/session-store.js +105 -47
  28. package/src/board/evidence-resolvers.js +3 -4
  29. package/src/cli.js +23 -3
  30. package/src/commands/ai/identity-lifecycle.js +30 -26
  31. package/src/commands/ai/provision-governance.js +32 -24
  32. package/src/commands/ai/shared.js +6 -4
  33. package/src/commands/audit.js +14 -12
  34. package/src/commands/auth.js +24 -4
  35. package/src/commands/board.js +2 -2
  36. package/src/commands/chat.js +8 -6
  37. package/src/commands/judge.js +125 -0
  38. package/src/commands/legacy-args.js +1 -0
  39. package/src/commands/mcp.js +45 -52
  40. package/src/commands/omargate.js +2 -1
  41. package/src/commands/plugin.js +11 -3
  42. package/src/commands/review.js +9 -4
  43. package/src/commands/scan.js +7 -7
  44. package/src/commands/session.js +607 -57
  45. package/src/commands/skills.js +45 -0
  46. package/src/commands/watch.js +9 -7
  47. package/src/config/output-filesystem.js +156 -0
  48. package/src/config/schema.js +4 -2
  49. package/src/config/service.js +40 -4
  50. package/src/cost/history.js +15 -9
  51. package/src/daemon/artifact-lineage.js +15 -12
  52. package/src/daemon/assignment-ledger.js +31 -25
  53. package/src/daemon/ast-drift.js +7 -3
  54. package/src/daemon/budget-governor.js +26 -20
  55. package/src/daemon/error-worker.js +28 -22
  56. package/src/daemon/fix-cycle.js +5 -4
  57. package/src/daemon/hybrid-mapper.js +19 -16
  58. package/src/daemon/ingest-refresh.js +4 -0
  59. package/src/daemon/jira-lifecycle.js +21 -17
  60. package/src/daemon/operator-control.js +18 -15
  61. package/src/daemon/reliability-lane.js +18 -15
  62. package/src/daemon/scope-engine.js +12 -8
  63. package/src/daemon/watchdog.js +15 -12
  64. package/src/engram/build-state.js +24 -3
  65. package/src/engram/capability-index.js +367 -0
  66. package/src/engram/compile-graph.js +1063 -0
  67. package/src/engram/contracts/action-receipt.js +82 -0
  68. package/src/engram/contracts/capability-index.js +259 -0
  69. package/src/engram/contracts/capability.js +377 -0
  70. package/src/engram/contracts/content-hash.js +8 -0
  71. package/src/engram/contracts/evidence.js +258 -0
  72. package/src/engram/contracts/graph.js +594 -0
  73. package/src/engram/contracts/identity.js +8 -0
  74. package/src/engram/contracts/index.js +149 -0
  75. package/src/engram/contracts/media-asset.js +85 -0
  76. package/src/engram/contracts/money.js +249 -0
  77. package/src/engram/contracts/source-snapshot.js +57 -0
  78. package/src/engram/contracts/task-context-pack.js +114 -0
  79. package/src/engram/contracts/temporal-segment.js +72 -0
  80. package/src/engram/contracts/versions.js +96 -0
  81. package/src/engram/extract-html.js +692 -0
  82. package/src/engram/graph-store.js +877 -0
  83. package/src/engram/index.js +98 -14
  84. package/src/engram/ingest-site.js +132 -0
  85. package/src/engram/live-state.js +1180 -0
  86. package/src/engram/namespace.js +34 -1
  87. package/src/engram/retrieve-site.js +753 -0
  88. package/src/engram/safe-reader.js +597 -0
  89. package/src/engram/source-intake.js +251 -0
  90. package/src/engram/store.js +227 -9
  91. package/src/ingest/engine.js +21 -28
  92. package/src/judge/verdict.js +305 -0
  93. package/src/legacy-cli.js +96 -19
  94. package/src/mcp/bridge-inputs.js +28 -4
  95. package/src/mcp/cli-command-tools.js +70 -0
  96. package/src/mcp/registry.js +17 -13
  97. package/src/mcp/session-stdio-server.js +297 -19
  98. package/src/memory/blackboard.js +5 -4
  99. package/src/net/gh-api-path.js +35 -9
  100. package/src/net/test-egress-guard.js +16 -4
  101. package/src/plugin/manifest.js +12 -8
  102. package/src/review/ai-review.js +110 -10
  103. package/src/review/investor-dd-devtestbot.js +17 -6
  104. package/src/review/investor-dd-orchestrator.js +21 -13
  105. package/src/review/local-review.js +26 -16
  106. package/src/review/omargate-cache.js +5 -5
  107. package/src/review/omargate-orchestrator.js +89 -17
  108. package/src/review/replay.js +9 -4
  109. package/src/review/report.js +22 -13
  110. package/src/review/typesafe-severity.js +201 -0
  111. package/src/session/admission-auth.js +34 -7
  112. package/src/session/admission.js +322 -71
  113. package/src/session/agent-access.js +137 -0
  114. package/src/session/agent-registry.js +2 -1
  115. package/src/session/attachment-summary.js +77 -0
  116. package/src/session/edit-summary.js +19 -0
  117. package/src/session/invitations.js +65 -1
  118. package/src/session/mentions.js +17 -9
  119. package/src/session/message-edits.js +5 -4
  120. package/src/session/preview.js +10 -2
  121. package/src/session/recap.js +3 -2
  122. package/src/session/room-files.js +519 -0
  123. package/src/session/route-classes.js +22 -8
  124. package/src/session/setup-guides.js +4 -0
  125. package/src/session/standup.js +192 -0
  126. package/src/session/sync.js +229 -10
  127. package/src/session/tickets.js +41 -0
  128. package/src/session/transcript.js +2 -1
  129. package/src/session/wake/resolve-target.js +7 -3
  130. package/src/skills/listener-runtime.cjs +92 -0
  131. package/src/skills/service.js +207 -0
  132. package/src/swarm/factory.js +5 -5
  133. package/src/swarm/pentest.js +9 -9
  134. package/src/swarm/report.js +4 -4
  135. package/src/swarm/runtime.js +15 -12
  136. package/src/swarm/scenario-dsl.js +7 -5
  137. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,519 @@
1
+ // Room files: list the files published in a session room, and download one of them.
2
+ //
3
+ // The API publishes an uploaded file when a message that references it is sent. That message
4
+ // event carries the file's pointer in payload.attachments (markdown and JSON uploads also emit a
5
+ // session_attachment event). There is no list endpoint yet, so listing reads the room's history
6
+ // newest first and collects those pointers.
7
+ //
8
+ // A download asks the API for the file's metadata and a short-lived signed link
9
+ // (GET /api/v1/sessions/{id}/attachments/{attachmentId}?download=true), fetches the link from
10
+ // the file store WITHOUT API credentials, streams it into a temporary file next to the
11
+ // destination while hashing it, checks the size and sha256 the API reported, and only then
12
+ // moves the temporary file into place, replacing an existing file only with force. An expired
13
+ // link is fetched again once.
14
+
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import fs from "node:fs";
17
+ import fsp from "node:fs/promises";
18
+ import path from "node:path";
19
+ import process from "node:process";
20
+ import { Readable, Transform } from "node:stream";
21
+ import { pipeline } from "node:stream/promises";
22
+
23
+ import { authLoginHint } from "../ui/command-hints.js";
24
+ import { fetchSessionAttachment, pollSessionEventsBefore } from "./sync.js";
25
+
26
+ const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{8,120}$/;
27
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
28
+ const FILE_EVENT_TYPES = new Set(["session_message", "session_attachment"]);
29
+ const LIST_PAGE_EVENTS = 200;
30
+ export const DEFAULT_ROOM_FILES_LIMIT = 50;
31
+ export const MAX_ROOM_FILES_LIMIT = 500;
32
+ export const DEFAULT_ROOM_FILES_MAX_PAGES = 10;
33
+ export const MAX_ROOM_FILES_MAX_PAGES = 50;
34
+ // Treat a link this close to its expiry as expired, and fetch a fresh one first.
35
+ const LINK_EXPIRY_SKEW_MS = 30_000;
36
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
37
+ const MAX_FILE_NAME_LENGTH = 255;
38
+ // Windows device names, with or without an extension, cannot name a regular file.
39
+ const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
40
+
41
+ export class RoomFileError extends Error {
42
+ constructor(code, message, details = {}) {
43
+ super(message);
44
+ this.name = "RoomFileError";
45
+ this.code = code;
46
+ this.details = details;
47
+ }
48
+ }
49
+
50
+ function normalizeString(value) {
51
+ return String(value ?? "").trim();
52
+ }
53
+
54
+ function clampInteger(value, fallback, max) {
55
+ const parsed = Number(value);
56
+ if (!Number.isFinite(parsed) || parsed < 1) return fallback;
57
+ return Math.min(max, Math.floor(parsed));
58
+ }
59
+
60
+ function eventSequence(event = {}) {
61
+ const value = Number(event?.sequenceId ?? event?.sequence_id);
62
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
63
+ }
64
+
65
+ function eventAuthorId(event = {}) {
66
+ const agent = event?.agent;
67
+ return normalizeString((agent && typeof agent === "object" ? agent.id : agent) || event?.agentId || event?.agent_id);
68
+ }
69
+
70
+ function attachmentPointersOf(event = {}) {
71
+ if (!FILE_EVENT_TYPES.has(normalizeString(event?.event || event?.type))) return [];
72
+ const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
73
+ const pointers = Array.isArray(payload.attachments) ? [...payload.attachments] : [];
74
+ if (payload.attachment && typeof payload.attachment === "object") pointers.unshift(payload.attachment);
75
+ return pointers.filter((pointer) => pointer && typeof pointer === "object" && !Array.isArray(pointer));
76
+ }
77
+
78
+ /**
79
+ * One published file, from an attachment pointer and the event that carried it; null when the
80
+ * pointer is not a usable file record.
81
+ */
82
+ export function roomFileFromPointer(pointer = {}, event = {}) {
83
+ const attachmentId = normalizeString(pointer.attachmentId || pointer.attachment_id || pointer.id);
84
+ const sizeBytes = Number(pointer.sizeBytes ?? pointer.size_bytes);
85
+ if (!ATTACHMENT_ID_PATTERN.test(attachmentId) || attachmentId.includes("..")) return null;
86
+ if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0) return null;
87
+ const sha256 = normalizeString(pointer.sha256).toLowerCase();
88
+ return {
89
+ attachmentId,
90
+ filename: normalizeString(pointer.filename || pointer.name),
91
+ sizeBytes,
92
+ contentType: normalizeString(pointer.contentType || pointer.content_type) || null,
93
+ kind: normalizeString(pointer.kind) || null,
94
+ sha256: SHA256_PATTERN.test(sha256) ? sha256 : null,
95
+ status: normalizeString(pointer.status) || null,
96
+ uploadedBy: normalizeString(pointer.uploadedBy || pointer.uploaded_by) || eventAuthorId(event) || null,
97
+ uploadedAt: normalizeString(pointer.uploadedAt || pointer.uploaded_at) || null,
98
+ publishedAt: normalizeString(event.ts || event.timestamp || event.createdAt) || null,
99
+ publishedBy: eventAuthorId(event) || null,
100
+ publishedSequenceId: eventSequence(event) || null,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The files published in a room, newest first, read from its message history.
106
+ *
107
+ * @returns {Promise<{ok: boolean, reason: string, files: Array<object>, count: number,
108
+ * complete: boolean, pages: number, scannedEvents: number, skippedPointers: number}>}
109
+ * complete is true when the whole history was read and every file found is listed; raise
110
+ * limit or maxPages to see more when it is false.
111
+ */
112
+ export async function listRoomFiles(
113
+ sessionId,
114
+ {
115
+ targetPath = process.cwd(),
116
+ limit = DEFAULT_ROOM_FILES_LIMIT,
117
+ maxPages = DEFAULT_ROOM_FILES_MAX_PAGES,
118
+ timeoutMs = 15_000,
119
+ pollSessionEventsBeforeFn = pollSessionEventsBefore,
120
+ } = {},
121
+ ) {
122
+ const wanted = clampInteger(limit, DEFAULT_ROOM_FILES_LIMIT, MAX_ROOM_FILES_LIMIT);
123
+ const pageBudget = clampInteger(maxPages, DEFAULT_ROOM_FILES_MAX_PAGES, MAX_ROOM_FILES_MAX_PAGES);
124
+ const files = [];
125
+ const seen = new Set();
126
+ let beforeSequence = null;
127
+ let pages = 0;
128
+ let scannedEvents = 0;
129
+ let skippedPointers = 0;
130
+ let complete = false;
131
+ let reason = "";
132
+
133
+ while (pages < pageBudget && files.length < wanted) {
134
+ const result = await pollSessionEventsBeforeFn(sessionId, {
135
+ targetPath,
136
+ beforeSequence,
137
+ limit: LIST_PAGE_EVENTS,
138
+ timeoutMs,
139
+ });
140
+ pages += 1;
141
+ if (!result?.ok) {
142
+ reason = normalizeString(result?.reason) || "history_read_failed";
143
+ if (pages === 1) {
144
+ return {
145
+ ok: false,
146
+ reason,
147
+ files: [],
148
+ count: 0,
149
+ complete: false,
150
+ pages,
151
+ scannedEvents,
152
+ skippedPointers,
153
+ };
154
+ }
155
+ break;
156
+ }
157
+ const events = Array.isArray(result.events) ? result.events : [];
158
+ scannedEvents += events.length;
159
+ const newestFirst = [...events].sort((left, right) => eventSequence(right) - eventSequence(left));
160
+ for (const event of newestFirst) {
161
+ for (const pointer of attachmentPointersOf(event)) {
162
+ const file = roomFileFromPointer(pointer, event);
163
+ if (!file) {
164
+ skippedPointers += 1;
165
+ continue;
166
+ }
167
+ if (seen.has(file.attachmentId)) continue;
168
+ seen.add(file.attachmentId);
169
+ files.push(file);
170
+ }
171
+ }
172
+ const next = Number(result.beforeSequence || 0);
173
+ if (!events.length || result.hasMore === false || !next || (beforeSequence && next >= beforeSequence)) {
174
+ complete = true;
175
+ break;
176
+ }
177
+ beforeSequence = next;
178
+ }
179
+
180
+ const listed = files.slice(0, wanted);
181
+ return {
182
+ ok: true,
183
+ reason,
184
+ files: listed,
185
+ count: listed.length,
186
+ complete: complete && listed.length === files.length,
187
+ pages,
188
+ scannedEvents,
189
+ skippedPointers,
190
+ };
191
+ }
192
+
193
+ function hasControlCharacter(text) {
194
+ for (const character of text) {
195
+ const code = character.codePointAt(0);
196
+ if (code < 0x20 || code === 0x7f) return true;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ /**
202
+ * The server-provided file name, when it is one plain file name; otherwise a RoomFileError.
203
+ * The API cleans names before it stores them, so a name that fails here is refused, not
204
+ * rewritten.
205
+ */
206
+ export function requirePlainFileName(name) {
207
+ const text = String(name ?? "");
208
+ const plain =
209
+ text.trim() !== "" &&
210
+ text === text.trim() &&
211
+ text !== "." &&
212
+ text !== ".." &&
213
+ text.length <= MAX_FILE_NAME_LENGTH &&
214
+ !/[\\/:*?"<>|]/.test(text) &&
215
+ !hasControlCharacter(text) &&
216
+ !text.endsWith(".") &&
217
+ !WINDOWS_DEVICE_NAME.test(text);
218
+ if (!plain) {
219
+ throw new RoomFileError(
220
+ "unsafe_file_name",
221
+ `The server-provided file name ${JSON.stringify(text)} is not a plain file name; choose where to save it with --out <path>.`,
222
+ );
223
+ }
224
+ return text;
225
+ }
226
+
227
+ function isInside(parent, child) {
228
+ const relative = path.relative(parent, child);
229
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
230
+ }
231
+
232
+ /**
233
+ * Where the file is written. Without `out`, the server's file name in the workspace. A relative
234
+ * `out` is resolved in the workspace and must stay inside it, through links too; an absolute
235
+ * `out` is allowed only when workspaceOnly is false (the CLI, where a person chose it). An
236
+ * `out` naming an existing directory, or ending with a separator, gets the server's file name.
237
+ */
238
+ export async function resolveRoomFileDestination({ targetPath = process.cwd(), out = "", filename = "", workspaceOnly = false } = {}) {
239
+ const workspace = path.resolve(targetPath);
240
+ const requested = normalizeString(out);
241
+ let destination;
242
+ if (!requested) {
243
+ destination = path.join(workspace, requirePlainFileName(filename));
244
+ } else {
245
+ if (path.isAbsolute(requested) && workspaceOnly) {
246
+ throw new RoomFileError("out_outside_workspace", `out must be a path inside the workspace ${workspace}, not an absolute path.`);
247
+ }
248
+ const resolved = path.resolve(workspace, requested);
249
+ if (!path.isAbsolute(requested) && !isInside(workspace, resolved)) {
250
+ throw new RoomFileError("out_outside_workspace", `--out ${requested} leaves the workspace ${workspace}.`);
251
+ }
252
+ const existing = await fsp.stat(resolved).catch(() => null);
253
+ const namesDirectory = Boolean(existing?.isDirectory()) || /[\\/]$/.test(requested);
254
+ destination = namesDirectory ? path.join(resolved, requirePlainFileName(filename)) : resolved;
255
+ }
256
+ // On Windows "name:stream" names a data stream of the file "name", not a file. A download to one
257
+ // fails only after it has left empty files next to it, so it is refused before anything is written.
258
+ if (process.platform === "win32" && path.basename(destination).includes(":")) {
259
+ throw new RoomFileError(
260
+ "unsafe_file_name",
261
+ `${JSON.stringify(path.basename(destination))} is not a plain file name: on Windows a ":" names a file stream. Choose another name with --out.`,
262
+ );
263
+ }
264
+
265
+ const directory = path.dirname(destination);
266
+ const realDirectory = await fsp.realpath(directory).catch(() => null);
267
+ if (!realDirectory) {
268
+ throw new RoomFileError("out_directory_missing", `The directory ${directory} does not exist.`);
269
+ }
270
+ if (workspaceOnly || !requested || !path.isAbsolute(requested)) {
271
+ const realWorkspace = await fsp.realpath(workspace);
272
+ if (!isInside(realWorkspace, realDirectory)) {
273
+ throw new RoomFileError("out_outside_workspace", `${directory} resolves outside the workspace ${workspace} through a link.`);
274
+ }
275
+ }
276
+ return path.join(realDirectory, path.basename(destination));
277
+ }
278
+
279
+ async function existingEntry(destination) {
280
+ const entry = await fsp.lstat(destination).catch((error) => {
281
+ if (error?.code === "ENOENT") return null;
282
+ throw error;
283
+ });
284
+ if (!entry) return null;
285
+ if (entry.isDirectory()) {
286
+ throw new RoomFileError("destination_is_directory", `${destination} is a directory; choose a file path with --out.`);
287
+ }
288
+ if (entry.isSymbolicLink()) {
289
+ throw new RoomFileError("destination_is_link", `${destination} is a link; refusing to write through it.`);
290
+ }
291
+ return entry;
292
+ }
293
+
294
+ function attachmentReadError(result, sessionId, attachmentId) {
295
+ const status = result?.status ?? null;
296
+ const details = { status, apiCode: result?.errorCode || null };
297
+ if (status === 404) {
298
+ return new RoomFileError(
299
+ "not_found",
300
+ `Attachment ${attachmentId} was not found in room ${sessionId}. A file is visible to the room once a sent message references it (until then only its uploader can fetch it), and a removed file is gone.`,
301
+ details,
302
+ );
303
+ }
304
+ if (status === 403) {
305
+ return new RoomFileError("forbidden", `You cannot read room ${sessionId}: the signed-in account is not a member.`, details);
306
+ }
307
+ if (status === 401) {
308
+ return new RoomFileError(
309
+ "unauthorized",
310
+ `The API refused this credential for room files. Downloading needs your user login (\`${authLoginHint()}\`); agent admission credentials cannot fetch download links.`,
311
+ details,
312
+ );
313
+ }
314
+ if (status === 409) {
315
+ return new RoomFileError("not_ready", `The upload of attachment ${attachmentId} is not complete yet; try again once its uploader finishes.`, details);
316
+ }
317
+ if (status === 429) {
318
+ const seconds = Math.max(1, Math.ceil(Number(result?.retryAfterMs || 0) / 1000));
319
+ return new RoomFileError("rate_limited", `Too many attachment reads; try again in ${seconds} s.`, { ...details, retryAfterMs: result?.retryAfterMs || null });
320
+ }
321
+ if (status === 503) {
322
+ return new RoomFileError("storage_unavailable", "The file store is unavailable right now; try again shortly.", details);
323
+ }
324
+ if (result?.reason === "not_authenticated" || result?.reason === "no_session") {
325
+ return new RoomFileError("not_authenticated", `Downloading room files requires authentication. Run \`${authLoginHint()}\` first.`, details);
326
+ }
327
+ if (result?.reason === "invalid_attachment_id" || result?.reason === "invalid_session_id") {
328
+ return new RoomFileError(result.reason, `The ${result.reason === "invalid_session_id" ? "room id" : "attachment id"} is not valid.`, details);
329
+ }
330
+ return new RoomFileError("api_error", `Reading attachment ${attachmentId} failed (${result?.reason || "unknown"}).`, details);
331
+ }
332
+
333
+ function validatedAttachment(attachment, attachmentId) {
334
+ const file = roomFileFromPointer(attachment || {}, {});
335
+ if (!file || file.attachmentId !== attachmentId) {
336
+ throw new RoomFileError("invalid_metadata", `The API answered with no usable metadata for attachment ${attachmentId}.`);
337
+ }
338
+ if (!file.sha256) {
339
+ throw new RoomFileError("invalid_metadata", `The API gave no sha256 for attachment ${attachmentId}, so the download cannot be verified.`);
340
+ }
341
+ return file;
342
+ }
343
+
344
+ function validatedLink(download, attachmentId) {
345
+ const url = normalizeString(download?.url);
346
+ let parsed = null;
347
+ try {
348
+ parsed = new URL(url);
349
+ } catch {
350
+ parsed = null;
351
+ }
352
+ const loopback = parsed && ["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname);
353
+ if (!parsed || !(parsed.protocol === "https:" || (parsed.protocol === "http:" && loopback))) {
354
+ throw new RoomFileError("invalid_link", `The API answered with no usable download link for attachment ${attachmentId}.`);
355
+ }
356
+ const expiresAtMs = Date.parse(normalizeString(download?.expiresAt));
357
+ return { url: parsed.toString(), expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : null };
358
+ }
359
+
360
+ // Counts and hashes the bytes as they stream, and stops a body longer than the API reported.
361
+ function verifyingCounter(expectedBytes, state) {
362
+ return new Transform({
363
+ transform(chunk, _encoding, done) {
364
+ state.bytes += chunk.length;
365
+ if (state.bytes > expectedBytes) {
366
+ done(new RoomFileError(
367
+ "size_mismatch",
368
+ `The download is larger than the ${expectedBytes} bytes the API reported; it was not saved.`,
369
+ ));
370
+ return;
371
+ }
372
+ state.hash.update(chunk);
373
+ done(null, chunk);
374
+ },
375
+ });
376
+ }
377
+
378
+ function destinationAppeared(destination) {
379
+ return new RoomFileError("destination_exists", `${destination} appeared during the download; pass --force to overwrite it.`);
380
+ }
381
+
382
+ // Puts the verified temporary file at destination unless something is already there. rename()
383
+ // would replace a file created after the last existence check, so this hard-links instead:
384
+ // link() fails with EEXIST rather than replacing. A filesystem without hard links refuses link()
385
+ // with an error that varies by platform, so any other link() error falls back to copying the
386
+ // bytes into a newly created file (COPYFILE_EXCL), which never replaces either. The caller
387
+ // removes the temporary file.
388
+ async function publishWithoutReplacing(temporary, destination) {
389
+ try {
390
+ await fsp.link(temporary, destination);
391
+ return;
392
+ } catch (error) {
393
+ if (error?.code === "EEXIST") throw destinationAppeared(destination);
394
+ }
395
+ try {
396
+ await fsp.copyFile(temporary, destination, fs.constants.COPYFILE_EXCL);
397
+ } catch (error) {
398
+ throw error?.code === "EEXIST" ? destinationAppeared(destination) : error;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Download one published room file to disk, verified and atomic.
404
+ *
405
+ * @returns {Promise<{ok: true, path: string, bytes: number, sha256: string, verified: true,
406
+ * overwritten: boolean, linkRefetched: boolean, attachment: object}>}
407
+ * @throws {RoomFileError} with a code: not_found, forbidden, unauthorized, not_ready,
408
+ * rate_limited, storage_unavailable, not_authenticated, invalid_*, unsafe_file_name,
409
+ * out_outside_workspace, out_directory_missing, destination_exists, destination_is_*,
410
+ * link_expired, download_failed, size_mismatch, checksum_mismatch.
411
+ */
412
+ export async function downloadRoomFile(
413
+ sessionId,
414
+ attachmentId,
415
+ {
416
+ targetPath = process.cwd(),
417
+ out = "",
418
+ force = false,
419
+ workspaceOnly = false,
420
+ timeoutMs = 15_000,
421
+ downloadTimeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS,
422
+ fetchAttachmentFn = fetchSessionAttachment,
423
+ fetchImpl = (url, init) => fetch(url, init),
424
+ nowMs = Date.now,
425
+ } = {},
426
+ ) {
427
+ const sid = normalizeString(sessionId);
428
+ const aid = normalizeString(attachmentId);
429
+ if (!ATTACHMENT_ID_PATTERN.test(aid) || aid.includes("..")) {
430
+ throw new RoomFileError("invalid_attachment_id", `${JSON.stringify(aid)} is not an attachment id.`);
431
+ }
432
+
433
+ const requestLink = async () => {
434
+ const result = await fetchAttachmentFn(sid, aid, { targetPath, timeoutMs });
435
+ if (!result?.ok) throw attachmentReadError(result, sid, aid);
436
+ return {
437
+ attachment: validatedAttachment(result.attachment, aid),
438
+ link: validatedLink(result.download, aid),
439
+ };
440
+ };
441
+
442
+ let { attachment, link } = await requestLink();
443
+ const destination = await resolveRoomFileDestination({ targetPath, out, filename: attachment.filename, workspaceOnly });
444
+ const existedBefore = Boolean(await existingEntry(destination));
445
+ if (existedBefore && !force) {
446
+ throw new RoomFileError("destination_exists", `${destination} already exists; pass --force to overwrite it.`);
447
+ }
448
+
449
+ let linkRefetched = false;
450
+ const refetchLink = async () => {
451
+ linkRefetched = true;
452
+ const fresh = await requestLink();
453
+ if (fresh.attachment.sha256 !== attachment.sha256 || fresh.attachment.sizeBytes !== attachment.sizeBytes) {
454
+ throw new RoomFileError("download_failed", `Attachment ${aid} changed while it was being downloaded; try again.`);
455
+ }
456
+ link = fresh.link;
457
+ };
458
+ if (link.expiresAtMs !== null && link.expiresAtMs - LINK_EXPIRY_SKEW_MS <= (Number(nowMs()) || Date.now())) {
459
+ await refetchLink();
460
+ }
461
+
462
+ const signal = AbortSignal.timeout(downloadTimeoutMs);
463
+ // The signed link carries its own authorization: no API credential or header is sent with it.
464
+ let response = await fetchImpl(link.url, { method: "GET", redirect: "error", signal });
465
+ if ((response?.status === 401 || response?.status === 403) && !linkRefetched) {
466
+ await response.body?.cancel?.().catch?.(() => {});
467
+ await refetchLink();
468
+ response = await fetchImpl(link.url, { method: "GET", redirect: "error", signal });
469
+ }
470
+ if (!response?.ok) {
471
+ const status = response?.status ?? "no response";
472
+ await response?.body?.cancel?.().catch?.(() => {});
473
+ if (status === 401 || status === 403) {
474
+ throw new RoomFileError(
475
+ "link_expired",
476
+ `The file store refused the download link (HTTP ${status}) even after a fresh link was fetched; try again.`,
477
+ { status },
478
+ );
479
+ }
480
+ throw new RoomFileError("download_failed", `The file store answered HTTP ${status}.`, { status });
481
+ }
482
+
483
+ const directory = path.dirname(destination);
484
+ const temporary = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.part`);
485
+ const state = { bytes: 0, hash: createHash("sha256") };
486
+ let renamed = false;
487
+ try {
488
+ const source = response.body ? Readable.fromWeb(response.body) : Readable.from([]);
489
+ await pipeline(source, verifyingCounter(attachment.sizeBytes, state), fs.createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
490
+ const sha256 = state.hash.digest("hex");
491
+ if (state.bytes !== attachment.sizeBytes || sha256 !== attachment.sha256) {
492
+ throw new RoomFileError(
493
+ "checksum_mismatch",
494
+ `The download does not match the server's checksum (expected ${attachment.sizeBytes} bytes, sha256 ${attachment.sha256}; got ${state.bytes} bytes, sha256 ${sha256}). Nothing was saved.`,
495
+ { expectedSha256: attachment.sha256, actualSha256: sha256, expectedBytes: attachment.sizeBytes, actualBytes: state.bytes },
496
+ );
497
+ }
498
+ if (force) {
499
+ await fsp.rename(temporary, destination);
500
+ renamed = true;
501
+ } else {
502
+ if (await existingEntry(destination)) throw destinationAppeared(destination);
503
+ await publishWithoutReplacing(temporary, destination);
504
+ }
505
+ return {
506
+ ok: true,
507
+ path: destination,
508
+ bytes: state.bytes,
509
+ sha256,
510
+ verified: true,
511
+ overwritten: existedBefore,
512
+ linkRefetched,
513
+ attachment,
514
+ };
515
+ } finally {
516
+ // A renamed temporary file is gone; after a link or copy, or a failure, its name is removed here.
517
+ if (!renamed) await fsp.rm(temporary, { force: true }).catch(() => {});
518
+ }
519
+ }
@@ -7,12 +7,16 @@
7
7
  // assertDispatchMatchesScope in src/session/admission-auth.js):
8
8
  //
9
9
  // legacy-data-plane a compatibility list for rooms that don't require admission:
10
- // posting, replying, reading/polling, message actions and reactions,
11
- // attention requests. Runs as before; the server's admission mode
12
- // decides whether a room accepts it.
13
- // control leases, locks, guards, tickets, checkpoints, joining and leaving,
14
- // listeners, room setup and everything else that is not plain
15
- // data-plane traffic. Refused unless the session's admission binds it.
10
+ // posting, replying, reading/polling, reading room files, message
11
+ // actions and reactions, attention requests. Runs as before; the
12
+ // server's admission mode decides whether a room accepts it.
13
+ // self-provisioning joining and leaving: commands an agent runs before it has, or
14
+ // after it loses, an admission. The CLI bridge requires an explicit
15
+ // target matching its trusted caller id; every other route that
16
+ // needs an admission then uses it.
17
+ // control leases, locks, guards, tickets, checkpoints, listeners, room setup
18
+ // and everything else that is not plain data-plane traffic. Refused
19
+ // unless the session's admission binds it.
16
20
  // owner room owner actions. Refused in an agent context.
17
21
  // exempt-local sends no request (a local store or built-in constants only).
18
22
  //
@@ -25,10 +29,13 @@ export const SESSION_ROUTE_CLASSES = Object.freeze({
25
29
  "mcp:poll_inbox": "legacy-data-plane",
26
30
  "mcp:read_history": "legacy-data-plane",
27
31
  "mcp:send_message": "legacy-data-plane",
32
+ "mcp:judge_verdict": "legacy-data-plane",
28
33
  "mcp:attention_request": "legacy-data-plane",
29
34
  "mcp:session_action": "legacy-data-plane",
30
35
  "mcp:session_react": "legacy-data-plane",
31
36
  "mcp:session_reply": "legacy-data-plane",
37
+ "mcp:session_files_list": "legacy-data-plane",
38
+ "mcp:session_file_get": "legacy-data-plane",
32
39
  "mcp:session_lock": "control",
33
40
  "mcp:session_unlock": "control",
34
41
  "mcp:session_locks": "control",
@@ -47,6 +54,7 @@ export const SESSION_ROUTE_CLASSES = Object.freeze({
47
54
  "cli:session read": "legacy-data-plane",
48
55
  "cli:session history": "legacy-data-plane",
49
56
  "cli:session search": "legacy-data-plane",
57
+ "cli:session events": "legacy-data-plane",
50
58
  "cli:session pins": "legacy-data-plane",
51
59
  "cli:session lock": "control",
52
60
  "cli:session unlock": "control",
@@ -63,11 +71,12 @@ export const SESSION_ROUTE_CLASSES = Object.freeze({
63
71
  "cli:session ticket claim": "control",
64
72
  "cli:session ticket events": "control",
65
73
  "cli:session ticket list": "control",
74
+ "cli:session standup": "control",
66
75
  "cli:session ticket release": "control",
67
76
  "cli:session ticket renew": "control",
68
77
  "cli:session ticket submit": "control",
69
- "cli:session join": "control",
70
- "cli:session leave": "control",
78
+ "cli:session join": "self-provisioning",
79
+ "cli:session leave": "self-provisioning",
71
80
  "cli:session stop-listener": "control",
72
81
  "cli:session listeners": "control",
73
82
  "cli:session set-title": "control",
@@ -102,6 +111,11 @@ export const LEGACY_DATA_PLANE_TOOLS = Object.freeze(
102
111
  Object.keys(SESSION_ROUTE_CLASSES).filter((route) => SESSION_ROUTE_CLASSES[route] === "legacy-data-plane"),
103
112
  );
104
113
 
114
+ /** Routes an agent may use to establish or tear down its own admission. */
115
+ export const SELF_PROVISIONING_TOOLS = Object.freeze(
116
+ Object.keys(SESSION_ROUTE_CLASSES).filter((route) => SESSION_ROUTE_CLASSES[route] === "self-provisioning"),
117
+ );
118
+
105
119
  export function sessionRouteClass(route) {
106
120
  return SESSION_ROUTE_CLASSES[route] || "unclassified";
107
121
  }
@@ -27,6 +27,10 @@ function buildSessionCoordinationSection() {
27
27
  return `${SECTION_START_MARKER}
28
28
  ${SECTION_HEADING}
29
29
 
30
+ ### Agent Skills
31
+ Install the bundled Senti identity, notification, CLI, judge and Omar Gate setup guides with \`sl skills install\`.
32
+ Use \`sl skills list\` to inspect installed versions and hashes.
33
+
30
34
  ### Required Etiquette
31
35
  ${renderCoordinationBulletList()}
32
36