From 89f9997248ec5d9dd8b7efc89caecf010590076a Mon Sep 17 00:00:00 2001 From: Season Saw Date: Sat, 22 Aug 2026 22:35:07 +0800 Subject: [PATCH 1/5] fix(ai-sandbox-cloudflare): verify preview tunnels before returning URLs exposePreview returned sandbox.tunnels.get(port).url unchecked, so a stale cached tunnel record was re-shared as a dead URL with no error signal. Probe the port inside the sandbox first (actionable error when nothing listens), verify the tunnel URL through the edge with a propagation-aware retry window, and replace the tunnel once when the local server is healthy but the edge keeps answering 502/530. Fixes #992 --- .changeset/verify-preview-tunnels.md | 5 + .../ai-sandbox-cloudflare/src/preview-tool.ts | 99 +++++++++++- .../tests/preview-tool.test.ts | 152 +++++++++++++++--- 3 files changed, 233 insertions(+), 23 deletions(-) create mode 100644 .changeset/verify-preview-tunnels.md diff --git a/.changeset/verify-preview-tunnels.md b/.changeset/verify-preview-tunnels.md new file mode 100644 index 0000000000..c76f46554b --- /dev/null +++ b/.changeset/verify-preview-tunnels.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-cloudflare': patch +--- + +`exposePreview` now verifies the preview URL is actually reachable before returning it: it fails with an actionable error when nothing is listening on the port, and detects and replaces stale quick tunnels instead of re-sharing dead URLs (#992). diff --git a/packages/ai-sandbox-cloudflare/src/preview-tool.ts b/packages/ai-sandbox-cloudflare/src/preview-tool.ts index 359049b751..9f10198f1b 100644 --- a/packages/ai-sandbox-cloudflare/src/preview-tool.ts +++ b/packages/ai-sandbox-cloudflare/src/preview-tool.ts @@ -68,6 +68,80 @@ export const PREVIEW_GUIDANCE: string = [ 'Once it is listening, call `exposePreview` with that port, then share the URL.', ].join('\n') +const LOCAL_PROBE_TIMEOUT_MS = 5_000 +// Fresh quick tunnels need a few seconds of DNS/edge propagation, hence retries. +const EDGE_PROBE_ATTEMPTS = 5 +const EDGE_PROBE_BASE_DELAY_MS = 250 +const EDGE_PROBE_FETCH_TIMEOUT_MS = 3_000 + +/** + * Probe the port INSIDE the sandbox via `containerFetch`. Returns the HTTP + * status when a listener answered (any response — 4xx/5xx included — proves one + * exists), or the failure symptom (a string) when nothing did. + */ +async function localProbe( + sandbox: Sandbox, + port: number, +): Promise { + // ponytail: race instead of AbortSignal — a signal doesn't serialize across the + // sandbox RPC boundary, and a lost in-flight probe response is harmless. + const timeout = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)), + LOCAL_PROBE_TIMEOUT_MS, + ), + ) + try { + const res = await Promise.race([ + sandbox.containerFetch('http://preview/', { method: 'HEAD' }, port), + timeout, + ]) + return res.status + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + +/** + * Probe a tunnel URL through the public edge with bounded retries. Only 502/530 + * — Cloudflare's tunnel/origin-unreachable signatures — mark the URL as + * unreachable (401/403/404 prove the server answered, and `redirect: 'manual'` + * keeps a login redirect from probing some other site), and even those are + * trusted when the app answered the SAME status locally, so an app's own + * 502/530 never gets its healthy tunnel destroyed. Success is `null`; repeated + * failure returns the last symptom. + */ +async function edgeProbeFailure( + url: string, + localStatus: number, +): Promise { + let lastFailure = 'no response' + for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => + setTimeout(resolve, EDGE_PROBE_BASE_DELAY_MS * 2 ** (attempt - 1)), + ) + } + try { + const res = await fetch(url, { + method: 'HEAD', + redirect: 'manual', + signal: AbortSignal.timeout(EDGE_PROBE_FETCH_TIMEOUT_MS), + }) + if ( + (res.status !== 502 && res.status !== 530) || + res.status === localStatus + ) { + return null + } + lastFailure = `HTTP ${res.status}` + } catch (error) { + lastFailure = error instanceof Error ? error.message : String(error) + } + } + return lastFailure +} + /** * Build the `exposePreview` server tool for one run. Starting a tunnel is a * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from @@ -100,11 +174,34 @@ export function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) { const sandbox = getSandbox(env.Sandbox, input.threadId, { transport: 'rpc', }) + // Gate tunnel work on a live listener: a fresh tunnel to a dead port is still + // a dead preview, and the failure the agent can FIX is "start the server". + const local = await localProbe(sandbox, port) + if (typeof local === 'string') { + throw new Error( + `No server is listening on port ${port} inside the sandbox (${local}). Start the dev server (bound to 0.0.0.0:${port}) first, then retry exposePreview.`, + ) + } // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite // can't hijack the preview's asset requests) and needs no custom domain on a // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API. const tunnel = await sandbox.tunnels.get(port) - return { url: tunnel.url } + const staleSymptom = await edgeProbeFailure(tunnel.url, local) + if (staleSymptom === null) return { url: tunnel.url } + // Local server healthy but the edge kept answering 502/530: the cached tunnel + // record is suspect. Refresh, bounded to ONE so we never churn tunnels. + await sandbox.tunnels.destroy(port) + const fresh = await sandbox.tunnels.get(port) + const freshSymptom = await edgeProbeFailure(fresh.url, local) + if (freshSymptom === null) { + return { + url: fresh.url, + note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`, + } + } + throw new Error( + `Port ${port} is serving inside the sandbox, but its preview tunnel never became reachable (old tunnel: ${staleSymptom}; replacement tunnel: ${freshSymptom}). Retry exposePreview, and if it keeps failing, restart the dev server and try again.`, + ) }) } diff --git a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts index 64fd9e2613..c03cfdf50d 100644 --- a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts +++ b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts @@ -1,11 +1,12 @@ /** * Deterministic tests for the browser-preview building blocks (no Workers - * runtime). `getSandbox` is module-mocked to a stub whose `tunnels.get` records - * its calls, so we can assert the tool opens a quick tunnel to the right port and - * returns its URL, and that `PREVIEW_GUIDANCE` carries the directives an agent - * needs (allow-all-hosts, non-3000 port, call exposePreview). + * runtime). `getSandbox` is module-mocked to a stub recording `containerFetch` + * (the in-container listener probe) and `tunnels.get`/`tunnels.destroy`, and the + * global `fetch` (the public edge probe) is stubbed, so the tool's + * verify-then-return contract is assertable without a sandbox. `PREVIEW_GUIDANCE` + * is asserted to carry the directives an agent needs. */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Sandbox } from '@cloudflare/sandbox' import type { StartRunInput } from '../src/coordinator' @@ -14,33 +15,67 @@ import type { StartRunInput } from '../src/coordinator' // lib's) — use it bare, the way the package's own modules do. // Hoisted so the `vi.mock` factory can close over the same spies the tests assert on. -const { tunnelGetMock, getSandboxMock } = vi.hoisted(() => { - const tunnelGetMock = vi.fn<(port: number) => Promise<{ url: string }>>() - return { - tunnelGetMock, - getSandboxMock: vi.fn(() => ({ tunnels: { get: tunnelGetMock } })), - } -}) +const { containerFetchMock, tunnelGetMock, tunnelDestroyMock, getSandboxMock } = + vi.hoisted(() => { + const containerFetchMock = + vi.fn< + (url: string, init: RequestInit, port: number) => Promise + >() + const tunnelGetMock = vi.fn<(port: number) => Promise<{ url: string }>>() + const tunnelDestroyMock = vi.fn<(port: number) => Promise>() + return { + containerFetchMock, + tunnelGetMock, + tunnelDestroyMock, + getSandboxMock: vi.fn(() => ({ + containerFetch: containerFetchMock, + tunnels: { get: tunnelGetMock, destroy: tunnelDestroyMock }, + })), + } + }) vi.mock('@cloudflare/sandbox', () => ({ getSandbox: getSandboxMock })) +// The edge probe goes through the global `fetch` (a plain Worker subrequest). +const edgeFetchMock = + vi.fn<(url: string, init?: RequestInit) => Promise>() +vi.stubGlobal('fetch', edgeFetchMock) + // Imported AFTER the mock is registered. const { PREVIEW_GUIDANCE, exposePreviewTool } = await import('../src/preview-tool') const SANDBOX = {} as unknown as DurableObjectNamespace +const OLD_URL = 'https://two-words-here.trycloudflare.com' +const NEW_URL = 'https://fresh-words-here.trycloudflare.com' -function runInput(overrides: Partial = {}): StartRunInput { - return { runId: 'r1', threadId: 'thread-x', messages: [], ...overrides } +const http = (status: number) => new Response(null, { status }) + +function makeTool() { + const input: StartRunInput = { + runId: 'r1', + threadId: 'thread-x', + messages: [], + } + return exposePreviewTool(input, { Sandbox: SANDBOX }) } -describe('exposePreviewTool', () => { - it('opens a quick tunnel on the run’s container for the given port', async () => { - tunnelGetMock.mockResolvedValue({ - url: 'https://two-words-here.trycloudflare.com', - }) - const tool = exposePreviewTool(runInput(), { Sandbox: SANDBOX }) +beforeEach(() => { + getSandboxMock.mockClear() + // mockReset drops leftover `mockResolvedValueOnce` queues, then happy-path + // defaults; individual tests override the failing leg. + containerFetchMock.mockReset().mockResolvedValue(http(200)) + tunnelGetMock.mockReset().mockResolvedValue({ url: OLD_URL }) + tunnelDestroyMock.mockReset().mockResolvedValue(undefined) + edgeFetchMock.mockReset().mockResolvedValue(http(200)) +}) + +afterEach(() => { + vi.useRealTimers() +}) - const result = await tool.execute?.({ port: 5173 }) +describe('exposePreviewTool', () => { + it('opens a quick tunnel on the run’s container and returns its URL once verified', async () => { + const result = await makeTool().execute?.({ port: 5173 }) // The run's container is addressed by threadId, over the RPC transport that // `sandbox.tunnels` requires; the tunnel targets the dev port. @@ -48,7 +83,80 @@ describe('exposePreviewTool', () => { transport: 'rpc', }) expect(tunnelGetMock).toHaveBeenCalledWith(5173) - expect(result).toEqual({ url: 'https://two-words-here.trycloudflare.com' }) + expect(result).toEqual({ url: OLD_URL }) + }) + + it('fails with an actionable error — and mints no tunnel — when nothing listens on the port', async () => { + vi.useFakeTimers() + // A probe that never settles: the tool's own timeout must bound it. + containerFetchMock.mockReturnValue(new Promise(() => {})) + + const promise = makeTool().execute?.({ port: 5173 }) + const assertion = expect(promise).rejects.toThrow(/listening on port 5173/) + await vi.runAllTimersAsync() + await assertion + + expect(tunnelGetMock).not.toHaveBeenCalled() + }) + + it('counts local 5xx as a live listener and edge 4xx / transient failures as reachable', async () => { + vi.useFakeTimers() + // An auth-protected app: 500 locally, and the edge needs one propagation + // retry before answering 403. None of that means the tunnel is stale. + containerFetchMock.mockResolvedValue(http(500)) + edgeFetchMock + .mockRejectedValueOnce(new Error('DNS not propagated')) + .mockResolvedValue(http(403)) + + const promise = makeTool().execute?.({ port: 5173 }) + await vi.runAllTimersAsync() + + await expect(promise).resolves.toEqual({ url: OLD_URL }) + expect(tunnelDestroyMock).not.toHaveBeenCalled() + }) + + it('does not replace the tunnel when the app itself answers 502 (same status locally and at the edge)', async () => { + containerFetchMock.mockResolvedValue(http(502)) + edgeFetchMock.mockResolvedValue(http(502)) + + const result = await makeTool().execute?.({ port: 5173 }) + + expect(result).toEqual({ url: OLD_URL }) + expect(tunnelDestroyMock).not.toHaveBeenCalled() + }) + + it('replaces a stale tunnel (local healthy, edge stuck on 502) and flags the old URL as dead', async () => { + vi.useFakeTimers() + tunnelGetMock + .mockResolvedValueOnce({ url: OLD_URL }) + .mockResolvedValueOnce({ url: NEW_URL }) + // The stale tunnel 502s no matter how often it's probed; the fresh one works. + edgeFetchMock.mockImplementation((url) => + Promise.resolve(http(url === NEW_URL ? 200 : 502)), + ) + + const promise = makeTool().execute?.({ port: 5173 }) + await vi.runAllTimersAsync() + + await expect(promise).resolves.toMatchObject({ + url: NEW_URL, + note: expect.stringMatching(/dead/), + }) + expect(tunnelDestroyMock).toHaveBeenCalledWith(5173) + }) + + it('errors instead of returning a URL when the replacement tunnel never becomes reachable', async () => { + vi.useFakeTimers() + edgeFetchMock.mockResolvedValue(http(502)) + + const promise = makeTool().execute?.({ port: 5173 }) + // Attach the rejection handler BEFORE running the timers, or the rejection + // fires unhandled while the fake clock advances. + const assertion = expect(promise).rejects.toThrow( + /Port 5173 is serving inside the sandbox/, + ) + await vi.runAllTimersAsync() + await assertion }) }) From d002a59384299de3217cfccfe64aaab659138201 Mon Sep 17 00:00:00 2001 From: Season Saw Date: Sat, 22 Aug 2026 22:49:34 +0800 Subject: [PATCH 2/5] fix(ai-sandbox-cloudflare): keep the tunnel when the edge probe cannot verify it Review feedback: a fetch exception (timeout, DNS, subrequest failure) is not evidence of a stale tunnel, so it must not trigger destroy and replace. The edge probe now reports stale only when a non-matching 502/530 response was actually observed; exception-only outcomes throw an actionable error and leave the tunnel in place. Also clear the local probe timeout timer once the race settles. --- .../ai-sandbox-cloudflare/src/preview-tool.ts | 38 +++++++++++++------ .../tests/preview-tool.test.ts | 13 +++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/ai-sandbox-cloudflare/src/preview-tool.ts b/packages/ai-sandbox-cloudflare/src/preview-tool.ts index 9f10198f1b..ceb43f6aab 100644 --- a/packages/ai-sandbox-cloudflare/src/preview-tool.ts +++ b/packages/ai-sandbox-cloudflare/src/preview-tool.ts @@ -85,12 +85,13 @@ async function localProbe( ): Promise { // ponytail: race instead of AbortSignal — a signal doesn't serialize across the // sandbox RPC boundary, and a lost in-flight probe response is harmless. - const timeout = new Promise((_, reject) => - setTimeout( + let timeoutId: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout( () => reject(new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)), LOCAL_PROBE_TIMEOUT_MS, - ), - ) + ) + }) try { const res = await Promise.race([ sandbox.containerFetch('http://preview/', { method: 'HEAD' }, port), @@ -99,6 +100,8 @@ async function localProbe( return res.status } catch (error) { return error instanceof Error ? error.message : String(error) + } finally { + clearTimeout(timeoutId) } } @@ -109,13 +112,16 @@ async function localProbe( * keeps a login redirect from probing some other site), and even those are * trusted when the app answered the SAME status locally, so an app's own * 502/530 never gets its healthy tunnel destroyed. Success is `null`; repeated - * failure returns the last symptom. + * failure returns the last symptom plus a verdict: 'stale' when a non-matching + * 502/530 was actually observed, 'unverified' when only fetch exceptions + * (timeout/DNS/subrequest failure) occurred. */ async function edgeProbeFailure( url: string, localStatus: number, -): Promise { +): Promise<{ verdict: 'stale' | 'unverified'; symptom: string } | null> { let lastFailure = 'no response' + let verdict: 'stale' | 'unverified' = 'unverified' for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => @@ -135,11 +141,12 @@ async function edgeProbeFailure( return null } lastFailure = `HTTP ${res.status}` + verdict = 'stale' } catch (error) { lastFailure = error instanceof Error ? error.message : String(error) } } - return lastFailure + return { verdict, symptom: lastFailure } } /** @@ -187,21 +194,28 @@ export function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) { // can't hijack the preview's asset requests) and needs no custom domain on a // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API. const tunnel = await sandbox.tunnels.get(port) - const staleSymptom = await edgeProbeFailure(tunnel.url, local) - if (staleSymptom === null) return { url: tunnel.url } + const edgeFailure = await edgeProbeFailure(tunnel.url, local) + if (edgeFailure === null) return { url: tunnel.url } + // Never destroy on 'unverified': the tunnel may be healthy with only the + // probe path broken, so destroying it could kill a working preview. + if (edgeFailure.verdict === 'unverified') { + throw new Error( + `Port ${port} is serving inside the sandbox, but the preview tunnel could not be verified from the edge (${edgeFailure.symptom}). The tunnel was left in place — retry exposePreview in a few seconds.`, + ) + } // Local server healthy but the edge kept answering 502/530: the cached tunnel // record is suspect. Refresh, bounded to ONE so we never churn tunnels. await sandbox.tunnels.destroy(port) const fresh = await sandbox.tunnels.get(port) - const freshSymptom = await edgeProbeFailure(fresh.url, local) - if (freshSymptom === null) { + const freshFailure = await edgeProbeFailure(fresh.url, local) + if (freshFailure === null) { return { url: fresh.url, note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`, } } throw new Error( - `Port ${port} is serving inside the sandbox, but its preview tunnel never became reachable (old tunnel: ${staleSymptom}; replacement tunnel: ${freshSymptom}). Retry exposePreview, and if it keeps failing, restart the dev server and try again.`, + `Port ${port} is serving inside the sandbox, but its preview tunnel never became reachable (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). Retry exposePreview, and if it keeps failing, restart the dev server and try again.`, ) }) } diff --git a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts index c03cfdf50d..975489ed44 100644 --- a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts +++ b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts @@ -125,6 +125,19 @@ describe('exposePreviewTool', () => { expect(tunnelDestroyMock).not.toHaveBeenCalled() }) + it('leaves the tunnel alone when the edge probe only ever throws (unverified, not stale)', async () => { + vi.useFakeTimers() + // Timeouts/DNS failures say nothing about the tunnel — it must survive. + edgeFetchMock.mockRejectedValue(new Error('subrequest failed')) + + const promise = makeTool().execute?.({ port: 5173 }) + const assertion = expect(promise).rejects.toThrow(/could not be verified/) + await vi.runAllTimersAsync() + await assertion + + expect(tunnelDestroyMock).not.toHaveBeenCalled() + }) + it('replaces a stale tunnel (local healthy, edge stuck on 502) and flags the old URL as dead', async () => { vi.useFakeTimers() tunnelGetMock From 04bd955110208ea46f4f136958d5b8c61483c27a Mon Sep 17 00:00:00 2001 From: Season Saw Date: Sat, 22 Aug 2026 22:58:06 +0800 Subject: [PATCH 3/5] fix(ai-sandbox-cloudflare): diagnose the replacement tunnel by probe verdict Review feedback: when the replacement tunnel's probe only saw fetch exceptions, the error claimed the tunnel never became reachable and recommended restarting the dev server. Pick the diagnosis and hint from the probe verdict: unverified reports that the edge could not be verified and suggests a plain retry; the stale message is unchanged. --- packages/ai-sandbox-cloudflare/src/preview-tool.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/ai-sandbox-cloudflare/src/preview-tool.ts b/packages/ai-sandbox-cloudflare/src/preview-tool.ts index ceb43f6aab..59b2d38bee 100644 --- a/packages/ai-sandbox-cloudflare/src/preview-tool.ts +++ b/packages/ai-sandbox-cloudflare/src/preview-tool.ts @@ -214,8 +214,18 @@ export function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) { note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`, } } + const [diagnosis, hint] = + freshFailure.verdict === 'stale' + ? [ + 'its preview tunnel never became reachable', + 'Retry exposePreview, and if it keeps failing, restart the dev server and try again.', + ] + : [ + 'the replacement preview tunnel could not be verified from the edge', + 'Retry exposePreview in a few seconds.', + ] throw new Error( - `Port ${port} is serving inside the sandbox, but its preview tunnel never became reachable (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). Retry exposePreview, and if it keeps failing, restart the dev server and try again.`, + `Port ${port} is serving inside the sandbox, but ${diagnosis} (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). ${hint}`, ) }) } From 5e3a8fc79534754c5bc52b76b8c5f731c83a33fc Mon Sep 17 00:00:00 2001 From: Season Saw Date: Sat, 22 Aug 2026 23:17:04 +0800 Subject: [PATCH 4/5] refactor(ai-sandbox-cloudflare): make preview-tool comments state constraints Drop internal shorthand from the probe-race comment, explain the stale/unverified split by the evidence rule it encodes instead of restating the return type, and note why the test mocks need mockReset rather than what the setup does. --- packages/ai-sandbox-cloudflare/src/preview-tool.ts | 12 ++++++------ .../ai-sandbox-cloudflare/tests/preview-tool.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ai-sandbox-cloudflare/src/preview-tool.ts b/packages/ai-sandbox-cloudflare/src/preview-tool.ts index 59b2d38bee..9096ee2a9c 100644 --- a/packages/ai-sandbox-cloudflare/src/preview-tool.ts +++ b/packages/ai-sandbox-cloudflare/src/preview-tool.ts @@ -83,8 +83,8 @@ async function localProbe( sandbox: Sandbox, port: number, ): Promise { - // ponytail: race instead of AbortSignal — a signal doesn't serialize across the - // sandbox RPC boundary, and a lost in-flight probe response is harmless. + // A race instead of AbortSignal: signals don't serialize across the sandbox + // RPC boundary, and a lost in-flight probe response is harmless. let timeoutId: ReturnType | undefined const timeout = new Promise((_, reject) => { timeoutId = setTimeout( @@ -111,10 +111,10 @@ async function localProbe( * unreachable (401/403/404 prove the server answered, and `redirect: 'manual'` * keeps a login redirect from probing some other site), and even those are * trusted when the app answered the SAME status locally, so an app's own - * 502/530 never gets its healthy tunnel destroyed. Success is `null`; repeated - * failure returns the last symptom plus a verdict: 'stale' when a non-matching - * 502/530 was actually observed, 'unverified' when only fetch exceptions - * (timeout/DNS/subrequest failure) occurred. + * 502/530 never gets its healthy tunnel destroyed. The verdict split exists + * because only an OBSERVED non-matching 502/530 ('stale') is evidence that + * justifies destroying the tunnel; fetch exceptions ('unverified') prove + * nothing about it — the probe path itself may be what failed. */ async function edgeProbeFailure( url: string, diff --git a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts index 975489ed44..f649909851 100644 --- a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts +++ b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts @@ -61,8 +61,8 @@ function makeTool() { beforeEach(() => { getSandboxMock.mockClear() - // mockReset drops leftover `mockResolvedValueOnce` queues, then happy-path - // defaults; individual tests override the failing leg. + // mockReset, not mockClear: leftover `mockResolvedValueOnce` queues from one + // test would leak into the next. containerFetchMock.mockReset().mockResolvedValue(http(200)) tunnelGetMock.mockReset().mockResolvedValue({ url: OLD_URL }) tunnelDestroyMock.mockReset().mockResolvedValue(undefined) From 931de7006c8cfb90577591870058cee9c7147eea Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:27:09 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- scripts/lovable-gateway.models.json | 387 ++++++---------------------- 1 file changed, 82 insertions(+), 305 deletions(-) diff --git a/scripts/lovable-gateway.models.json b/scripts/lovable-gateway.models.json index 8a193376e4..79c6babbc9 100644 --- a/scripts/lovable-gateway.models.json +++ b/scripts/lovable-gateway.models.json @@ -12,15 +12,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -85,15 +78,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -150,15 +136,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -222,12 +201,8 @@ "context_window": 8192, "max_tokens": 16384, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -265,12 +240,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -308,15 +279,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -406,12 +370,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -449,15 +409,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -522,15 +475,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -595,15 +541,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -668,15 +607,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -740,15 +672,8 @@ "context_window": 65536, "max_tokens": 4096, "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -813,12 +738,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -856,15 +777,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -954,15 +868,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1027,15 +934,8 @@ "max_tokens": 64000, "knowledge": "2026-03", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1100,12 +1000,8 @@ "max_tokens": 0, "knowledge": "2025-05", "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -1133,15 +1029,8 @@ "max_tokens": 0, "knowledge": "2025-11", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1192,13 +1081,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1226,13 +1110,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1260,13 +1139,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1291,13 +1165,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1342,12 +1211,8 @@ "context_window": 2000, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -1384,13 +1249,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1436,13 +1296,8 @@ "max_tokens": 128000, "knowledge": "2024-09-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1526,13 +1381,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1616,13 +1466,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1671,13 +1516,8 @@ "max_tokens": 128000, "knowledge": "2024-10", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1761,13 +1601,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1866,13 +1701,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1956,13 +1786,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2011,13 +1836,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2081,13 +1901,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2186,13 +2001,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2256,13 +2066,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2361,13 +2166,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2466,13 +2266,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2570,13 +2365,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2621,13 +2411,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2672,12 +2457,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -2704,12 +2485,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": {