@aexol/spectral 0.9.212 → 0.9.216

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 (115) hide show
  1. package/dist/backend/generated-images.d.ts +180 -0
  2. package/dist/backend/generated-images.d.ts.map +1 -0
  3. package/dist/backend/generated-images.js +483 -0
  4. package/dist/backend/machine-routing.d.ts +25 -0
  5. package/dist/backend/machine-routing.d.ts.map +1 -0
  6. package/dist/backend/machine-routing.js +35 -0
  7. package/dist/commands/logout.d.ts.map +1 -1
  8. package/dist/commands/logout.js +4 -0
  9. package/dist/commands/serve.d.ts.map +1 -1
  10. package/dist/commands/serve.js +20 -2
  11. package/dist/extensions/browser/browser-service.d.ts +75 -1
  12. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  13. package/dist/extensions/browser/browser-service.js +600 -34
  14. package/dist/extensions/browser/index.d.ts +10 -4
  15. package/dist/extensions/browser/index.d.ts.map +1 -1
  16. package/dist/extensions/browser/index.js +44 -5
  17. package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
  18. package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
  19. package/dist/extensions/browser/stream/stream-tool.js +42 -14
  20. package/dist/extensions/browser/tools/core.d.ts.map +1 -1
  21. package/dist/extensions/browser/tools/core.js +63 -2
  22. package/dist/extensions/desktop-control/index.d.ts +55 -0
  23. package/dist/extensions/desktop-control/index.d.ts.map +1 -0
  24. package/dist/extensions/desktop-control/index.js +633 -0
  25. package/dist/extensions/desktop-control/keys.d.ts +33 -0
  26. package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
  27. package/dist/extensions/desktop-control/keys.js +170 -0
  28. package/dist/extensions/desktop-control/linux.d.ts +26 -0
  29. package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
  30. package/dist/extensions/desktop-control/linux.js +195 -0
  31. package/dist/extensions/desktop-control/macos.d.ts +53 -0
  32. package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
  33. package/dist/extensions/desktop-control/macos.js +382 -0
  34. package/dist/extensions/desktop-control/safety.d.ts +89 -0
  35. package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
  36. package/dist/extensions/desktop-control/safety.js +157 -0
  37. package/dist/extensions/desktop-control/types.d.ts +79 -0
  38. package/dist/extensions/desktop-control/types.d.ts.map +1 -0
  39. package/dist/extensions/desktop-control/types.js +10 -0
  40. package/dist/extensions/desktop-control/windows.d.ts +31 -0
  41. package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
  42. package/dist/extensions/desktop-control/windows.js +276 -0
  43. package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
  44. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  45. package/dist/extensions/desktop-screenshot/index.js +363 -17
  46. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  47. package/dist/extensions/image-generation/index.js +133 -34
  48. package/dist/extensions/seo/index.d.ts.map +1 -1
  49. package/dist/extensions/seo/index.js +4 -0
  50. package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
  51. package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
  52. package/dist/extensions/seo/tools/backend-bridge.js +378 -0
  53. package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
  54. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  55. package/dist/extensions/seo/tools/full-audit.js +1 -1
  56. package/dist/extensions/seo/tools/report.d.ts +10 -0
  57. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  58. package/dist/extensions/seo/tools/report.js +1 -1
  59. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  60. package/dist/extensions/spectral-vision-fallback.js +11 -1
  61. package/dist/generated/zeus/const.d.ts.map +1 -1
  62. package/dist/generated/zeus/const.js +121 -4
  63. package/dist/generated/zeus/index.d.ts +527 -0
  64. package/dist/generated/zeus/index.d.ts.map +1 -1
  65. package/dist/mcp/tool-registrar.d.ts.map +1 -1
  66. package/dist/mcp/tool-registrar.js +5 -2
  67. package/dist/mcp/types.d.ts +5 -0
  68. package/dist/mcp/types.d.ts.map +1 -1
  69. package/dist/relay/dispatcher.d.ts +12 -1
  70. package/dist/relay/dispatcher.d.ts.map +1 -1
  71. package/dist/relay/dispatcher.js +44 -42
  72. package/dist/sdk/ai/providers/faux.js +1 -1
  73. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  74. package/dist/sdk/ai/providers/openai-completions.js +69 -16
  75. package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
  76. package/dist/sdk/ai/providers/openrouter-images.js +58 -9
  77. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  78. package/dist/sdk/ai/providers/simple-options.js +2 -0
  79. package/dist/sdk/ai/types.d.ts +37 -1
  80. package/dist/sdk/ai/types.d.ts.map +1 -1
  81. package/dist/sdk/ai/utils/hosted-image-resolver.d.ts +60 -0
  82. package/dist/sdk/ai/utils/hosted-image-resolver.d.ts.map +1 -0
  83. package/dist/sdk/ai/utils/hosted-image-resolver.js +75 -0
  84. package/dist/sdk/ai/utils/image-source.d.ts +168 -0
  85. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
  86. package/dist/sdk/ai/utils/image-source.js +487 -0
  87. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
  89. package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
  90. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  92. package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
  93. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
  94. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
  95. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  96. package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
  97. package/dist/server/agent-bridge.d.ts +14 -0
  98. package/dist/server/agent-bridge.d.ts.map +1 -1
  99. package/dist/server/agent-bridge.js +105 -26
  100. package/dist/server/handlers/queue.d.ts.map +1 -1
  101. package/dist/server/handlers/queue.js +11 -34
  102. package/dist/server/image-attachments.d.ts +109 -0
  103. package/dist/server/image-attachments.d.ts.map +1 -0
  104. package/dist/server/image-attachments.js +195 -0
  105. package/dist/server/session-stream.d.ts +67 -0
  106. package/dist/server/session-stream.d.ts.map +1 -1
  107. package/dist/server/session-stream.js +222 -23
  108. package/dist/server/storage.d.ts.map +1 -1
  109. package/dist/server/storage.js +10 -31
  110. package/dist/server/ui/terminal-confirm.d.ts +145 -0
  111. package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
  112. package/dist/server/ui/terminal-confirm.js +333 -0
  113. package/dist/server/wire.d.ts +30 -5
  114. package/dist/server/wire.d.ts.map +1 -1
  115. package/package.json +1 -1
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Image source resolution for provider serialization.
3
+ *
4
+ * `ImageContent` can now carry either inline base64 (`data`) or a hosted
5
+ * `url`. Providers (OpenRouter, AWS Bedrock, OpenAI, ...) can only fetch
6
+ * images from PUBLIC urls — a `http://localhost:9002/...` MinIO url in dev is
7
+ * unreachable for them. This module resolves an `ImageContent` block into
8
+ * something a provider can actually consume:
9
+ *
10
+ * - `data` present -> `data:<mimeType>;base64,<data>` (unchanged, legacy safe)
11
+ * - `url` public -> passed through as-is (no base64 in the context!)
12
+ * - `url` backend-hosted -> swapped for a presigned, headerless-fetchable
13
+ * url (`/generated-images/<id>` is what the
14
+ * backend returns; it is AUTH-GATED, so handing
15
+ * it to a provider only yields a 401)
16
+ * - `url` relative -> resolved against the backend base url first
17
+ * - `url` local/private -> fetched and inlined **at send time only**
18
+ *
19
+ * The inlining is deliberately scoped to a single request: the fetched bytes
20
+ * are written into a throw-away copy of the message list, never back into the
21
+ * session, so base64 never accumulates in persisted history.
22
+ *
23
+ * @module @spectral/ai/utils/image-source
24
+ */
25
+ import { hostedImageIdFromUrl, isHostedImageUrl } from "../../../backend/generated-images.js";
26
+ /** Don't inline absurd payloads — 25 MiB of base64 would blow the request. */
27
+ export const DEFAULT_MAX_INLINE_BYTES = 25 * 1024 * 1024;
28
+ /** Timeout for fetching a local image that has to be inlined. */
29
+ export const DEFAULT_INLINE_FETCH_TIMEOUT_MS = 15_000;
30
+ function isDigitRun(value) {
31
+ return /^\d+$/.test(value);
32
+ }
33
+ function isPrivateIpv4(hostname) {
34
+ const parts = hostname.split(".");
35
+ if (parts.length !== 4 || !parts.every(isDigitRun))
36
+ return false;
37
+ const [a, b] = parts.map((p) => Number(p));
38
+ // 0.0.0.0/8, 10/8, 127/8 (loopback), 169.254/16 (link-local), 172.16/12, 192.168/16
39
+ if (a === 0 || a === 10 || a === 127)
40
+ return true;
41
+ if (a === 169 && b === 254)
42
+ return true;
43
+ if (a === 172 && b >= 16 && b <= 31)
44
+ return true;
45
+ if (a === 192 && b === 168)
46
+ return true;
47
+ // 100.64/10 CGNAT
48
+ if (a === 100 && b >= 64 && b <= 127)
49
+ return true;
50
+ return false;
51
+ }
52
+ /**
53
+ * Host suffixes that never resolve publicly: container/dev hosts
54
+ * (`host.docker.internal`), cloud metadata (`metadata.google.internal`),
55
+ * home networks (`*.home.arpa`) and reserved TLDs (`*.test`, `*.example`,
56
+ * `*.invalid`). A provider can never fetch these -> inline them.
57
+ */
58
+ const LOCAL_HOST_SUFFIXES = [
59
+ "docker.internal",
60
+ ".internal",
61
+ ".localhost",
62
+ ".local",
63
+ ".home.arpa",
64
+ ".test",
65
+ ".example",
66
+ ".invalid",
67
+ ];
68
+ const ABSOLUTE_URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
69
+ /**
70
+ * True when `rawUrl` has no scheme, i.e. it is relative and therefore
71
+ * unusable for a provider until it is joined with a base url.
72
+ */
73
+ export function isRelativeImageUrl(rawUrl) {
74
+ const trimmed = typeof rawUrl === "string" ? rawUrl.trim() : "";
75
+ if (!trimmed)
76
+ return false;
77
+ return !ABSOLUTE_URL_SCHEME.test(trimmed);
78
+ }
79
+ /**
80
+ * Join a relative image url (`/generated-images/<id>`) with the backend base
81
+ * url. Absolute urls and missing base urls are returned untouched.
82
+ */
83
+ export function resolveImageUrlAgainstBase(rawUrl, baseUrl) {
84
+ const trimmed = typeof rawUrl === "string" ? rawUrl.trim() : "";
85
+ if (!trimmed || !isRelativeImageUrl(trimmed))
86
+ return trimmed;
87
+ const base = typeof baseUrl === "string" ? baseUrl.trim() : "";
88
+ if (!base)
89
+ return trimmed;
90
+ try {
91
+ return new URL(trimmed, base).toString();
92
+ }
93
+ catch {
94
+ return trimmed;
95
+ }
96
+ }
97
+ /**
98
+ * Loopback / unique-local (fc00::/7) / link-local (fe80::/10) IPv6 are
99
+ * unreachable for a provider; every other literal (e.g. `2001:4860::8888`)
100
+ * is public and can be passed through.
101
+ */
102
+ function isPrivateIpv6(hostname) {
103
+ if (hostname === "::" || hostname === "::1" || hostname === "0:0:0:0:0:0:0:1")
104
+ return true;
105
+ const mapped = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
106
+ if (mapped)
107
+ return isPrivateIpv4(mapped[1]);
108
+ const head = hostname.split(":")[0] ?? "";
109
+ // fc00::/7 (fc00-fdff) unique local
110
+ if (/^f[cd][0-9a-f]{0,2}$/.test(head))
111
+ return true;
112
+ // fe80::/10 (fe80-febf) link local
113
+ if (/^fe[89ab][0-9a-f]?$/.test(head))
114
+ return true;
115
+ return false;
116
+ }
117
+ /**
118
+ * True when a provider is unlikely to be able to fetch this URL itself and
119
+ * the image must therefore be inlined as base64 before sending.
120
+ *
121
+ * Covers: localhost, `*.local`, `*.localhost`, loopback IPs (v4 + v6),
122
+ * RFC1918 private ranges, link-local and CGNAT ranges, and non-http(s)
123
+ * schemes (except `data:`/`blob:` which need no fetch at all), plus
124
+ * container/metadata/reserved hosts and relative urls that still need to be
125
+ * resolved against a base url.
126
+ */
127
+ export function isLocalOrPrivateImageUrl(rawUrl) {
128
+ const trimmed = typeof rawUrl === "string" ? rawUrl.trim() : "";
129
+ if (!trimmed)
130
+ return false;
131
+ // Relative (`/generated-images/x`) or unparseable: the provider would only
132
+ // ever see a path fragment, so this needs resolution/inlining.
133
+ if (isRelativeImageUrl(trimmed))
134
+ return true;
135
+ let url;
136
+ try {
137
+ url = new URL(trimmed);
138
+ }
139
+ catch {
140
+ // Unparseable -> treat like a relative url: unusable for the provider.
141
+ return true;
142
+ }
143
+ const protocol = url.protocol.toLowerCase();
144
+ if (protocol === "data:" || protocol === "blob:")
145
+ return false;
146
+ if (protocol !== "http:" && protocol !== "https:")
147
+ return true;
148
+ const hostname = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
149
+ if (hostname === "localhost" || hostname === "localhost.localdomain")
150
+ return true;
151
+ if (LOCAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)))
152
+ return true;
153
+ // IPv6 literal: explicit ranges only — a public literal must NOT be
154
+ // inlined (it has no dot, so the single-label rule below would misfire).
155
+ if (hostname.includes(":"))
156
+ return isPrivateIpv6(hostname);
157
+ if (isPrivateIpv4(hostname))
158
+ return true;
159
+ // Single-label hostnames (docker service names, "minio", ...) are not
160
+ // publicly resolvable -> inline them.
161
+ if (!hostname.includes("."))
162
+ return true;
163
+ return false;
164
+ }
165
+ async function resolveBaseUrlOption(baseUrl) {
166
+ if (!baseUrl)
167
+ return undefined;
168
+ if (typeof baseUrl === "string")
169
+ return baseUrl;
170
+ try {
171
+ return await baseUrl();
172
+ }
173
+ catch {
174
+ return undefined;
175
+ }
176
+ }
177
+ /**
178
+ * True for a backend-hosted image reference, either backend-relative
179
+ * (`/generated-images/<id>`) or absolute
180
+ * (`https://api.aexol.ai/generated-images/<id>`).
181
+ *
182
+ * `backendUrl` is REQUIRED to accept an ABSOLUTE url: without it there is no
183
+ * origin to compare against, and a foreign host that happens to expose
184
+ * `/generated-images/<id>` must never be treated as ours (that would send the
185
+ * machine JWT to it, and would let it swap in a presigned url of one of our
186
+ * tenant's images). Relative urls are always accepted — they can only ever be
187
+ * resolved against the backend base url.
188
+ *
189
+ * Those endpoints are auth-gated: a provider fetching them without an
190
+ * `Authorization` header gets a 401 and never sees the image.
191
+ */
192
+ export function isHostedBackendImageUrl(rawUrl, backendUrl) {
193
+ return isHostedImageUrl(rawUrl, backendUrl ?? undefined);
194
+ }
195
+ /** Resolve (and normalize) the lazily supplied auth headers. */
196
+ async function resolveAuthHeadersOption(authHeaders) {
197
+ if (!authHeaders)
198
+ return undefined;
199
+ let headers;
200
+ if (typeof authHeaders === "function") {
201
+ try {
202
+ headers = await authHeaders();
203
+ }
204
+ catch {
205
+ return undefined;
206
+ }
207
+ }
208
+ else {
209
+ headers = authHeaders;
210
+ }
211
+ if (!headers || Object.keys(headers).length === 0)
212
+ return undefined;
213
+ return headers;
214
+ }
215
+ export function hasInlineImageData(image) {
216
+ return typeof image.data === "string" && image.data.length > 0;
217
+ }
218
+ /** Build a `data:` URI from base64 payload + mime type (idempotent). */
219
+ export function dataUriForImage(data, mimeType) {
220
+ if (data.startsWith("data:"))
221
+ return data;
222
+ return `data:${mimeType || "image/png"};base64,${data}`;
223
+ }
224
+ /**
225
+ * `ImageContent.data` is base64 WITHOUT a `data:` prefix, but legacy
226
+ * sessions may carry a full data URI. Strip it so consumers doing
227
+ * `Buffer.from(data, "base64")` get real bytes.
228
+ */
229
+ export function base64FromImageData(data) {
230
+ if (!data.startsWith("data:"))
231
+ return data;
232
+ const comma = data.indexOf(",");
233
+ return comma === -1 ? data : data.slice(comma + 1);
234
+ }
235
+ function bytesToBase64(bytes) {
236
+ const maybeBuffer = globalThis
237
+ .Buffer;
238
+ if (maybeBuffer)
239
+ return maybeBuffer.from(bytes).toString("base64");
240
+ let binary = "";
241
+ const CHUNK = 0x8000;
242
+ for (let i = 0; i < bytes.length; i += CHUNK) {
243
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
244
+ }
245
+ return btoa(binary);
246
+ }
247
+ function normalizeMimeType(value, fallback) {
248
+ if (!value)
249
+ return fallback || "image/png";
250
+ const base = value.split(";")[0].trim().toLowerCase();
251
+ return base || fallback || "image/png";
252
+ }
253
+ /**
254
+ * Resolve an image block into a provider-sendable url.
255
+ *
256
+ * Returns `null` when the block carries neither data nor url (nothing sendable).
257
+ * Local/private urls are fetched and inlined; failures fall back to the raw
258
+ * url so the caller still emits *something* rather than dropping the image.
259
+ */
260
+ export async function resolveImageForProvider(image, options = {}) {
261
+ if (hasInlineImageData(image)) {
262
+ // Keep the stored data URI intact (mime may differ from `mimeType`),
263
+ // but expose the raw base64 so copies stay contract-compliant.
264
+ const raw = base64FromImageData(image.data);
265
+ return {
266
+ url: image.data.startsWith("data:") ? image.data : dataUriForImage(raw, image.mimeType),
267
+ inlined: true,
268
+ mimeType: image.mimeType,
269
+ data: raw,
270
+ };
271
+ }
272
+ let url = typeof image.url === "string" ? image.url.trim() : "";
273
+ if (!url)
274
+ return null;
275
+ // Resolve the backend base url lazily, and ONLY when the url even looks
276
+ // hosted (path-wise): public cdn urls must not pay for a config/machine
277
+ // store read. The base url doubles as the origin an absolute url has to
278
+ // match before it is accepted as ours.
279
+ const looksHosted = hostedImageIdFromUrl(url) !== null;
280
+ const backendUrl = looksHosted ? await resolveBaseUrlOption(options.baseUrl) : undefined;
281
+ // Relative urls (`/generated-images/<id>`) must be joined with the backend
282
+ // base url before anything else — otherwise the provider gets a path.
283
+ if (isRelativeImageUrl(url)) {
284
+ url = resolveImageUrlAgainstBase(url, backendUrl);
285
+ }
286
+ // A hosted url points at OUR backend, which authenticates every request:
287
+ // the provider would fetch it anonymously and get a 401. Swap it for a
288
+ // presigned url first (that one needs no headers at all).
289
+ const hosted = isHostedBackendImageUrl(url, backendUrl);
290
+ if (hosted && options.resolveHostedUrl) {
291
+ try {
292
+ const resolvedUrl = (await options.resolveHostedUrl(url, { signal: options.signal, timeoutMs: options.timeoutMs }))?.trim();
293
+ if (resolvedUrl) {
294
+ return { url: resolvedUrl, inlined: false, mimeType: image.mimeType, hostedResolved: true };
295
+ }
296
+ }
297
+ catch (error) {
298
+ const reason = error instanceof Error ? error.message : String(error);
299
+ options.onWarn?.(`Could not resolve hosted image ${url} (${reason})`);
300
+ }
301
+ }
302
+ // Only inline a hosted url when we can actually authenticate the download;
303
+ // without credentials the fetch is a guaranteed 401 anyway, so the plain
304
+ // pass-through (identical end result, one request less) is kept.
305
+ const authHeaders = hosted ? await resolveAuthHeadersOption(options.authHeaders) : undefined;
306
+ // Belt and braces: re-check the origin here, because `url` was rewritten
307
+ // above (relative -> absolute) and only a url that is still ours may carry
308
+ // credentials. The resolver refuses foreign origins too, this is the last
309
+ // gate before the token goes on the wire.
310
+ const authenticated = authHeaders !== undefined && isHostedBackendImageUrl(url, backendUrl);
311
+ if (!isLocalOrPrivateImageUrl(url) && !authenticated) {
312
+ return { url, inlined: false, mimeType: image.mimeType };
313
+ }
314
+ const requestHeaders = authenticated ? authHeaders : undefined;
315
+ const doFetch = options.fetchImpl ?? fetch;
316
+ const timeoutMs = options.timeoutMs ?? DEFAULT_INLINE_FETCH_TIMEOUT_MS;
317
+ const maxInlineBytes = options.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
318
+ const controller = new AbortController();
319
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
320
+ const onAbort = () => controller.abort();
321
+ options.signal?.addEventListener("abort", onAbort, { once: true });
322
+ try {
323
+ // An already-aborted signal never fires the listener above.
324
+ if (options.signal?.aborted)
325
+ throw new Error("aborted");
326
+ // `requestHeaders` is only set when the (possibly rewritten) url still
327
+ // resolves to the backend origin — a url on any other host is fetched
328
+ // anonymously, so the JWT cannot leak to a third party.
329
+ const response = await doFetch(url, requestHeaders ? { signal: controller.signal, headers: requestHeaders } : { signal: controller.signal });
330
+ if (!response.ok)
331
+ throw new Error(`HTTP ${response.status}`);
332
+ // Reject oversized payloads from the declared length BEFORE reading
333
+ // the body — otherwise the whole payload lands in memory first.
334
+ const declaredLength = response.headers?.get?.("content-length");
335
+ if (declaredLength !== null && declaredLength !== undefined) {
336
+ const declared = Number(declaredLength);
337
+ if (Number.isFinite(declared) && declared > maxInlineBytes) {
338
+ throw new Error(`image too large to inline (${declared} bytes)`);
339
+ }
340
+ }
341
+ const buffer = await response.arrayBuffer();
342
+ if (buffer.byteLength > maxInlineBytes) {
343
+ throw new Error(`image too large to inline (${buffer.byteLength} bytes)`);
344
+ }
345
+ const mimeType = normalizeMimeType(response.headers?.get?.("content-type"), image.mimeType);
346
+ const data = bytesToBase64(new Uint8Array(buffer));
347
+ return {
348
+ url: dataUriForImage(data, mimeType),
349
+ inlined: true,
350
+ mimeType,
351
+ data,
352
+ };
353
+ }
354
+ catch (error) {
355
+ const reason = error instanceof Error ? error.message : String(error);
356
+ options.onWarn?.(`Could not inline local image ${url} (${reason}); sending url as-is${hosted ? " — the provider may not be able to fetch it (auth required)" : ""}`);
357
+ return { url, inlined: false, mimeType: image.mimeType };
358
+ }
359
+ finally {
360
+ clearTimeout(timer);
361
+ options.signal?.removeEventListener("abort", onAbort);
362
+ }
363
+ }
364
+ function inlineImageContentBlock(block, resolved) {
365
+ const image = block;
366
+ const patch = resolved.get(image);
367
+ if (patch === undefined)
368
+ return block;
369
+ // Copy-on-write: never mutate the block stored in the session.
370
+ const copy = { ...image, ...patch };
371
+ return copy;
372
+ }
373
+ /**
374
+ * Pre-pass used right before a request is built: any image block that only has
375
+ * a local/private `url` gets its bytes fetched and attached as `data` on a
376
+ * throw-away copy of the context.
377
+ *
378
+ * The returned context is a shallow clone (messages copied, unchanged messages
379
+ * shared by reference) and is discarded after the single request completes, so
380
+ * base64 never lands in session history. When there is nothing to inline the
381
+ * original context object is returned unchanged.
382
+ */
383
+ export async function resolveImagesForContext(context, options = {}) {
384
+ const pending = [];
385
+ for (const message of context.messages) {
386
+ if (message.role === "user" && Array.isArray(message.content)) {
387
+ for (const block of message.content) {
388
+ if (block.type === "image" && !hasInlineImageData(block))
389
+ pending.push(block);
390
+ }
391
+ }
392
+ else if (message.role === "toolResult") {
393
+ for (const block of message.content) {
394
+ if (block.type === "image" && !hasInlineImageData(block))
395
+ pending.push(block);
396
+ }
397
+ }
398
+ }
399
+ if (pending.length === 0)
400
+ return context;
401
+ const resolved = new Map();
402
+ await Promise.all(pending.map(async (image) => {
403
+ const source = await resolveImageForProvider(image, options);
404
+ if (!source)
405
+ return;
406
+ const originalUrl = typeof image.url === "string" ? image.url.trim() : "";
407
+ const patch = {};
408
+ if (source.inlined) {
409
+ // `data` must be raw base64 (no `data:` prefix) — see
410
+ // `ImageContent.data` and `base64FromImageData`.
411
+ if (source.data)
412
+ patch.data = source.data;
413
+ if (source.mimeType && source.mimeType !== image.mimeType)
414
+ patch.mimeType = source.mimeType;
415
+ }
416
+ else if (source.url !== originalUrl) {
417
+ // e.g. a relative url resolved against the backend base url.
418
+ patch.url = source.url;
419
+ }
420
+ if (Object.keys(patch).length > 0)
421
+ resolved.set(image, patch);
422
+ }));
423
+ if (resolved.size === 0)
424
+ return context;
425
+ const messages = context.messages.map((message) => {
426
+ if (message.role === "user" && Array.isArray(message.content)) {
427
+ return {
428
+ ...message,
429
+ content: message.content.map((block) => inlineImageContentBlock(block, resolved)),
430
+ };
431
+ }
432
+ if (message.role === "toolResult") {
433
+ return {
434
+ ...message,
435
+ content: message.content.map((block) => inlineImageContentBlock(block, resolved)),
436
+ };
437
+ }
438
+ return message;
439
+ });
440
+ return { ...context, messages };
441
+ }
442
+ /**
443
+ * Same as `resolveImagesForContext` but for the images API input list.
444
+ */
445
+ export async function resolveImagesForInput(input, options = {}) {
446
+ const images = input.filter((item) => item.type === "image");
447
+ if (images.length === 0)
448
+ return input;
449
+ let changed = false;
450
+ const resolvedInputs = await Promise.all(input.map(async (item) => {
451
+ if (item.type !== "image")
452
+ return item;
453
+ const image = item;
454
+ const source = await resolveImageForProvider(image, options);
455
+ if (!source)
456
+ return item;
457
+ if (source.inlined && source.data) {
458
+ changed = true;
459
+ return { ...image, data: source.data, mimeType: source.mimeType };
460
+ }
461
+ if (!source.inlined && source.url !== (typeof image.url === "string" ? image.url.trim() : "")) {
462
+ changed = true;
463
+ return { ...image, url: source.url };
464
+ }
465
+ return item;
466
+ }));
467
+ return changed ? resolvedInputs : input;
468
+ }
469
+ /**
470
+ * Synchronous serializer used inside the (sync) param builders: after the
471
+ * `resolveImagesForContext` pre-pass there should be nothing local left, so
472
+ * this only has to choose between an existing `data` payload and a public url.
473
+ */
474
+ export function imageSourceUrlForProvider(image, baseUrl) {
475
+ if (hasInlineImageData(image))
476
+ return dataUriForImage(image.data, image.mimeType);
477
+ const url = resolveImageUrlAgainstBase(image.url ?? "", baseUrl);
478
+ if (!url)
479
+ return undefined;
480
+ // Still relative after resolution (`/generated-images/<id>` with no
481
+ // backend base url configured): a provider would only receive a path
482
+ // fragment and answer HTTP 400, so drop the block instead of sending
483
+ // something it can never fetch.
484
+ if (isRelativeImageUrl(url))
485
+ return undefined;
486
+ return url;
487
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"native-extensions.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/extensions/native-extensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,GAAG,YAAY,GAAG,aAAa,CAAC;IAChD;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,cAAc,EAAE,OAAO,CAAC;IACxB,mCAAmC;IACnC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,EAAE,uBAAuB,EAkJtD,CAAC;AAEF;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS,CAElF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAI1G;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC;IAClG,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC,CAUD"}
1
+ {"version":3,"file":"native-extensions.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/extensions/native-extensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,GAAG,YAAY,GAAG,aAAa,CAAC;IAChD;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,cAAc,EAAE,OAAO,CAAC;IACxB,mCAAmC;IACnC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,EAAE,uBAAuB,EAiKtD,CAAC;AAEF;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS,CAElF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAI1G;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC;IAClG,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC,CAUD"}
@@ -119,6 +119,20 @@ export const NATIVE_EXTENSIONS = [
119
119
  defaultEnabled: true,
120
120
  tags: ["screenshot", "desktop", "vision", "screen"],
121
121
  },
122
+ {
123
+ id: "desktop-control",
124
+ label: "Desktop Control",
125
+ description: "Computer use with a safety layer: moves the mouse, clicks, drags, scrolls, types text, presses hotkeys and " +
126
+ "focuses windows via native OS APIs (macOS: JXA/CGEvent, Windows: PowerShell/SendInput, Linux: xdotool/wtype). " +
127
+ "Every mutating action returns before/after screenshots for verification. Enabling the extension is the " +
128
+ "explicit consent, so actions run without a confirmation prompt; set SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1 to " +
129
+ "opt back into a per-session prompt. Supports SPECTRAL_DESKTOP_DRY_RUN and SPECTRAL_DESKTOP_ALLOWLIST. " +
130
+ "Off by default because it injects real input.",
131
+ category: "automation",
132
+ entryPath: "src/extensions/desktop-control/index.ts",
133
+ defaultEnabled: false,
134
+ tags: ["desktop", "computer-use", "mouse", "keyboard", "automation", "dangerous"],
135
+ },
122
136
  {
123
137
  id: "code-order",
124
138
  label: "Code Order",
@@ -83,7 +83,16 @@ export interface ExtensionUIDialogOptions {
83
83
  signal?: AbortSignal;
84
84
  /** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */
85
85
  timeout?: number;
86
+ /**
87
+ * Optional side-channel reporting how the dialog ended, so callers can
88
+ * distinguish an explicit human decision (`allow`/`deny`) from an auto-deny
89
+ * (`timeout`, `no_tty`, `cancelled`) where no user answered. Modes that
90
+ * cannot report outcomes simply never call it.
91
+ */
92
+ onOutcome?: (outcome: ExtensionConfirmOutcome) => void;
86
93
  }
94
+ /** How a confirm dialog ended (see ExtensionUIDialogOptions.onOutcome). */
95
+ export type ExtensionConfirmOutcome = "allow" | "deny" | "timeout" | "no_tty" | "cancelled";
87
96
  /** Placement for extension widgets. */
88
97
  export type WidgetPlacement = "aboveEditor" | "belowEditor";
89
98
  /** Options for extension widgets. */