@aexol/spectral 0.9.245 → 0.9.247

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 (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/commands/secret.d.ts +16 -0
  4. package/dist/commands/secret.d.ts.map +1 -0
  5. package/dist/commands/secret.js +153 -0
  6. package/dist/commands/serve.d.ts.map +1 -1
  7. package/dist/commands/serve.js +21 -0
  8. package/dist/extensions/list-secrets.d.ts +14 -0
  9. package/dist/extensions/list-secrets.d.ts.map +1 -0
  10. package/dist/extensions/list-secrets.js +63 -0
  11. package/dist/generated/zeus/const.d.ts.map +1 -1
  12. package/dist/generated/zeus/const.js +16 -0
  13. package/dist/generated/zeus/index.d.ts +73 -0
  14. package/dist/generated/zeus/index.d.ts.map +1 -1
  15. package/dist/relay/routing.d.ts +1 -1
  16. package/dist/relay/routing.d.ts.map +1 -1
  17. package/dist/relay/routing.js +93 -17
  18. package/dist/sdk/coding-agent/utils/shell.d.ts.map +1 -1
  19. package/dist/sdk/coding-agent/utils/shell.js +15 -1
  20. package/dist/secrets/index.d.ts +11 -0
  21. package/dist/secrets/index.d.ts.map +1 -0
  22. package/dist/secrets/index.js +10 -0
  23. package/dist/secrets/store.d.ts +109 -0
  24. package/dist/secrets/store.d.ts.map +1 -0
  25. package/dist/secrets/store.js +415 -0
  26. package/dist/server/agent-bridge.d.ts.map +1 -1
  27. package/dist/server/agent-bridge.js +20 -2
  28. package/dist/server/attachments/cleanup.d.ts +42 -0
  29. package/dist/server/attachments/cleanup.d.ts.map +1 -0
  30. package/dist/server/attachments/cleanup.js +100 -0
  31. package/dist/server/attachments/persist.d.ts +131 -0
  32. package/dist/server/attachments/persist.d.ts.map +1 -0
  33. package/dist/server/attachments/persist.js +406 -0
  34. package/dist/server/handlers/secrets.d.ts +40 -0
  35. package/dist/server/handlers/secrets.d.ts.map +1 -0
  36. package/dist/server/handlers/secrets.js +78 -0
  37. package/dist/server/session-stream.d.ts +5 -0
  38. package/dist/server/session-stream.d.ts.map +1 -1
  39. package/dist/server/session-stream.js +51 -1
  40. package/dist/server/storage.d.ts +6 -0
  41. package/dist/server/storage.d.ts.map +1 -1
  42. package/dist/server/storage.js +9 -0
  43. package/package.json +1 -1
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Attachment persistence (Option A) for Studio /agent image attachments.
3
+ *
4
+ * User-attached images arrive as relay `ImageAttachment`s — either inline
5
+ * base64 (`data`, legacy/small) or a backend-hosted url (`url`, optionally
6
+ * `imageId`). LLM providers see those natively, but agent TOOLS do not: a
7
+ * tool such as `image_generate` (reference images) or `read` can only use
8
+ * normal file paths. This module writes every attachment to
9
+ *
10
+ * <cwd>/.spectral/attachments/<sanitized sessionId>/<name>.<ext>
11
+ *
12
+ * so the prompt can be annotated with the saved absolute paths (see
13
+ * `formatAttachmentAnnotation`) and the agent can use the images as files.
14
+ *
15
+ * Guarantees:
16
+ * - NEVER throws and never rejects: every failure (fetch error, fs error,
17
+ * base64 decode) is reported through `onWarn` and the prompt proceeds.
18
+ * - Idempotent: the same imageId (or content hash) always maps to the same
19
+ * path, so re-sending an attachment overwrites the same file.
20
+ * - Sandboxed: every path segment is stripped to `[A-Za-z0-9_-]`, so a
21
+ * malicious sessionId/imageId/url can never traverse (`..` contains
22
+ * only dots → stripped → falls back to a safe name).
23
+ * - Capped: per-session file count and total size (both named consts).
24
+ *
25
+ * The stored session history is NEVER rewritten: callers keep persisting the
26
+ * original content to SQLite and only annotate the text handed to the agent.
27
+ *
28
+ * @module @spectral/server/attachments/persist
29
+ */
30
+ import type { ImageAttachment } from "../wire.js";
31
+ /** Max persisted files per session directory — beyond this, persistence is skipped. */
32
+ export declare const MAX_FILES_PER_SESSION = 50;
33
+ /** Max total bytes per session directory (200 MB) — beyond this, persistence is skipped. */
34
+ export declare const MAX_TOTAL_ATTACHMENT_BYTES: number;
35
+ /** Wall-clock budget for one `persistAttachments()` call (url fetches dominate). */
36
+ export declare const PERSIST_DEADLINE_MS = 30000;
37
+ /** Per-request timeout for fetching a hosted/foreign image. */
38
+ export declare const FETCH_ATTACHMENT_TIMEOUT_MS = 10000;
39
+ /**
40
+ * Strip a path segment to `[A-Za-z0-9_-]` (length-capped). `..`, `/`, `\`,
41
+ * percent-encodings and unicode are all removed, so traversal is impossible:
42
+ * a fully-stripped segment collapses to `fallback` instead of becoming `""`.
43
+ */
44
+ export declare function sanitizeSegment(input: string, fallback: string): string;
45
+ export declare function attachmentExtension(mimeType: string): string | null;
46
+ /**
47
+ * Sanitized file stem for an attachment, chosen deterministically so the
48
+ * same attachment always maps to the same path (idempotency):
49
+ * 1. sanitized `imageId`,
50
+ * 2. hosted image id extracted from `url`,
51
+ * 3. sha256 of `url` (foreign urls),
52
+ * 4. sha256 of the inline `data` payload.
53
+ * `null` when the attachment has none of the above.
54
+ */
55
+ export declare function attachmentFileStem(image: ImageAttachment): string | null;
56
+ /** `stem.ext` for an attachment, or `null` when the mime/stem is unusable. */
57
+ export declare function attachmentFileName(image: ImageAttachment): string | null;
58
+ /** `<cwd>/.spectral/attachments` — the sweep root. Always absolute. */
59
+ export declare function attachmentsRootFor(cwd: string): string;
60
+ /** Per-session directory under the attachments root. Always absolute. */
61
+ export declare function attachmentsSessionDirFor(cwd: string, sessionId: string): string;
62
+ /** Absolute path an attachment would be persisted to, or `null` when unusable. */
63
+ export declare function attachmentPathFor(cwd: string, sessionId: string, image: ImageAttachment): string | null;
64
+ /**
65
+ * The persisted paths that still exist on disk for a message's attachments.
66
+ * Used by history rehydration: the annotation is only appended for files
67
+ * that are actually readable — an attachment persisted days ago may have
68
+ * been swept by `sweepAttachments()` in the meantime.
69
+ */
70
+ export declare function existingAttachmentPaths(cwd: string, sessionId: string, images?: ImageAttachment[]): string[];
71
+ /**
72
+ * Prompt annotation for persisted attachments, e.g.
73
+ * `"\n\n[Attached images saved at: /abs/a.png, /abs/b.jpg]"`.
74
+ * Empty string when no path survived (nothing persisted / none exist).
75
+ */
76
+ export declare function formatAttachmentAnnotation(paths: readonly string[]): string;
77
+ /**
78
+ * Rehydration twin of the prompt annotation: `content` plus the saved-at
79
+ * annotation for attachments that still exist on disk. Used by
80
+ * `agent-bridge.ts` when rebuilding prior user messages from
81
+ * `messages.images_json`, so the LLM context matches what the original
82
+ * prompt carried. Content is returned unchanged when nothing exists.
83
+ */
84
+ export declare function annotatedUserMessageText(options: {
85
+ cwd: string;
86
+ sessionId?: string;
87
+ content: string;
88
+ images?: ImageAttachment[];
89
+ }): string;
90
+ /** One successfully persisted attachment. */
91
+ export interface PersistedAttachment {
92
+ path: string;
93
+ bytes: number;
94
+ }
95
+ export interface PersistAttachmentsInput {
96
+ /** Sanitized into the directory segment — never used raw. */
97
+ sessionId: string;
98
+ /** Project cwd; attachments land under `<cwd>/.spectral/attachments/`. */
99
+ cwd: string;
100
+ images?: ImageAttachment[];
101
+ /**
102
+ * Backend routing override. `serve.ts` already resolved both (the same
103
+ * credentials AgentBridge uses for inference), so the server passes them
104
+ * in instead of re-reading config + machine store. Falls back to
105
+ * `resolveBackendRouting()` when omitted.
106
+ */
107
+ backendUrl?: string;
108
+ machineJwt?: string;
109
+ /** Injectable fetch for tests / alternative runtimes. */
110
+ fetchImpl?: typeof fetch;
111
+ /** Diagnostics sink — failures warn and continue. */
112
+ onWarn?: (message: string) => void;
113
+ /** Deadline override (tests). Defaults to `PERSIST_DEADLINE_MS`. */
114
+ deadlineMs?: number;
115
+ }
116
+ /**
117
+ * Persist every usable attachment of a user message to disk and return the
118
+ * saved absolute paths (annotation input). NEVER throws and never rejects:
119
+ * every per-image failure warns and continues, and the prompt must always
120
+ * proceed — a missing file is a degradation, not an error.
121
+ *
122
+ * Per-image flow:
123
+ * - `{data}` → base64-decoded and written directly,
124
+ * - `{url}` → bytes fetched through the hosted resolver machinery.
125
+ *
126
+ * Skips (with a warning) when: the mime type is outside the allowlist, the
127
+ * per-session file cap / total size cap / wall-clock deadline is reached, or
128
+ * the fetch/write fails.
129
+ */
130
+ export declare function persistAttachments(input: PersistAttachmentsInput): Promise<PersistedAttachment[]>;
131
+ //# sourceMappingURL=persist.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persist.d.ts","sourceRoot":"","sources":["../../../src/server/attachments/persist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAaH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AASlD,uFAAuF;AACvF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,4FAA4F;AAC5F,eAAO,MAAM,0BAA0B,QAAoB,CAAC;AAE5D,oFAAoF;AACpF,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAE1C,+DAA+D;AAC/D,eAAO,MAAM,2BAA2B,QAAS,CAAC;AAiBlD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGvE;AAeD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAInE;AAMD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAYxE;AAED,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAMxE;AAED,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,yEAAyE;AACzE,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,GACrB,MAAM,GAAG,IAAI,CAIf;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,eAAe,EAAE,GACzB,MAAM,EAAE,CAaV;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAM3E;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,eAAe,EAAE,CAAC;CAC5B,GAAG,MAAM,CAGT;AAED,6CAA6C;AAC7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,uBAAuB;IACtC,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,eAAe,EAAE,CAAC;IAC3B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,qDAAqD;IACrD,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4JD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,mBAAmB,EAAE,CAAC,CA8EhC"}
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Attachment persistence (Option A) for Studio /agent image attachments.
3
+ *
4
+ * User-attached images arrive as relay `ImageAttachment`s — either inline
5
+ * base64 (`data`, legacy/small) or a backend-hosted url (`url`, optionally
6
+ * `imageId`). LLM providers see those natively, but agent TOOLS do not: a
7
+ * tool such as `image_generate` (reference images) or `read` can only use
8
+ * normal file paths. This module writes every attachment to
9
+ *
10
+ * <cwd>/.spectral/attachments/<sanitized sessionId>/<name>.<ext>
11
+ *
12
+ * so the prompt can be annotated with the saved absolute paths (see
13
+ * `formatAttachmentAnnotation`) and the agent can use the images as files.
14
+ *
15
+ * Guarantees:
16
+ * - NEVER throws and never rejects: every failure (fetch error, fs error,
17
+ * base64 decode) is reported through `onWarn` and the prompt proceeds.
18
+ * - Idempotent: the same imageId (or content hash) always maps to the same
19
+ * path, so re-sending an attachment overwrites the same file.
20
+ * - Sandboxed: every path segment is stripped to `[A-Za-z0-9_-]`, so a
21
+ * malicious sessionId/imageId/url can never traverse (`..` contains
22
+ * only dots → stripped → falls back to a safe name).
23
+ * - Capped: per-session file count and total size (both named consts).
24
+ *
25
+ * The stored session history is NEVER rewritten: callers keep persisting the
26
+ * original content to SQLite and only annotate the text handed to the agent.
27
+ *
28
+ * @module @spectral/server/attachments/persist
29
+ */
30
+ import { createHash } from "node:crypto";
31
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, } from "node:fs";
32
+ import { join, resolve } from "node:path";
33
+ import { extractHostedImageId, isHostedImageUrl, resolveHostedImageUrl, } from "../../backend/generated-images.js";
34
+ import { resolveBackendRouting } from "../../backend/machine-routing.js";
35
+ /** Max persisted files per session directory — beyond this, persistence is skipped. */
36
+ export const MAX_FILES_PER_SESSION = 50;
37
+ /** Max total bytes per session directory (200 MB) — beyond this, persistence is skipped. */
38
+ export const MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024;
39
+ /** Wall-clock budget for one `persistAttachments()` call (url fetches dominate). */
40
+ export const PERSIST_DEADLINE_MS = 30_000;
41
+ /** Per-request timeout for fetching a hosted/foreign image. */
42
+ export const FETCH_ATTACHMENT_TIMEOUT_MS = 10_000;
43
+ /** Longest sanitized segment. Mirrors the backend image-id cap of 64 chars. */
44
+ const MAX_SEGMENT_LENGTH = 64;
45
+ /** Fallback segment when sanitization strips everything. */
46
+ const FALLBACK_SESSION_SEGMENT = "session";
47
+ /** Directory name under the project cwd. */
48
+ const ATTACHMENTS_DIR = join(".spectral", "attachments");
49
+ /**
50
+ * Characters allowed in a path segment. Anything else — dots (so `..` can
51
+ * never survive), slashes, backslashes, unicode, spaces — is stripped.
52
+ */
53
+ const UNSAFE_SEGMENT_CHARS = /[^A-Za-z0-9_-]/g;
54
+ /**
55
+ * Strip a path segment to `[A-Za-z0-9_-]` (length-capped). `..`, `/`, `\`,
56
+ * percent-encodings and unicode are all removed, so traversal is impossible:
57
+ * a fully-stripped segment collapses to `fallback` instead of becoming `""`.
58
+ */
59
+ export function sanitizeSegment(input, fallback) {
60
+ const cleaned = input.replace(UNSAFE_SEGMENT_CHARS, "").slice(0, MAX_SEGMENT_LENGTH);
61
+ return cleaned.length > 0 ? cleaned : fallback;
62
+ }
63
+ /**
64
+ * File extension for an attachment, from an allowlist of image mime types:
65
+ * png/jpg/jpeg/webp/gif ONLY. `null` for anything else (svg, pdf, ...) —
66
+ * such attachments are never persisted.
67
+ */
68
+ const EXTENSION_BY_MIME = {
69
+ "image/png": "png",
70
+ "image/jpg": "jpg",
71
+ "image/jpeg": "jpg",
72
+ "image/webp": "webp",
73
+ "image/gif": "gif",
74
+ };
75
+ export function attachmentExtension(mimeType) {
76
+ if (typeof mimeType !== "string")
77
+ return null;
78
+ const base = mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
79
+ return EXTENSION_BY_MIME[base] ?? null;
80
+ }
81
+ function sha256Hex(value) {
82
+ return createHash("sha256").update(value).digest("hex");
83
+ }
84
+ /**
85
+ * Sanitized file stem for an attachment, chosen deterministically so the
86
+ * same attachment always maps to the same path (idempotency):
87
+ * 1. sanitized `imageId`,
88
+ * 2. hosted image id extracted from `url`,
89
+ * 3. sha256 of `url` (foreign urls),
90
+ * 4. sha256 of the inline `data` payload.
91
+ * `null` when the attachment has none of the above.
92
+ */
93
+ export function attachmentFileStem(image) {
94
+ const imageId = typeof image.imageId === "string" ? image.imageId.trim() : "";
95
+ if (imageId)
96
+ return sanitizeSegment(imageId, "");
97
+ const url = typeof image.url === "string" ? image.url.trim() : "";
98
+ if (url) {
99
+ const hostedId = extractHostedImageId(url);
100
+ if (hostedId)
101
+ return sanitizeSegment(hostedId, "");
102
+ return sanitizeSegment(sha256Hex(url), "");
103
+ }
104
+ const data = typeof image.data === "string" ? image.data : "";
105
+ if (data)
106
+ return sanitizeSegment(sha256Hex(data), "");
107
+ return null;
108
+ }
109
+ /** `stem.ext` for an attachment, or `null` when the mime/stem is unusable. */
110
+ export function attachmentFileName(image) {
111
+ const ext = attachmentExtension(image.mimeType);
112
+ if (!ext)
113
+ return null;
114
+ const stem = attachmentFileStem(image);
115
+ if (!stem)
116
+ return null;
117
+ return `${stem}.${ext}`;
118
+ }
119
+ /** `<cwd>/.spectral/attachments` — the sweep root. Always absolute. */
120
+ export function attachmentsRootFor(cwd) {
121
+ return join(resolve(cwd), ATTACHMENTS_DIR);
122
+ }
123
+ /** Per-session directory under the attachments root. Always absolute. */
124
+ export function attachmentsSessionDirFor(cwd, sessionId) {
125
+ return join(attachmentsRootFor(cwd), sanitizeSegment(sessionId, FALLBACK_SESSION_SEGMENT));
126
+ }
127
+ /** Absolute path an attachment would be persisted to, or `null` when unusable. */
128
+ export function attachmentPathFor(cwd, sessionId, image) {
129
+ const name = attachmentFileName(image);
130
+ if (!name)
131
+ return null;
132
+ return join(attachmentsSessionDirFor(cwd, sessionId), name);
133
+ }
134
+ /**
135
+ * The persisted paths that still exist on disk for a message's attachments.
136
+ * Used by history rehydration: the annotation is only appended for files
137
+ * that are actually readable — an attachment persisted days ago may have
138
+ * been swept by `sweepAttachments()` in the meantime.
139
+ */
140
+ export function existingAttachmentPaths(cwd, sessionId, images) {
141
+ if (!sessionId || !images || images.length === 0)
142
+ return [];
143
+ const out = [];
144
+ for (const image of images) {
145
+ const path = attachmentPathFor(cwd, sessionId, image);
146
+ if (!path)
147
+ continue;
148
+ try {
149
+ if (existsSync(path) && statSync(path).isFile())
150
+ out.push(path);
151
+ }
152
+ catch {
153
+ // Unreadable → treat as missing.
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+ /**
159
+ * Prompt annotation for persisted attachments, e.g.
160
+ * `"\n\n[Attached images saved at: /abs/a.png, /abs/b.jpg]"`.
161
+ * Empty string when no path survived (nothing persisted / none exist).
162
+ */
163
+ export function formatAttachmentAnnotation(paths) {
164
+ const unique = [
165
+ ...new Set(paths.filter((p) => typeof p === "string" && p.length > 0)),
166
+ ];
167
+ if (unique.length === 0)
168
+ return "";
169
+ return `\n\n[Attached images saved at: ${unique.join(", ")}]`;
170
+ }
171
+ /**
172
+ * Rehydration twin of the prompt annotation: `content` plus the saved-at
173
+ * annotation for attachments that still exist on disk. Used by
174
+ * `agent-bridge.ts` when rebuilding prior user messages from
175
+ * `messages.images_json`, so the LLM context matches what the original
176
+ * prompt carried. Content is returned unchanged when nothing exists.
177
+ */
178
+ export function annotatedUserMessageText(options) {
179
+ const saved = existingAttachmentPaths(options.cwd, options.sessionId ?? "", options.images);
180
+ return options.content + formatAttachmentAnnotation(saved);
181
+ }
182
+ /**
183
+ * Standard base64 alphabet (padding validated separately). Node's
184
+ * `Buffer.from(..., "base64")` silently IGNORES invalid characters, so a
185
+ * corrupt payload would decode into garbage bytes without this check.
186
+ */
187
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
188
+ /**
189
+ * Decode a base64 payload (tolerating a legacy `data:` URI prefix).
190
+ * Strict: payloads carrying characters outside the base64 alphabet are
191
+ * rejected instead of silently decoded into garbage.
192
+ */
193
+ function decodeBase64(value) {
194
+ const comma = value.indexOf(",");
195
+ const payload = value.startsWith("data:") && comma !== -1 ? value.slice(comma + 1) : value;
196
+ const compact = payload.replace(/\s+/g, "");
197
+ if (!compact || !BASE64_PATTERN.test(compact))
198
+ return null;
199
+ try {
200
+ const decoded = new Uint8Array(Buffer.from(compact, "base64"));
201
+ return decoded.byteLength > 0 ? decoded : null;
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ async function tryFetchBytes(url, headers, options) {
208
+ const fetchImpl = options.fetchImpl ?? ((...args) => fetch(...args));
209
+ const controller = new AbortController();
210
+ const timer = setTimeout(() => controller.abort(), FETCH_ATTACHMENT_TIMEOUT_MS);
211
+ try {
212
+ const response = await fetchImpl(url, { signal: controller.signal, headers });
213
+ if (!response.ok) {
214
+ options.onWarn?.(`Attachment fetch failed for ${url}: HTTP ${response.status}`);
215
+ return null;
216
+ }
217
+ const buffer = await response.arrayBuffer();
218
+ if (buffer.byteLength === 0) {
219
+ options.onWarn?.(`Attachment fetch for ${url} returned an empty body`);
220
+ return null;
221
+ }
222
+ return new Uint8Array(buffer);
223
+ }
224
+ catch (error) {
225
+ const message = error instanceof Error ? error.message : String(error);
226
+ options.onWarn?.(`Attachment fetch failed for ${url}: ${message}`);
227
+ return null;
228
+ }
229
+ finally {
230
+ clearTimeout(timer);
231
+ }
232
+ }
233
+ /**
234
+ * Fetch the bytes behind an attachment's url.
235
+ *
236
+ * Backend-hosted urls are fetched through the presigned-url machinery
237
+ * (`resolveHostedImageUrl` — machine-JWT-authed `GET /generated-images/<id>/url`,
238
+ * cached in `generated-images.ts`), with a fallback to an authenticated
239
+ * direct download from the backend itself. Foreign urls are fetched
240
+ * anonymously — the machine JWT is NEVER attached to a non-backend origin
241
+ * (`isHostedImageUrl` is the gate). Returns `null` on any failure.
242
+ */
243
+ async function fetchAttachmentBytes(image, routing, options) {
244
+ const url = typeof image.url === "string" ? image.url.trim() : "";
245
+ if (!url)
246
+ return null;
247
+ const hosted = routing !== null && isHostedImageUrl(url, routing.backendUrl);
248
+ if (routing && hosted) {
249
+ const imageId = typeof image.imageId === "string" && image.imageId.trim() ? image.imageId.trim() : undefined;
250
+ const presigned = await resolveHostedImageUrl({
251
+ backendUrl: routing.backendUrl,
252
+ token: routing.machineJwt,
253
+ ...(imageId ? { imageId } : {}),
254
+ url,
255
+ fetchImpl: options.fetchImpl,
256
+ timeoutMs: FETCH_ATTACHMENT_TIMEOUT_MS,
257
+ onWarn: options.onWarn,
258
+ });
259
+ if (presigned) {
260
+ const bytes = await tryFetchBytes(presigned.url, undefined, options);
261
+ if (bytes)
262
+ return bytes;
263
+ }
264
+ // Fallback: authenticated direct download. The endpoint 302-redirects to
265
+ // a presigned S3 GET; fetch strips the Authorization header on
266
+ // cross-origin redirects, so the JWT never reaches storage.
267
+ const downloadUrl = url.startsWith("/")
268
+ ? `${routing.backendUrl.replace(/\/+$/, "")}${url}`
269
+ : url;
270
+ return await tryFetchBytes(downloadUrl, { Authorization: `Bearer ${routing.machineJwt}` }, options);
271
+ }
272
+ if (!routing && extractHostedImageId(url) !== null) {
273
+ // A hosted-backend reference with no usable machine JWT: the anonymous
274
+ // fetch below is a guaranteed 401, so skip with a warning instead.
275
+ options.onWarn?.("Not logged in (no machine routing) — skipping hosted attachment persistence");
276
+ return null;
277
+ }
278
+ return await tryFetchBytes(url, undefined, options);
279
+ }
280
+ /** File count + total bytes of the files in a session directory. */
281
+ function sessionDirUsage(dir) {
282
+ let files = 0;
283
+ let bytes = 0;
284
+ let entries;
285
+ try {
286
+ entries = readdirSync(dir, { withFileTypes: true });
287
+ }
288
+ catch {
289
+ return { files, bytes };
290
+ }
291
+ for (const entry of entries) {
292
+ if (!entry.isFile())
293
+ continue;
294
+ try {
295
+ bytes += statSync(join(dir, entry.name)).size;
296
+ files += 1;
297
+ }
298
+ catch {
299
+ // Skip unreadable entries — usage is best-effort.
300
+ }
301
+ }
302
+ return { files, bytes };
303
+ }
304
+ /**
305
+ * Drop a `.gitignore` (containing `*` + `!.gitignore`) into the attachments
306
+ * root at first use, so persisted attachments are never committed regardless
307
+ * of the user's own gitignore. Best-effort.
308
+ */
309
+ function ensureAttachmentsGitignore(root, onWarn) {
310
+ const gitignore = join(root, ".gitignore");
311
+ if (existsSync(gitignore))
312
+ return;
313
+ try {
314
+ writeFileSync(gitignore, "*\n!.gitignore\n", "utf8");
315
+ }
316
+ catch (error) {
317
+ const message = error instanceof Error ? error.message : String(error);
318
+ onWarn?.(`Could not write ${gitignore}: ${message}`);
319
+ }
320
+ }
321
+ const defaultOnWarn = (message) => {
322
+ console.warn(`[spectral] warn: ${message}`);
323
+ };
324
+ /**
325
+ * Persist every usable attachment of a user message to disk and return the
326
+ * saved absolute paths (annotation input). NEVER throws and never rejects:
327
+ * every per-image failure warns and continues, and the prompt must always
328
+ * proceed — a missing file is a degradation, not an error.
329
+ *
330
+ * Per-image flow:
331
+ * - `{data}` → base64-decoded and written directly,
332
+ * - `{url}` → bytes fetched through the hosted resolver machinery.
333
+ *
334
+ * Skips (with a warning) when: the mime type is outside the allowlist, the
335
+ * per-session file cap / total size cap / wall-clock deadline is reached, or
336
+ * the fetch/write fails.
337
+ */
338
+ export async function persistAttachments(input) {
339
+ const saved = [];
340
+ const onWarn = input.onWarn ?? defaultOnWarn;
341
+ const images = Array.isArray(input.images) ? input.images : [];
342
+ const candidates = images
343
+ .map((image) => ({ image, name: attachmentFileName(image) }))
344
+ .filter((c) => c.name !== null);
345
+ if (candidates.length === 0)
346
+ return saved;
347
+ const sessionDir = attachmentsSessionDirFor(input.cwd, input.sessionId);
348
+ try {
349
+ mkdirSync(sessionDir, { recursive: true });
350
+ ensureAttachmentsGitignore(attachmentsRootFor(input.cwd), onWarn);
351
+ }
352
+ catch (error) {
353
+ const message = error instanceof Error ? error.message : String(error);
354
+ onWarn?.(`Could not create attachment directory ${sessionDir}: ${message} — continuing without persisted attachments`);
355
+ return saved;
356
+ }
357
+ let cachedRouting;
358
+ const getRouting = async () => {
359
+ if (input.backendUrl && input.machineJwt) {
360
+ return { backendUrl: input.backendUrl, machineJwt: input.machineJwt };
361
+ }
362
+ return (cachedRouting ??= await resolveBackendRouting().catch(() => null));
363
+ };
364
+ const usage = sessionDirUsage(sessionDir);
365
+ const deadlineAt = Date.now() + (input.deadlineMs ?? PERSIST_DEADLINE_MS);
366
+ for (const candidate of candidates) {
367
+ if (Date.now() > deadlineAt) {
368
+ onWarn?.(`Attachment persistence deadline (${input.deadlineMs ?? PERSIST_DEADLINE_MS}ms) reached — skipping remaining images`);
369
+ break;
370
+ }
371
+ if (usage.files >= MAX_FILES_PER_SESSION) {
372
+ onWarn?.(`Attachment file cap reached for session ${input.sessionId} (${MAX_FILES_PER_SESSION} files) — skipping ${candidate.name}`);
373
+ break;
374
+ }
375
+ const targetPath = join(sessionDir, candidate.name);
376
+ try {
377
+ let bytes = null;
378
+ const data = typeof candidate.image.data === "string" ? candidate.image.data : "";
379
+ if (data) {
380
+ bytes = decodeBase64(data);
381
+ if (!bytes)
382
+ onWarn?.(`Attachment ${candidate.name} has undecodable base64 — skipped`);
383
+ }
384
+ else if (candidate.image.url) {
385
+ bytes = await fetchAttachmentBytes(candidate.image, await getRouting(), { fetchImpl: input.fetchImpl, onWarn });
386
+ }
387
+ if (!bytes)
388
+ continue;
389
+ if (usage.bytes + bytes.byteLength > MAX_TOTAL_ATTACHMENT_BYTES) {
390
+ onWarn?.(`Attachment size cap reached for session ${input.sessionId} ` +
391
+ `(${MAX_TOTAL_ATTACHMENT_BYTES} bytes) — skipping ${candidate.name}`);
392
+ break;
393
+ }
394
+ writeFileSync(targetPath, bytes);
395
+ usage.files += 1;
396
+ usage.bytes += bytes.byteLength;
397
+ saved.push({ path: targetPath, bytes: bytes.byteLength });
398
+ }
399
+ catch (error) {
400
+ const message = error instanceof Error ? error.message : String(error);
401
+ onWarn?.(`Failed to persist attachment ${candidate.name}: ${message}`);
402
+ continue;
403
+ }
404
+ }
405
+ return saved;
406
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * REST handlers for the local secret store.
3
+ *
4
+ * Contract (shared with the landing frontend):
5
+ * - `GET /api/secrets` → `{ secrets: SecretMeta[] }` — metadata only,
6
+ * values are NEVER returned by anything.
7
+ * - `POST /api/secrets` → `{ secret: SecretMeta }` — body
8
+ * `{ name, value }`.
9
+ * - `DELETE /api/secrets/:name` → `{ ok: true }`.
10
+ *
11
+ * Name normalization/validation and the 64 KiB value cap live in
12
+ * `src/secrets/store.ts`. Errors follow the existing handler error shape
13
+ * (`BadRequestError`/`NotFoundError` → 400/404 in the relay rest loop).
14
+ *
15
+ * SECURITY: no handler here (or anywhere in the relay loop) logs a request or
16
+ * response value. Only names appear in error messages, and only after they
17
+ * have passed `normalizeSecretName` (letters/digits/underscores).
18
+ */
19
+ import { type SecretMeta } from "../../secrets/index.js";
20
+ /**
21
+ * `GET /api/secrets` — list secrets as metadata only.
22
+ */
23
+ export declare function handleListSecrets(): {
24
+ secrets: SecretMeta[];
25
+ };
26
+ /**
27
+ * `POST /api/secrets` — create or update a secret. Body: `{ name, value }`.
28
+ * Returns the metadata-only view; the stored value is never echoed back.
29
+ */
30
+ export declare function handleSetSecret(body: Record<string, unknown>): {
31
+ secret: SecretMeta;
32
+ };
33
+ /**
34
+ * `DELETE /api/secrets/:name` — delete a secret. Throws `NotFoundError` (404)
35
+ * when the name is not stored.
36
+ */
37
+ export declare function handleDeleteSecret(rawName: string): {
38
+ ok: true;
39
+ };
40
+ //# sourceMappingURL=secrets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/secrets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAON,KAAK,UAAU,EACf,MAAM,wBAAwB,CAAC;AAUhC;;GAEG;AACH,wBAAgB,iBAAiB,IAAI;IAAE,OAAO,EAAE,UAAU,EAAE,CAAA;CAAE,CAE7D;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAAE,MAAM,EAAE,UAAU,CAAA;CAAE,CAerF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAiBhE"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * REST handlers for the local secret store.
3
+ *
4
+ * Contract (shared with the landing frontend):
5
+ * - `GET /api/secrets` → `{ secrets: SecretMeta[] }` — metadata only,
6
+ * values are NEVER returned by anything.
7
+ * - `POST /api/secrets` → `{ secret: SecretMeta }` — body
8
+ * `{ name, value }`.
9
+ * - `DELETE /api/secrets/:name` → `{ ok: true }`.
10
+ *
11
+ * Name normalization/validation and the 64 KiB value cap live in
12
+ * `src/secrets/store.ts`. Errors follow the existing handler error shape
13
+ * (`BadRequestError`/`NotFoundError` → 400/404 in the relay rest loop).
14
+ *
15
+ * SECURITY: no handler here (or anywhere in the relay loop) logs a request or
16
+ * response value. Only names appear in error messages, and only after they
17
+ * have passed `normalizeSecretName` (letters/digits/underscores).
18
+ */
19
+ import { SecretNameError, SecretValueError, deleteSecret, listSecrets, normalizeSecretName, setSecret, } from "../../secrets/index.js";
20
+ import { BadRequestError, NotFoundError } from "./errors.js";
21
+ function toBadRequest(err) {
22
+ if (err instanceof SecretNameError || err instanceof SecretValueError) {
23
+ return new BadRequestError(err.message);
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * `GET /api/secrets` — list secrets as metadata only.
29
+ */
30
+ export function handleListSecrets() {
31
+ return { secrets: listSecrets() };
32
+ }
33
+ /**
34
+ * `POST /api/secrets` — create or update a secret. Body: `{ name, value }`.
35
+ * Returns the metadata-only view; the stored value is never echoed back.
36
+ */
37
+ export function handleSetSecret(body) {
38
+ if (typeof body.name !== "string" || body.name.trim() === "") {
39
+ throw new BadRequestError("name (string) is required");
40
+ }
41
+ if (typeof body.value !== "string") {
42
+ throw new BadRequestError("value (string) is required");
43
+ }
44
+ try {
45
+ const secret = setSecret(body.name, body.value);
46
+ return { secret };
47
+ }
48
+ catch (err) {
49
+ const mapped = toBadRequest(err);
50
+ if (mapped)
51
+ throw mapped;
52
+ throw err;
53
+ }
54
+ }
55
+ /**
56
+ * `DELETE /api/secrets/:name` — delete a secret. Throws `NotFoundError` (404)
57
+ * when the name is not stored.
58
+ */
59
+ export function handleDeleteSecret(rawName) {
60
+ if (typeof rawName !== "string" || rawName.trim() === "") {
61
+ throw new BadRequestError("name (string) is required");
62
+ }
63
+ let name;
64
+ try {
65
+ name = normalizeSecretName(rawName);
66
+ }
67
+ catch (err) {
68
+ const mapped = toBadRequest(err);
69
+ if (mapped)
70
+ throw mapped;
71
+ throw err;
72
+ }
73
+ const deleted = deleteSecret(name);
74
+ if (!deleted) {
75
+ throw new NotFoundError(`Secret ${name} not found`);
76
+ }
77
+ return { ok: true };
78
+ }
@@ -423,6 +423,11 @@ export declare class SessionStreamManager {
423
423
  private readonly onCloudAgentsAuthRejected;
424
424
  private readonly broker;
425
425
  private readonly streams;
426
+ /**
427
+ * Opportunistic attachment GC runs at most once per manager lifetime
428
+ * (first prompt). `false` until the fire-and-forget sweep is scheduled.
429
+ */
430
+ private attachmentsSweepRan;
426
431
  /**
427
432
  * In-flight `attach` per session. Serializes concurrent attaches so the
428
433
  * `subscribe` + first `client_message` race cannot create two independent