@bivy/bivy 0.5.1-staging.74 → 0.5.1-staging.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js CHANGED
@@ -164,8 +164,13 @@ export function isAuthorized(ctx) {
164
164
  * local/private hostnames and reject a public Host (rebinding) or public Origin
165
165
  * (cross-site). Escape hatches: BIVY_ALLOWED_HOSTS (comma-separated extra
166
166
  * hostnames, e.g. a reverse-proxy domain) and BIVY_ALLOW_ANY_ORIGIN=1.
167
+ *
168
+ * Exported: also reused as the private/local-address check for the inline
169
+ * markdown-image SSRF guard (src/session/inline-image-fetch.ts) — same
170
+ * "must not be a private/loopback/link-local address" question, just asked
171
+ * about an *outbound* fetch target instead of an *inbound* request's Host.
167
172
  */
168
- function hostnameIsLocal(hostname) {
173
+ export function hostnameIsLocal(hostname) {
169
174
  const h = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/^::ffff:/, "");
170
175
  if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0")
171
176
  return true;
@@ -66,14 +66,23 @@ const FALLBACK_MODELS = [
66
66
  * it can't (it looks for a tool, finds none). BIVY_SESSION_ID is injected into the
67
67
  * subprocess env (see spawnQuery), so the bare command resolves the session. Keep
68
68
  * this short: it rides on every turn's system prompt.
69
+ *
70
+ * The chat still has no route to a LOCAL/workspace file path, so that half of the
71
+ * guidance (use `bivy attach`, not markdown, for those) stands. A REMOTE
72
+ * `https://` image URL is different: the node now fetches it server-side and
73
+ * serves it back to the chat (see src/session/inline-image-fetch.ts, issue #293),
74
+ * so plain markdown is the right tool there — `bivy attach` only works on files
75
+ * already inside the workspace, which a URL by definition isn't.
69
76
  */
70
77
  export const BIVY_ATTACH_SYSTEM_PROMPT = "Sending files and images to the user: the person you're talking to is in a chat UI. They cannot see files you only " +
71
- "write to disk, and the chat cannot load remote image URLs or workspace file paths. " +
72
- "To show them a file or image — a report, screenshot, chart, or a file they asked for — run " +
78
+ "write to disk, and the chat has no route to a workspace file path. " +
79
+ "To show them a LOCAL file or image — a report, screenshot, chart, or a file they asked for — run " +
73
80
  '`bivy attach <path> [--caption "short note"]` in your shell. ' +
74
81
  "An image renders inline in the chat; any other file shows as a downloadable chip. The path must be inside the session " +
75
- "workspace. Do NOT use markdown image syntax like ![](path) to show a local file or a URL — it will not render; always " +
76
- "use `bivy attach`. Prefer this over pasting large file contents or describing where a file lives on disk.";
82
+ "workspace. Do NOT use markdown image syntax like ![](path) for a local file or workspace path — it will not render; " +
83
+ "always use `bivy attach` for those. A REMOTE image you already have a URL for is different: plain markdown " +
84
+ "`![alt](https://...)` renders it inline, no attach needed. Prefer `bivy attach` / markdown links over pasting large " +
85
+ "file contents or describing where a file lives on disk.";
77
86
  /** Name of the in-process MCP server the native attach tool is registered
78
87
  * under (see buildAttachMcpServer) — the SDK namespaces the tool the agent
79
88
  * sees as `mcp__<server>__<tool>`. */
package/dist/server.js CHANGED
@@ -74,6 +74,7 @@ import { buildNativeImportSeedPrompt } from "./session/native-import.js";
74
74
  import { EventLog } from "./session/event-log.js";
75
75
  import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
76
76
  import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
77
+ import { extractInlineImageUrls, assistantTextForImageScan, fetchInlineImage, isFetchImageError, inlineImageDisplayName, } from "./session/inline-image-fetch.js";
77
78
  import { ReplicationService } from "./session/replication-service.js";
78
79
  import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
79
80
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
@@ -2322,6 +2323,70 @@ function persistTranscriptSnapshot(record) {
2322
2323
  return;
2323
2324
  eventLog.appendBaseSnapshot(record.id, base);
2324
2325
  }
2326
+ // In-flight dedupe so two sessions (or two turns) referencing the same remote
2327
+ // image URL only ever trigger one outbound fetch. Process-lifetime only — a
2328
+ // restart just means the first re-encounter fetches again, which is fine.
2329
+ const inlineImageFetchInFlight = new Map();
2330
+ // A URL that failed (bad host, timeout, not-an-image, …) is not retried for a
2331
+ // cooldown window, so a persistently broken URL in a long-lived session can't
2332
+ // turn every subsequent message_end into a wasted fetch attempt.
2333
+ const inlineImageFailedAt = new Map();
2334
+ const INLINE_IMAGE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
2335
+ /**
2336
+ * Scan a session's just-finalized assistant messages for remote markdown images
2337
+ * (`![alt](https://…)`) and, for any URL not already resolved or in flight,
2338
+ * fetch it (SSRF-guarded, size-capped — see inline-image-fetch.ts), store the
2339
+ * bytes in the content-addressed AttachmentStore, persist the durable url→ref
2340
+ * mapping, and broadcast it live so an already-open chat hydrates the image
2341
+ * without waiting for a reload. Fire-and-forget: called from the session event
2342
+ * listener, which must not block on a network fetch.
2343
+ */
2344
+ function resolveInlineImages(record) {
2345
+ const messages = record.session.getMessages();
2346
+ const urls = new Set();
2347
+ for (const m of messages) {
2348
+ if (m.role !== "assistant")
2349
+ continue;
2350
+ for (const url of extractInlineImageUrls(assistantTextForImageScan(m.content)))
2351
+ urls.add(url);
2352
+ }
2353
+ if (!urls.size)
2354
+ return;
2355
+ const alreadyResolved = new Set(eventLog.readInlineImages(record.id).map(([url]) => url));
2356
+ for (const url of urls) {
2357
+ if (alreadyResolved.has(url) || inlineImageFetchInFlight.has(url))
2358
+ continue;
2359
+ const failedAt = inlineImageFailedAt.get(url);
2360
+ if (failedAt !== undefined && Date.now() - failedAt < INLINE_IMAGE_RETRY_COOLDOWN_MS)
2361
+ continue;
2362
+ const task = (async () => {
2363
+ try {
2364
+ const result = await fetchInlineImage(url);
2365
+ if (isFetchImageError(result)) {
2366
+ console.warn(`[inline-image] ${url}: ${result.error}`);
2367
+ inlineImageFailedAt.set(url, Date.now());
2368
+ return;
2369
+ }
2370
+ const ref = attachmentStore.put(result.bytes, {
2371
+ name: inlineImageDisplayName(url, result.mimeType),
2372
+ mimeType: result.mimeType,
2373
+ kind: "image",
2374
+ });
2375
+ eventLog.appendInlineImage(record.id, { url, ref });
2376
+ eventLog.flush(record.id);
2377
+ broadcast({ type: "session.event", sessionId: record.id, event: { type: "inlineImage", url, ref } });
2378
+ }
2379
+ catch (error) {
2380
+ console.warn(`[inline-image] ${url}:`, error instanceof Error ? error.message : String(error));
2381
+ inlineImageFailedAt.set(url, Date.now());
2382
+ }
2383
+ finally {
2384
+ inlineImageFetchInFlight.delete(url);
2385
+ }
2386
+ })();
2387
+ inlineImageFetchInFlight.set(url, task);
2388
+ }
2389
+ }
2325
2390
  // Append the tool-activity entry to the log. The id-merge and last-500 cap the old
2326
2391
  // store applied are now applied by `foldTool` on replay.
2327
2392
  function upsertToolActivityMessage(sessionId, entry) {
@@ -2445,6 +2510,11 @@ function buildHistoryEvent(opts) {
2445
2510
  // Durable attachment references (text→refs), so a client that never sent the
2446
2511
  // attachment (a reload, or a different device) rehydrates thumbnails by hash.
2447
2512
  attachmentRefs: opts.sessionId ? eventLog.readAttachments(opts.sessionId) : [],
2513
+ // Durable url→ref map for remote markdown images the node has already
2514
+ // fetched (see resolveInlineImages) — lets a reload resolve a
2515
+ // `data-remote-src` placeholder straight to its attachment hash instead of
2516
+ // waiting on a fresh (redundant) fetch.
2517
+ inlineImageRefs: opts.sessionId ? eventLog.readInlineImages(opts.sessionId) : [],
2448
2518
  };
2449
2519
  }
2450
2520
  // Idempotency for `session.new` keyed by requestId: a client's post-reconnect
@@ -6786,6 +6856,15 @@ function attachSessionListeners(record) {
6786
6856
  if (event.type === "turn_start" || event.type === "message_end" || event.type === "turn_end") {
6787
6857
  persistTranscriptSnapshot(record);
6788
6858
  }
6859
+ // A finalized assistant message may reference a remote image via markdown
6860
+ // (`![alt](https://…)`) — fetch and store it now so the chat can render it
6861
+ // (see resolveInlineImages). Checked on both events: message_end is the
6862
+ // precise "this assistant message is done" signal most runtimes emit, but
6863
+ // turn_end is a safety net for one that only surfaces the final text there.
6864
+ // Fire-and-forget and internally deduped, so checking on both costs nothing.
6865
+ if (event.type === "message_end" || event.type === "turn_end") {
6866
+ resolveInlineImages(record);
6867
+ }
6789
6868
  // Durably persist the throttled sidecars at the turn boundary so a crash
6790
6869
  // loses at most the in-flight turn's UI detail, not the whole turn.
6791
6870
  if (event.type === "turn_end")
@@ -61,8 +61,18 @@ function isOutboundAttachment(value) {
61
61
  typeof record.ref === "object" &&
62
62
  typeof record.ref.hash === "string");
63
63
  }
64
+ function isInlineImage(value) {
65
+ if (!value || typeof value !== "object")
66
+ return false;
67
+ const record = value;
68
+ return (record.bivyKind === "inline-image" &&
69
+ typeof record.url === "string" &&
70
+ !!record.ref &&
71
+ typeof record.ref === "object" &&
72
+ typeof record.ref.hash === "string");
73
+ }
64
74
  function isRecord(value) {
65
- return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value);
75
+ return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value);
66
76
  }
67
77
  /**
68
78
  * Fold attachment records into a text→refs list: last write wins per text (a
@@ -85,6 +95,24 @@ export function replayAttachments(entries) {
85
95
  }
86
96
  return [...byText.entries()];
87
97
  }
98
+ /**
99
+ * Fold inline-image records into a url→ref list: last write wins per URL
100
+ * (a re-resolved URL — e.g. after a retry — re-keys onto the newest ref),
101
+ * preserving first-seen order. Mirrors replayAttachments' shape exactly, one
102
+ * level simpler (a single ref instead of an array) since one URL is one image.
103
+ */
104
+ export function replayInlineImages(entries) {
105
+ const byUrl = new Map();
106
+ for (const entry of entries) {
107
+ if (entry.bivyKind !== "inline-image")
108
+ continue;
109
+ if (!entry.url)
110
+ continue;
111
+ byUrl.delete(entry.url);
112
+ byUrl.set(entry.url, entry.ref);
113
+ }
114
+ return [...byUrl.entries()];
115
+ }
88
116
  /**
89
117
  * Fold the intermediate-reasoning entries exactly as the legacy incremental
90
118
  * upsert did (`upsertIntermediateMessage` in server.ts): last write wins per id;
@@ -343,6 +371,22 @@ export class EventLog {
343
371
  };
344
372
  this.enqueue(id, `oa:${entry.id}`, record);
345
373
  }
374
+ /**
375
+ * Record the durable ref for a fetched inline (remote markdown) image,
376
+ * keyed by its source URL. Coalesces on the URL so a re-resolve (retry after
377
+ * a transient failure) updates in place rather than appending a duplicate line.
378
+ */
379
+ appendInlineImage(id, entry) {
380
+ if (!entry.url)
381
+ return;
382
+ this.load(id);
383
+ const record = { bivyKind: "inline-image", createdAt: Date.now(), url: entry.url, ref: { ...entry.ref } };
384
+ this.enqueue(id, `ii:${entry.url}`, record);
385
+ }
386
+ /** Replay the inline-image records (disk + pending) into a url→ref list. */
387
+ readInlineImages(id) {
388
+ return replayInlineImages(this.entries(id));
389
+ }
346
390
  /** Replay the overlay entries (disk + pending) into the flat `extras` list. */
347
391
  read(id) {
348
392
  return replayExtras(this.entries(id));
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Fetch + validate a remote image an agent referenced with markdown image syntax
5
+ // (`![alt](https://…)`), so the node — not the viewer's browser — makes the
6
+ // request. See docs/issue #293: the deployed web app's CSP (`img-src 'self'
7
+ // data: blob:`) blocks a literal `<img src="https://…">` outright, so without
8
+ // this the syntax rendered nothing. Fetching server-side also closes the
9
+ // SSRF/privacy hole a client-side fetch would otherwise open (an agent could
10
+ // otherwise get the *viewer's* browser/IP to hit an arbitrary URL by embedding
11
+ // it in a reply).
12
+ //
13
+ // This is the PURE-ish, testable half — URL extraction, host/SSRF validation,
14
+ // and the guarded fetch itself — all dependency-injectable so tests never hit
15
+ // the real network or DNS. The server half (src/server.ts) owns the AttachmentStore
16
+ // write, the durable event-log ref, and the live broadcast; see resolveInlineImages.
17
+ import dns from "node:dns/promises";
18
+ import { hostnameIsLocal } from "../auth.js";
19
+ import { sanitizeAttachmentName, sniffMime } from "./attach-to-chat.js";
20
+ /**
21
+ * The image-markdown pattern, `![alt](https://…)` — MUST match the image regex
22
+ * in `inline()` in packages/core/src/markdown.ts exactly. Not a shared import: the
23
+ * node (src/) intentionally does not depend on @bivy/core (a browser/client
24
+ * package — see packages/core's own description). Kept in lock-step by comment
25
+ * instead, the same convention EPHEMERAL_ALLOWED_HOSTS uses in
26
+ * src/ephemeral-exec.ts for its cross-copy host allowlist. If you change one,
27
+ * change the other.
28
+ */
29
+ const INLINE_IMAGE_MD_RE = /!\[[^\]]*\]\((https:\/\/[^)\s]+)\)/g;
30
+ /** Bound how many distinct remote images a single message can trigger a fetch
31
+ * for — a pathological/malicious message can't fan out into an unbounded
32
+ * number of outbound requests. */
33
+ export const MAX_INLINE_IMAGES_PER_MESSAGE = 6;
34
+ /** Ceiling for a single fetched inline image. Smaller than
35
+ * MAX_AGENT_ATTACHMENT_BYTES (attach-to-chat.ts) because these bytes come from
36
+ * an arbitrary, untrusted remote origin rather than the local workspace —
37
+ * still comfortably under the relay's 32 MiB reassembly limit (see
38
+ * packages/core/src/wire-format.ts). */
39
+ export const MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024;
40
+ const FETCH_TIMEOUT_MS = 10_000;
41
+ /** Hard cap on redirect hops, mirroring execEphemeralRequest's guard. */
42
+ const MAX_REDIRECTS = 5;
43
+ /** Extract the distinct `https://` URLs a message's raw markdown references via
44
+ * `![alt](url)`, in first-seen order, capped at MAX_INLINE_IMAGES_PER_MESSAGE. */
45
+ export function extractInlineImageUrls(text) {
46
+ if (!text)
47
+ return [];
48
+ const out = [];
49
+ const seen = new Set();
50
+ for (const match of text.matchAll(INLINE_IMAGE_MD_RE)) {
51
+ const url = match[1];
52
+ if (!url || seen.has(url))
53
+ continue;
54
+ seen.add(url);
55
+ out.push(url);
56
+ if (out.length >= MAX_INLINE_IMAGES_PER_MESSAGE)
57
+ break;
58
+ }
59
+ return out;
60
+ }
61
+ /** Best-effort plain text for an assistant RuntimeMessage's `content`, which is
62
+ * either a plain string or an array of typed blocks (`{type:"text", text}`
63
+ * among others, e.g. tool_use/thinking). Only the text parts matter for
64
+ * finding markdown image references. */
65
+ export function assistantTextForImageScan(content) {
66
+ if (typeof content === "string")
67
+ return content;
68
+ if (!Array.isArray(content))
69
+ return "";
70
+ return content
71
+ .filter((part) => !!part && typeof part === "object" && String(part.type || "").toLowerCase() === "text")
72
+ .map((part) => String(part.text ?? ""))
73
+ .join("\n");
74
+ }
75
+ /** Env-configurable extra allowlist, comma-separated hostnames — same shape/
76
+ * naming convention as BIVY_ALLOWED_HOSTS (src/auth.ts). When set and
77
+ * non-empty, ONLY these hosts may be fetched (a stricter opt-in for locked-down
78
+ * deployments); unset means "any public host is fine" (the private/local-address
79
+ * block below still applies either way). */
80
+ function explicitAllowlist() {
81
+ return new Set((process.env.BIVY_INLINE_IMAGE_ALLOWED_HOSTS ?? "")
82
+ .split(",")
83
+ .map((h) => h.trim().toLowerCase())
84
+ .filter(Boolean));
85
+ }
86
+ export function isFetchImageError(value) {
87
+ return typeof value.error === "string";
88
+ }
89
+ async function defaultResolveHost(hostname) {
90
+ const results = await dns.lookup(hostname, { all: true });
91
+ return results.map((r) => r.address);
92
+ }
93
+ /**
94
+ * Reject anything but a public https host before it's ever requested: the
95
+ * literal hostname (an IP-literal `https://169.254.169.254/…` cloud-metadata
96
+ * URL, or an internal domain), AND every address it resolves to (a public-
97
+ * looking hostname an attacker points at an internal IP — classic DNS-rebinding
98
+ * SSRF). This runs again on every redirect hop in fetchInlineImage, exactly like
99
+ * execEphemeralRequest's per-hop host re-check.
100
+ *
101
+ * Known limitation: `fetch()` below does its own DNS resolution, which could in
102
+ * principle differ from what we just checked (a narrow TOCTOU window) — pinning
103
+ * the connection to the resolved address would need a custom dispatcher/Agent,
104
+ * which isn't worth the complexity here; this closes the overwhelming majority
105
+ * of real SSRF attempts (metadata endpoints, LAN scanning, loopback) the same
106
+ * way the rest of this codebase's SSRF guards do.
107
+ */
108
+ async function assertHostAllowed(url, resolveHost) {
109
+ if (url.protocol !== "https:")
110
+ throw new Error(`Refusing to fetch a non-https image URL (${url.protocol})`);
111
+ const hostname = url.hostname;
112
+ if (hostnameIsLocal(hostname))
113
+ throw new Error(`Refusing to fetch an image from a local/private host: ${hostname}`);
114
+ const allowlist = explicitAllowlist();
115
+ if (allowlist.size > 0 && !allowlist.has(hostname.toLowerCase())) {
116
+ throw new Error(`Host not in BIVY_INLINE_IMAGE_ALLOWED_HOSTS: ${hostname}`);
117
+ }
118
+ let addresses;
119
+ try {
120
+ addresses = await resolveHost(hostname);
121
+ }
122
+ catch (error) {
123
+ throw new Error(`Could not resolve host ${hostname}: ${error instanceof Error ? error.message : String(error)}`);
124
+ }
125
+ if (!addresses.length)
126
+ throw new Error(`Host ${hostname} did not resolve to any address`);
127
+ for (const address of addresses) {
128
+ if (hostnameIsLocal(address))
129
+ throw new Error(`Refusing to fetch an image — ${hostname} resolves to a private/local address`);
130
+ }
131
+ }
132
+ /**
133
+ * Fetch a single remote image, guarded against SSRF (private/local hosts,
134
+ * DNS-rebinding, unvalidated redirects), unbounded size, and non-image
135
+ * responses. Returns the bytes + a validated mime type, or a human-readable
136
+ * error — never throws (a bad/malicious URL must not crash the caller).
137
+ */
138
+ export async function fetchInlineImage(rawUrl, opts = {}) {
139
+ const fetchImpl = opts.fetchImpl ?? fetch;
140
+ const resolveHost = opts.resolveHost ?? defaultResolveHost;
141
+ const maxBytes = opts.maxBytes ?? MAX_INLINE_IMAGE_BYTES;
142
+ const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
143
+ let url;
144
+ try {
145
+ url = new URL(rawUrl);
146
+ }
147
+ catch {
148
+ return { error: `Invalid image URL: ${rawUrl}` };
149
+ }
150
+ const controller = new AbortController();
151
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
152
+ try {
153
+ for (let hop = 0;; hop++) {
154
+ try {
155
+ await assertHostAllowed(url, resolveHost);
156
+ }
157
+ catch (error) {
158
+ return { error: error instanceof Error ? error.message : String(error) };
159
+ }
160
+ let res;
161
+ try {
162
+ res = await fetchImpl(url.toString(), { signal: controller.signal, redirect: "manual", headers: { accept: "image/*" } });
163
+ }
164
+ catch (error) {
165
+ return { error: `Fetching image failed: ${error instanceof Error ? error.message : String(error)}` };
166
+ }
167
+ if (res.status >= 300 && res.status < 400 && res.status !== 304) {
168
+ if (hop >= MAX_REDIRECTS)
169
+ return { error: `Too many redirects fetching image: ${rawUrl}` };
170
+ const location = res.headers.get("location");
171
+ if (!location)
172
+ return { error: `Redirect (${res.status}) had no Location header` };
173
+ try {
174
+ url = new URL(location, url);
175
+ }
176
+ catch {
177
+ return { error: `Redirect target was not a valid URL: ${location}` };
178
+ }
179
+ continue; // next hop re-validates the new host before requesting it
180
+ }
181
+ if (!res.ok)
182
+ return { error: `Image fetch failed: HTTP ${res.status}` };
183
+ const contentLength = Number(res.headers.get("content-length") || "0");
184
+ if (contentLength > 0 && contentLength > maxBytes) {
185
+ return { error: `Image too large (${contentLength} bytes; limit ${maxBytes})` };
186
+ }
187
+ if (!res.body)
188
+ return { error: "Image response had no body" };
189
+ const contentType = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
190
+ const reader = res.body.getReader();
191
+ const chunks = [];
192
+ let total = 0;
193
+ for (;;) {
194
+ const { done, value } = await reader.read();
195
+ if (done)
196
+ break;
197
+ if (!value)
198
+ continue;
199
+ total += value.byteLength;
200
+ if (total > maxBytes) {
201
+ try {
202
+ await reader.cancel();
203
+ }
204
+ catch {
205
+ // best-effort — we're already erroring out
206
+ }
207
+ return { error: `Image exceeded the ${maxBytes}-byte limit` };
208
+ }
209
+ chunks.push(value);
210
+ }
211
+ const bytes = Buffer.concat(chunks.map((c) => Buffer.from(c)));
212
+ if (!bytes.length)
213
+ return { error: "Image response was empty" };
214
+ // Trust magic bytes over the (spoofable) Content-Type header when they
215
+ // disagree; fall back to the header only when sniffing is inconclusive
216
+ // (e.g. an SVG, which sniffMime doesn't recognize).
217
+ const sniffed = sniffMime(bytes);
218
+ const mimeType = sniffed || (contentType.startsWith("image/") ? contentType : "");
219
+ if (!mimeType)
220
+ return { error: "Response does not look like an image" };
221
+ return { bytes, mimeType };
222
+ }
223
+ }
224
+ finally {
225
+ clearTimeout(timeout);
226
+ }
227
+ }
228
+ /** A display name for a fetched inline image's AttachmentStore entry: the URL's
229
+ * last path segment when it looks like a filename, else a generic name derived
230
+ * from the resolved mime type. */
231
+ export function inlineImageDisplayName(url, mimeType) {
232
+ try {
233
+ const pathname = new URL(url).pathname;
234
+ const base = pathname.split("/").filter(Boolean).pop();
235
+ if (base)
236
+ return sanitizeAttachmentName(decodeURIComponent(base));
237
+ }
238
+ catch {
239
+ // fall through to the generic name below
240
+ }
241
+ const ext = mimeType.split("/")[1]?.split("+")[0] || "png";
242
+ return `inline-image.${ext}`;
243
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.5.1-staging.74",
3
+ "version": "0.5.1-staging.76",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",