@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 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 /\b401\b|unauthorized|authentication|invalid x-api-key|(missing|invalid).*(bearer|api[\s_-]?key|token)/i.test(raw);
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 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>`. */
@@ -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
- refreshSkewMs: 0,
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
  },
@@ -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
+ * (`![alt](https://…)`) 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
+ // (`![alt](https://…)`) — 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
+ // (`![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.75",
3
+ "version": "0.5.1-staging.77",
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.",