@bivy/bivy 0.5.1-staging.75 → 0.5.1-staging.77
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 +6 -1
- package/dist/runtime/anthropic-preflight.js +2 -1
- package/dist/runtime/auth-errors.js +71 -0
- package/dist/runtime/claude-code.js +13 -4
- package/dist/runtime/index.js +10 -0
- package/dist/runtime/oauth/model-oauth-providers.js +3 -1
- package/dist/runtime/protocol.js +43 -1
- package/dist/server.js +111 -0
- package/dist/session/event-log.js +45 -1
- package/dist/session/inline-image-fetch.js +243 -0
- package/package.json +1 -1
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;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
|
+
import { isModelAuthError } from "./auth-errors.js";
|
|
18
19
|
/** Candidate paths for the `claude` CLI's own stored login (`.credentials.json`). */
|
|
19
20
|
export function claudeCredentialFiles(deps = {}) {
|
|
20
21
|
const home = deps.home ?? os.homedir();
|
|
@@ -68,7 +69,7 @@ export function anthropicCredentialPreflight(env, deps = {}) {
|
|
|
68
69
|
}
|
|
69
70
|
/** True when a raw error string looks like an Anthropic auth failure (401 etc.). */
|
|
70
71
|
export function isAnthropicAuthError(raw) {
|
|
71
|
-
return
|
|
72
|
+
return isModelAuthError(raw);
|
|
72
73
|
}
|
|
73
74
|
/**
|
|
74
75
|
* Phrase an SDK error for the user: an auth failure gets the sign-in guidance
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Classify model auth failures and map a failing session to the provider the
|
|
4
|
+
// user needs to (re)authenticate.
|
|
5
|
+
//
|
|
6
|
+
// When a runtime has no usable model credential — or a present-but-expired one —
|
|
7
|
+
// its first upstream request fails with a 401. Codex surfaces this as
|
|
8
|
+
// `failed to connect to websocket: HTTP error: 401 Unauthorized, url:
|
|
9
|
+
// wss://api.openai.com/v1/responses`; Anthropic as `401 Unauthorized`; others
|
|
10
|
+
// similarly. Rather than let that stream past as a raw error, the daemon runs
|
|
11
|
+
// `isModelAuthError` over surfaced errors and, when one matches, broadcasts a
|
|
12
|
+
// `session.auth_required` targeted at `authProviderForSession(...)` so the client
|
|
13
|
+
// can pop the "Sign in to your model" sheet for the right provider.
|
|
14
|
+
import { MODEL_OAUTH_PROVIDERS } from "./oauth/model-oauth-providers.js";
|
|
15
|
+
/**
|
|
16
|
+
* True when a raw error string looks like a model auth failure (401 / missing
|
|
17
|
+
* bearer / invalid key). Covers both the generic SDK phrasing and Codex's
|
|
18
|
+
* websocket-connect form.
|
|
19
|
+
*/
|
|
20
|
+
export function isModelAuthError(raw) {
|
|
21
|
+
const text = String(raw || "");
|
|
22
|
+
// Generic: an explicit 401, "unauthorized"/"authentication", or a
|
|
23
|
+
// missing/invalid bearer/api-key/token phrase.
|
|
24
|
+
if (/\b401\b|unauthorized|authentication|invalid x-api-key|(missing|invalid)[\s\S]*(bearer|api[\s_-]?key|token)/i.test(text))
|
|
25
|
+
return true;
|
|
26
|
+
// Codex app-server: websocket connect rejected with an HTTP 401/403.
|
|
27
|
+
if (/failed to connect to websocket[\s\S]*http error:\s*40[13]/i.test(text))
|
|
28
|
+
return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
// Provider ids the app knows how to authenticate (OAuth subscription providers
|
|
32
|
+
// plus any provider that has a conventional API-key env var). Used to validate
|
|
33
|
+
// a model provider before signalling the client to sign in for it.
|
|
34
|
+
const KNOWN_KEY_PROVIDERS = new Set([
|
|
35
|
+
"anthropic",
|
|
36
|
+
"openai",
|
|
37
|
+
"openrouter",
|
|
38
|
+
"google",
|
|
39
|
+
"gemini",
|
|
40
|
+
"groq",
|
|
41
|
+
"mistral",
|
|
42
|
+
"deepseek",
|
|
43
|
+
"xai",
|
|
44
|
+
"together",
|
|
45
|
+
"fireworks",
|
|
46
|
+
"cohere",
|
|
47
|
+
"perplexity",
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Resolve which credential provider the user should sign in for, given the
|
|
51
|
+
* failing runtime id and (optionally) its active model provider. Returns
|
|
52
|
+
* undefined when we can't confidently name a provider — in that case the caller
|
|
53
|
+
* should not raise the sign-in sheet.
|
|
54
|
+
*
|
|
55
|
+
* Codex runtimes (`codex`, `codex-approvals`) are served by the ChatGPT
|
|
56
|
+
* subscription, whose vault/provider id is `openai-codex`; that's the id the
|
|
57
|
+
* "Sign in with OpenAI" OAuth button targets.
|
|
58
|
+
*/
|
|
59
|
+
export function authProviderForSession(runtimeId, modelProvider) {
|
|
60
|
+
const id = String(runtimeId || "").trim().toLowerCase();
|
|
61
|
+
if (id.startsWith("codex"))
|
|
62
|
+
return "openai-codex";
|
|
63
|
+
const provider = String(modelProvider || "").trim().toLowerCase();
|
|
64
|
+
if (!provider)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (Object.prototype.hasOwnProperty.call(MODEL_OAUTH_PROVIDERS, provider))
|
|
67
|
+
return provider;
|
|
68
|
+
if (KNOWN_KEY_PROVIDERS.has(provider))
|
|
69
|
+
return provider;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -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
|
|
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 
|
|
76
|
-
"use `bivy attach
|
|
82
|
+
"workspace. Do NOT use markdown image syntax like  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
|
+
"`` 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/runtime/index.js
CHANGED
|
@@ -1071,6 +1071,16 @@ function codexAppServerRuntime(credsDir, tier) {
|
|
|
1071
1071
|
args: [shim],
|
|
1072
1072
|
...(policy ? { env: { BIVY_CODEX_SANDBOX: policy.sandbox, BIVY_CODEX_APPROVAL_POLICY: policy.approvalPolicy } } : {}),
|
|
1073
1073
|
credentials: createCredentialStore(credsDir),
|
|
1074
|
+
// Mint ~/.codex/auth.json from the vault before the app-server spawns, then
|
|
1075
|
+
// preflight — mirroring the `codex` exec path (see below). Without this the
|
|
1076
|
+
// shim would launch uncredentialed and 401 on its first /responses call with
|
|
1077
|
+
// no actionable message. `prepare` runs pre-spawn (the shim reads auth.json at
|
|
1078
|
+
// launch); `preflight` backstops the genuinely uncredentialed case.
|
|
1079
|
+
prepare: async () => {
|
|
1080
|
+
const home = await ensureCodexAuth(credsDir);
|
|
1081
|
+
return home ? { CODEX_HOME: home } : {};
|
|
1082
|
+
},
|
|
1083
|
+
preflight: (env) => codexCredentialPreflight(env),
|
|
1074
1084
|
// Session-less catalog contribution: Codex runs OpenAI models under a ChatGPT
|
|
1075
1085
|
// subscription (provider id "openai-codex"). The authoritative per-session
|
|
1076
1086
|
// list comes from the app-server; this is the picker preview.
|
|
@@ -43,7 +43,9 @@ export const MODEL_OAUTH_PROVIDERS = {
|
|
|
43
43
|
codex_cli_simplified_flow: "true",
|
|
44
44
|
originator: "pi",
|
|
45
45
|
},
|
|
46
|
-
|
|
46
|
+
// Refresh a little early (like Anthropic/xAI) so Codex never bakes an
|
|
47
|
+
// already-expired access token into ~/.codex/auth.json and 401s mid-turn.
|
|
48
|
+
refreshSkewMs: 5 * 60 * 1000,
|
|
47
49
|
refreshRotates: true,
|
|
48
50
|
accountIdClaim: { path: "https://api.openai.com/auth", field: "chatgpt_account_id" },
|
|
49
51
|
},
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -212,6 +212,9 @@ class ProtocolSession {
|
|
|
212
212
|
currentModelId;
|
|
213
213
|
/** Provider of the selected model — scopes custom base-URL env injection. */
|
|
214
214
|
currentModelProvider;
|
|
215
|
+
/** Env patch from the last `prepare` run (e.g. Codex's minted CODEX_HOME),
|
|
216
|
+
* applied to the spawned child and reused by the per-turn preflight. */
|
|
217
|
+
prepareEnv = {};
|
|
215
218
|
getModels() { return this.models; }
|
|
216
219
|
getCurrentModel() {
|
|
217
220
|
if (!this.currentModelId)
|
|
@@ -257,12 +260,19 @@ class ProtocolSession {
|
|
|
257
260
|
const credentialEnv = this.runtimeOptions.credentials
|
|
258
261
|
? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
|
|
259
262
|
: {};
|
|
263
|
+
// Optional prepare step, run before the child spawns because a shim reads its
|
|
264
|
+
// credential at launch (e.g. Codex mints ~/.codex/auth.json from the vault and
|
|
265
|
+
// pins CODEX_HOME). Stored so the per-turn preflight sees the same env. Best-
|
|
266
|
+
// effort: a throw is swallowed and treated as no patch.
|
|
267
|
+
this.prepareEnv = this.runtimeOptions.prepare
|
|
268
|
+
? (await Promise.resolve(this.runtimeOptions.prepare({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv })).catch(() => undefined)) ?? {}
|
|
269
|
+
: {};
|
|
260
270
|
const child = spawn(this.runtimeOptions.command, this.runtimeOptions.args ?? [], {
|
|
261
271
|
cwd: this.cwd,
|
|
262
272
|
// bivySessionEnv() lets the agent's own shell resolve its session for
|
|
263
273
|
// `bivy attach <path>` (see session-env.ts); spread last so it can never
|
|
264
274
|
// be shadowed by an operator-configured env var of the same name.
|
|
265
|
-
env: { ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...bivySessionEnv(this.id) },
|
|
275
|
+
env: { ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...this.prepareEnv, ...bivySessionEnv(this.id) },
|
|
266
276
|
stdio: "pipe",
|
|
267
277
|
});
|
|
268
278
|
this.child = child;
|
|
@@ -503,6 +513,38 @@ class ProtocolSession {
|
|
|
503
513
|
async prompt(text, options) {
|
|
504
514
|
const wasStarted = this.started;
|
|
505
515
|
await this.open();
|
|
516
|
+
// Per-turn prepare + credential preflight, mirroring ProcessRuntime. Unlike a
|
|
517
|
+
// fresh-process runtime, the protocol child is long-lived, so a credential
|
|
518
|
+
// connected AFTER it spawned (a mid-session sign-in from the "Sign in to your
|
|
519
|
+
// model" sheet) would never be materialized by start()'s one-shot prepare.
|
|
520
|
+
// Re-run prepare here so e.g. Codex mints ~/.codex/auth.json from the just-
|
|
521
|
+
// completed sign-in before this turn — the app-server reads the default auth
|
|
522
|
+
// file, so it recovers on the next prompt instead of staying stuck on the
|
|
523
|
+
// initial 401. Then preflight backstops the genuinely uncredentialed case with
|
|
524
|
+
// an actionable error instead of an opaque upstream 401. ensureCodexAuth is
|
|
525
|
+
// idempotent (it no-ops once auth.json exists), so the repeat is cheap.
|
|
526
|
+
if (this.runtimeOptions.prepare || this.runtimeOptions.preflight) {
|
|
527
|
+
const credentialEnv = this.runtimeOptions.credentials
|
|
528
|
+
? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
|
|
529
|
+
: {};
|
|
530
|
+
if (this.runtimeOptions.prepare) {
|
|
531
|
+
this.prepareEnv =
|
|
532
|
+
(await Promise.resolve(this.runtimeOptions.prepare({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv })).catch(() => undefined)) ??
|
|
533
|
+
this.prepareEnv;
|
|
534
|
+
}
|
|
535
|
+
const preflightError = this.runtimeOptions.preflight?.({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...this.prepareEnv }, { provider: this.currentModelProvider });
|
|
536
|
+
if (preflightError) {
|
|
537
|
+
this.streaming = false;
|
|
538
|
+
const message = { role: "assistant", content: "", errorMessage: preflightError };
|
|
539
|
+
this.messages.push(message);
|
|
540
|
+
this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
|
|
541
|
+
this.emit({ type: "session.error", error: preflightError });
|
|
542
|
+
this.emit({ type: "message_end", message });
|
|
543
|
+
this.emit({ type: "turn_end" });
|
|
544
|
+
this.emit({ type: "agent_end", code: 1, signal: null });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
506
548
|
if (!wasStarted)
|
|
507
549
|
this.emit({ type: "agent_start" });
|
|
508
550
|
const prompt = text.trim();
|
package/dist/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { InMemoryLocationRegistry } from "./runtime/location-registry.js";
|
|
|
22
22
|
import { ControlPlaneSessionLocationRegistry, LayeredSessionLocationRegistry } from "./runtime/control-plane-location.js";
|
|
23
23
|
import { attachAdoptedSessions, classifyAttachFailure } from "./runtime/adoption.js";
|
|
24
24
|
import { createCredentialStore } from "./runtime/credentials.js";
|
|
25
|
+
import { isModelAuthError, authProviderForSession } from "./runtime/auth-errors.js";
|
|
25
26
|
import { createCredentialVault, migrateVaultDir } from "./runtime/credential-store.js";
|
|
26
27
|
import { provisionAgentRun } from "./runtime/credential-provisioning.js";
|
|
27
28
|
import { ingestAgentCredentials } from "./runtime/credential-ingest.js";
|
|
@@ -74,6 +75,7 @@ import { buildNativeImportSeedPrompt } from "./session/native-import.js";
|
|
|
74
75
|
import { EventLog } from "./session/event-log.js";
|
|
75
76
|
import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
|
|
76
77
|
import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
|
|
78
|
+
import { extractInlineImageUrls, assistantTextForImageScan, fetchInlineImage, isFetchImageError, inlineImageDisplayName, } from "./session/inline-image-fetch.js";
|
|
77
79
|
import { ReplicationService } from "./session/replication-service.js";
|
|
78
80
|
import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
|
|
79
81
|
import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
|
|
@@ -2322,6 +2324,70 @@ function persistTranscriptSnapshot(record) {
|
|
|
2322
2324
|
return;
|
|
2323
2325
|
eventLog.appendBaseSnapshot(record.id, base);
|
|
2324
2326
|
}
|
|
2327
|
+
// In-flight dedupe so two sessions (or two turns) referencing the same remote
|
|
2328
|
+
// image URL only ever trigger one outbound fetch. Process-lifetime only — a
|
|
2329
|
+
// restart just means the first re-encounter fetches again, which is fine.
|
|
2330
|
+
const inlineImageFetchInFlight = new Map();
|
|
2331
|
+
// A URL that failed (bad host, timeout, not-an-image, …) is not retried for a
|
|
2332
|
+
// cooldown window, so a persistently broken URL in a long-lived session can't
|
|
2333
|
+
// turn every subsequent message_end into a wasted fetch attempt.
|
|
2334
|
+
const inlineImageFailedAt = new Map();
|
|
2335
|
+
const INLINE_IMAGE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
|
|
2336
|
+
/**
|
|
2337
|
+
* Scan a session's just-finalized assistant messages for remote markdown images
|
|
2338
|
+
* (``) and, for any URL not already resolved or in flight,
|
|
2339
|
+
* fetch it (SSRF-guarded, size-capped — see inline-image-fetch.ts), store the
|
|
2340
|
+
* bytes in the content-addressed AttachmentStore, persist the durable url→ref
|
|
2341
|
+
* mapping, and broadcast it live so an already-open chat hydrates the image
|
|
2342
|
+
* without waiting for a reload. Fire-and-forget: called from the session event
|
|
2343
|
+
* listener, which must not block on a network fetch.
|
|
2344
|
+
*/
|
|
2345
|
+
function resolveInlineImages(record) {
|
|
2346
|
+
const messages = record.session.getMessages();
|
|
2347
|
+
const urls = new Set();
|
|
2348
|
+
for (const m of messages) {
|
|
2349
|
+
if (m.role !== "assistant")
|
|
2350
|
+
continue;
|
|
2351
|
+
for (const url of extractInlineImageUrls(assistantTextForImageScan(m.content)))
|
|
2352
|
+
urls.add(url);
|
|
2353
|
+
}
|
|
2354
|
+
if (!urls.size)
|
|
2355
|
+
return;
|
|
2356
|
+
const alreadyResolved = new Set(eventLog.readInlineImages(record.id).map(([url]) => url));
|
|
2357
|
+
for (const url of urls) {
|
|
2358
|
+
if (alreadyResolved.has(url) || inlineImageFetchInFlight.has(url))
|
|
2359
|
+
continue;
|
|
2360
|
+
const failedAt = inlineImageFailedAt.get(url);
|
|
2361
|
+
if (failedAt !== undefined && Date.now() - failedAt < INLINE_IMAGE_RETRY_COOLDOWN_MS)
|
|
2362
|
+
continue;
|
|
2363
|
+
const task = (async () => {
|
|
2364
|
+
try {
|
|
2365
|
+
const result = await fetchInlineImage(url);
|
|
2366
|
+
if (isFetchImageError(result)) {
|
|
2367
|
+
console.warn(`[inline-image] ${url}: ${result.error}`);
|
|
2368
|
+
inlineImageFailedAt.set(url, Date.now());
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
const ref = attachmentStore.put(result.bytes, {
|
|
2372
|
+
name: inlineImageDisplayName(url, result.mimeType),
|
|
2373
|
+
mimeType: result.mimeType,
|
|
2374
|
+
kind: "image",
|
|
2375
|
+
});
|
|
2376
|
+
eventLog.appendInlineImage(record.id, { url, ref });
|
|
2377
|
+
eventLog.flush(record.id);
|
|
2378
|
+
broadcast({ type: "session.event", sessionId: record.id, event: { type: "inlineImage", url, ref } });
|
|
2379
|
+
}
|
|
2380
|
+
catch (error) {
|
|
2381
|
+
console.warn(`[inline-image] ${url}:`, error instanceof Error ? error.message : String(error));
|
|
2382
|
+
inlineImageFailedAt.set(url, Date.now());
|
|
2383
|
+
}
|
|
2384
|
+
finally {
|
|
2385
|
+
inlineImageFetchInFlight.delete(url);
|
|
2386
|
+
}
|
|
2387
|
+
})();
|
|
2388
|
+
inlineImageFetchInFlight.set(url, task);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2325
2391
|
// Append the tool-activity entry to the log. The id-merge and last-500 cap the old
|
|
2326
2392
|
// store applied are now applied by `foldTool` on replay.
|
|
2327
2393
|
function upsertToolActivityMessage(sessionId, entry) {
|
|
@@ -2445,6 +2511,11 @@ function buildHistoryEvent(opts) {
|
|
|
2445
2511
|
// Durable attachment references (text→refs), so a client that never sent the
|
|
2446
2512
|
// attachment (a reload, or a different device) rehydrates thumbnails by hash.
|
|
2447
2513
|
attachmentRefs: opts.sessionId ? eventLog.readAttachments(opts.sessionId) : [],
|
|
2514
|
+
// Durable url→ref map for remote markdown images the node has already
|
|
2515
|
+
// fetched (see resolveInlineImages) — lets a reload resolve a
|
|
2516
|
+
// `data-remote-src` placeholder straight to its attachment hash instead of
|
|
2517
|
+
// waiting on a fresh (redundant) fetch.
|
|
2518
|
+
inlineImageRefs: opts.sessionId ? eventLog.readInlineImages(opts.sessionId) : [],
|
|
2448
2519
|
};
|
|
2449
2520
|
}
|
|
2450
2521
|
// Idempotency for `session.new` keyed by requestId: a client's post-reconnect
|
|
@@ -6724,6 +6795,24 @@ function terminalTurnError(event) {
|
|
|
6724
6795
|
}
|
|
6725
6796
|
return undefined;
|
|
6726
6797
|
}
|
|
6798
|
+
/**
|
|
6799
|
+
* When a surfaced error looks like a model auth failure (no credential, or an
|
|
6800
|
+
* expired/invalid one that 401'd upstream), tell the client which provider to
|
|
6801
|
+
* (re)authenticate so it can pop the "Sign in to your model" sheet instead of
|
|
6802
|
+
* leaving a bare error bubble. Fires at most once per turn (reset on turn_start)
|
|
6803
|
+
* so a retry storm — e.g. Codex's repeated websocket 401s — raises the sheet once.
|
|
6804
|
+
*/
|
|
6805
|
+
function maybeSignalAuthRequired(record, errorText) {
|
|
6806
|
+
if (record.authRequiredSignaled)
|
|
6807
|
+
return;
|
|
6808
|
+
if (!isModelAuthError(errorText))
|
|
6809
|
+
return;
|
|
6810
|
+
const provider = authProviderForSession(record.runtimeId, record.session.getCurrentModel()?.provider);
|
|
6811
|
+
if (!provider)
|
|
6812
|
+
return;
|
|
6813
|
+
record.authRequiredSignaled = true;
|
|
6814
|
+
broadcast({ type: "session.auth_required", sessionId: record.id, provider, reason: errorText.slice(0, 400) });
|
|
6815
|
+
}
|
|
6727
6816
|
function attachSessionListeners(record) {
|
|
6728
6817
|
record.unsubscribe?.();
|
|
6729
6818
|
// In-session model reroute controller (inert unless BIVY_SESSION_MODEL_FALLBACK
|
|
@@ -6773,6 +6862,10 @@ function attachSessionListeners(record) {
|
|
|
6773
6862
|
].includes(event.type)) {
|
|
6774
6863
|
markSessionWorking(record, event);
|
|
6775
6864
|
}
|
|
6865
|
+
// A fresh turn re-arms the once-per-turn auth-required signal, so a credential
|
|
6866
|
+
// that was fixed (or newly broke) is re-evaluated on the next prompt.
|
|
6867
|
+
if (event.type === "turn_start")
|
|
6868
|
+
record.authRequiredSignaled = false;
|
|
6776
6869
|
if (event.type === "message_update" && event.message && (event.message?.role === "assistant")) {
|
|
6777
6870
|
persistIntermediateFromEvent(record, event, false);
|
|
6778
6871
|
}
|
|
@@ -6786,6 +6879,15 @@ function attachSessionListeners(record) {
|
|
|
6786
6879
|
if (event.type === "turn_start" || event.type === "message_end" || event.type === "turn_end") {
|
|
6787
6880
|
persistTranscriptSnapshot(record);
|
|
6788
6881
|
}
|
|
6882
|
+
// A finalized assistant message may reference a remote image via markdown
|
|
6883
|
+
// (``) — fetch and store it now so the chat can render it
|
|
6884
|
+
// (see resolveInlineImages). Checked on both events: message_end is the
|
|
6885
|
+
// precise "this assistant message is done" signal most runtimes emit, but
|
|
6886
|
+
// turn_end is a safety net for one that only surfaces the final text there.
|
|
6887
|
+
// Fire-and-forget and internally deduped, so checking on both costs nothing.
|
|
6888
|
+
if (event.type === "message_end" || event.type === "turn_end") {
|
|
6889
|
+
resolveInlineImages(record);
|
|
6890
|
+
}
|
|
6789
6891
|
// Durably persist the throttled sidecars at the turn boundary so a crash
|
|
6790
6892
|
// loses at most the in-flight turn's UI detail, not the whole turn.
|
|
6791
6893
|
if (event.type === "turn_end")
|
|
@@ -6803,6 +6905,12 @@ function attachSessionListeners(record) {
|
|
|
6803
6905
|
const e = event;
|
|
6804
6906
|
broadcast({ type: "session.notice", sessionId: record.id, level: e.level ?? "info", message: String(e.message ?? ""), ...(e.action ? { action: e.action } : {}) });
|
|
6805
6907
|
}
|
|
6908
|
+
if (event.type === "session.error") {
|
|
6909
|
+
// A runtime-emitted auth failure (Codex's app-server websocket 401, or a
|
|
6910
|
+
// ProcessRuntime/ProtocolRuntime credential preflight) — raise the sign-in
|
|
6911
|
+
// sheet for the right provider alongside the inline error bubble.
|
|
6912
|
+
maybeSignalAuthRequired(record, String(event.error ?? ""));
|
|
6913
|
+
}
|
|
6806
6914
|
if (event.type === "runtime.commands") {
|
|
6807
6915
|
// The agent learned its own slash commands mid-session (e.g. Claude Code's
|
|
6808
6916
|
// system/init reports slash_commands only after the first turn starts).
|
|
@@ -6851,6 +6959,9 @@ function attachSessionListeners(record) {
|
|
|
6851
6959
|
metadata.touchSession(record.id, "failed");
|
|
6852
6960
|
scheduleAdvertise();
|
|
6853
6961
|
broadcast({ type: "session.error", sessionId: record.id, error: turnError });
|
|
6962
|
+
// If the terminal error is an auth failure (expired key/token → 4xx),
|
|
6963
|
+
// also raise the sign-in sheet for the failing provider.
|
|
6964
|
+
maybeSignalAuthRequired(record, turnError);
|
|
6854
6965
|
void sendNotificationHint({
|
|
6855
6966
|
kind: "session_error",
|
|
6856
6967
|
sessionId: record.id,
|
|
@@ -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
|
+
// (``), 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, `` — 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
|
+
* ``, 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