From a058ea057f9b5ee735d3790ab4efa2edf042ceef Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:56:06 +0700 Subject: [PATCH 01/33] feat(computer): bind quarantine scans to exact file bytes --- agent-computer/src/download-quarantine.ts | 241 +++++++++++++++++++--- 1 file changed, 212 insertions(+), 29 deletions(-) diff --git a/agent-computer/src/download-quarantine.ts b/agent-computer/src/download-quarantine.ts index a029d61a5..4b31045dd 100644 --- a/agent-computer/src/download-quarantine.ts +++ b/agent-computer/src/download-quarantine.ts @@ -1,10 +1,12 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; import { + lstat, mkdir, readFile, readdir, rename, - stat, + unlink, writeFile, } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -50,6 +52,10 @@ export type QuarantineRecord = { sourceUrl: string; savedAt: string; sizeBytes: number; + /** Identity of the exact bytes the record refers to. Never a container/host path. */ + sha256: string; + /** Exact bytes ClamAV scanned. Approval/export must still match this digest. */ + scannedSha256?: string; scan?: MalwareScanResult; approvedAt?: string; releasedAt?: string; @@ -58,12 +64,14 @@ export type QuarantineRecord = { type StoredQuarantineRecord = QuarantineRecord & { file: string; metadata: string; + integrityOk: boolean; }; export type QuarantinedDownload = { id: string; file: string; metadata: string; + record: QuarantineRecord; }; export class QuarantineStateError extends Error { @@ -76,11 +84,29 @@ export class QuarantineStateError extends Error { const METADATA_SUFFIX = ".openbot.json"; const QUARANTINE_ID = /^\d{10,16}-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SHA256 = /^[a-f0-9]{64}$/; function validId(id: string): boolean { return QUARANTINE_ID.test(id); } +async function fingerprint( + file: string, +): Promise<{ sizeBytes: number; sha256: string }> { + const info = await lstat(file); + if (!info.isFile() || info.isSymbolicLink()) { + throw new QuarantineStateError( + "A quarantined download must be a regular file.", + ); + } + + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) { + hash.update(chunk); + } + return { sizeBytes: info.size, sha256: hash.digest("hex") }; +} + async function writeMetadata( path: string, record: QuarantineRecord, @@ -100,7 +126,12 @@ async function writeMetadata( } function publicRecord(record: StoredQuarantineRecord): QuarantineRecord { - const { file: _file, metadata: _metadata, ...safe } = record; + const { + file: _file, + metadata: _metadata, + integrityOk: _integrityOk, + ...safe + } = record; return safe; } @@ -119,6 +150,26 @@ function normalizeStatus(value: unknown): QuarantineStatus { return "scan_failed"; } +function integrityFailure(record: QuarantineRecord): QuarantineRecord { + const { + approvedAt: _approvedAt, + releasedAt: _releasedAt, + scannedSha256: _scannedSha256, + ...untrusted + } = record; + return { + ...untrusted, + status: "scan_failed", + scan: { + status: "scan_failed", + scanner: "clamav", + detail: + "The quarantined file changed after its recorded identity/scan and must be rescanned.", + scannedAt: new Date().toISOString(), + }, + }; +} + async function readStoredMetadata( metadata: string, expectedBotId: string, @@ -141,14 +192,24 @@ async function readStoredMetadata( ); } - const fileInfo = await stat(file); - if (!fileInfo.isFile()) { - throw new QuarantineStateError("The quarantined download is not a file."); - } + const current = await fingerprint(file); + const recordedSha = + typeof raw.sha256 === "string" && SHA256.test(raw.sha256) + ? raw.sha256 + : current.sha256; + const recordedSize = + typeof raw.sizeBytes === "number" && + Number.isSafeInteger(raw.sizeBytes) && + raw.sizeBytes >= 0 + ? raw.sizeBytes + : current.sizeBytes; + const status = normalizeStatus(raw.status); + const integrityOk = + recordedSha === current.sha256 && recordedSize === current.sizeBytes; - return { + const parsed: QuarantineRecord = { version: 2, - status: normalizeStatus(raw.status), + status, id, botId: expectedBotId, originalName: @@ -159,8 +220,13 @@ async function readStoredMetadata( savedAt: typeof raw.savedAt === "string" ? raw.savedAt - : new Date(fileInfo.mtimeMs).toISOString(), - sizeBytes: fileInfo.size, + : new Date().toISOString(), + sizeBytes: recordedSize, + sha256: recordedSha, + ...(typeof raw.scannedSha256 === "string" && + SHA256.test(raw.scannedSha256) + ? { scannedSha256: raw.scannedSha256 } + : {}), ...(raw.scan && typeof raw.scan === "object" ? { scan: raw.scan as MalwareScanResult } : {}), @@ -170,8 +236,18 @@ async function readStoredMetadata( ...(typeof raw.releasedAt === "string" ? { releasedAt: raw.releasedAt } : {}), + }; + + const safe = + integrityOk || status === "pending" || status === "blocked" + ? parsed + : integrityFailure(parsed); + + return { + ...safe, file, metadata, + integrityOk, }; } @@ -209,8 +285,8 @@ export async function listQuarantinedDownloads( publicRecord(await readStoredMetadata(join(directory, entry), botId)), ); } catch { - // An incomplete/corrupt sidecar is not silently called clean. It is omitted from the normal - // list and still remains physically quarantined for diagnostics/recovery. + // An incomplete/corrupt sidecar is never promoted into a trusted-looking entry. Reset can + // still clear the complete quarantine volume for recovery. } } return records.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); @@ -229,17 +305,37 @@ export async function scanQuarantinedDownload( ); } + const before = await fingerprint(stored.file); const scan = await scanner(stored.file); + const after = await fingerprint(stored.file); const current = publicRecord(stored); const { approvedAt: _approvedAt, releasedAt: _releasedAt, + scannedSha256: _scannedSha256, ...unapproved } = current; + + const changedDuringScan = + before.sha256 !== after.sha256 || before.sizeBytes !== after.sizeBytes; + const effectiveScan: MalwareScanResult = changedDuringScan + ? { + status: "scan_failed", + scanner: "clamav", + detail: + "The quarantined file changed while malware scanning was in progress.", + scannedAt: new Date().toISOString(), + } + : scan; const updated: QuarantineRecord = { ...unapproved, - status: scan.status, - scan, + sizeBytes: after.sizeBytes, + sha256: after.sha256, + status: effectiveScan.status, + scan: effectiveScan, + ...(effectiveScan.status === "clean" + ? { scannedSha256: after.sha256 } + : {}), }; await writeMetadata(stored.metadata, updated); return updated; @@ -251,10 +347,20 @@ export async function approveQuarantinedDownload( id: string, ): Promise { const stored = await findStored(root, botId, id); - if (stored.status === "approved") return publicRecord(stored); - if (stored.status !== "clean") { + if ( + stored.status === "approved" && + stored.integrityOk && + stored.scannedSha256 === stored.sha256 + ) { + return publicRecord(stored); + } + if ( + stored.status !== "clean" || + !stored.integrityOk || + stored.scannedSha256 !== stored.sha256 + ) { throw new QuarantineStateError( - `Only a clean scanned download can be approved for export (current status: ${stored.status}).`, + `Only unchanged bytes from a clean scan can be approved for export (current status: ${stored.status}).`, ); } @@ -267,6 +373,79 @@ export async function approveQuarantinedDownload( return updated; } +/** + * Resolve bytes for the native export worker, never for a browser/model response. + * + * The caller still has to choose a host destination natively. Returning a path internally avoids + * loading hostile bytes into JSON while binding export to the exact digest that was scanned. + */ +export async function approvedQuarantineFile( + root: string, + botId: string, + id: string, +): Promise<{ file: string; record: QuarantineRecord }> { + const stored = await findStored(root, botId, id); + if ( + stored.status !== "approved" || + !stored.integrityOk || + stored.scannedSha256 !== stored.sha256 + ) { + throw new QuarantineStateError( + "This download is not an unchanged, clean, explicitly approved file.", + ); + } + return { file: stored.file, record: publicRecord(stored) }; +} + +export async function markQuarantinedDownloadReleased( + root: string, + botId: string, + id: string, +): Promise { + const stored = await findStored(root, botId, id); + if ( + stored.status !== "approved" || + !stored.integrityOk || + stored.scannedSha256 !== stored.sha256 + ) { + throw new QuarantineStateError( + "Only the unchanged approved bytes can be marked released.", + ); + } + const updated: QuarantineRecord = { + ...publicRecord(stored), + status: "released", + releasedAt: new Date().toISOString(), + }; + await writeMetadata(stored.metadata, updated); + return updated; +} + +export async function deleteQuarantinedDownload( + root: string, + botId: string, + id: string, +): Promise { + const stored = await findStored(root, botId, id).catch((error) => { + if ( + error instanceof QuarantineStateError && + error.message === "That quarantined download was not found." + ) { + return null; + } + throw error; + }); + if (!stored) return false; + + await unlink(stored.file).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + await unlink(stored.metadata).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + return true; +} + export async function quarantineDownload( root: string, botId: string, @@ -289,11 +468,10 @@ export async function quarantineDownload( ); await download.saveAs(file); - const fileInfo = await stat(file); - const metadata = `${file}${METADATA_SUFFIX}`; - await writeMetadata( - metadata, - { + try { + const { sizeBytes, sha256 } = await fingerprint(file); + const metadata = `${file}${METADATA_SUFFIX}`; + const record: QuarantineRecord = { version: 2, status: "pending", id, @@ -301,10 +479,15 @@ export async function quarantineDownload( originalName: download.suggestedFilename(), sourceUrl: download.url(), savedAt: new Date().toISOString(), - sizeBytes: fileInfo.size, - }, - true, - ); - - return { id, file, metadata }; + sizeBytes, + sha256, + }; + await writeMetadata(metadata, record, true); + return { id, file, metadata, record }; + } catch (error) { + // Untracked hostile bytes are worse than a failed download. If identity/metadata cannot be + // established, remove the bytes rather than leave something the UI cannot account for. + await unlink(file).catch(() => undefined); + throw error; + } } From aa99507bbe921220b443fb5a81b7d12cb566d253 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:56:33 +0700 Subject: [PATCH 02/33] test(computer): pin quarantine byte identity and release transition --- .../tests/download-quarantine.test.ts | 88 ++++++++++++++++++- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/agent-computer/tests/download-quarantine.test.ts b/agent-computer/tests/download-quarantine.test.ts index e4d00c87f..d82d7e182 100644 --- a/agent-computer/tests/download-quarantine.test.ts +++ b/agent-computer/tests/download-quarantine.test.ts @@ -4,7 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { approveQuarantinedDownload, + approvedQuarantineFile, + deleteQuarantinedDownload, listQuarantinedDownloads, + markQuarantinedDownloadReleased, quarantineDirectoryFor, quarantineDownload, QuarantineStateError, @@ -59,7 +62,7 @@ describe("download quarantine", () => { expect(quarantineDirectoryFor(root, "agent-a")).toBe(join(root, "agent-a")); }); - test("persists a download only under quarantine as pending untrusted input", async () => { + test("persists identity only under quarantine as pending untrusted input", async () => { let savedAs = ""; const result = await quarantineDownload(root, "agent-a", { failure: async () => null, @@ -74,15 +77,21 @@ describe("download quarantine", () => { expect(savedAs.startsWith(join(root, "agent-a"))).toBe(true); expect(savedAs.endsWith("-installer.exe")).toBe(true); expect(await readFile(result.file, "utf8")).toBe("untrusted bytes"); + expect(result.record.status).toBe("pending"); + expect(result.record.sha256).toMatch(/^[a-f0-9]{64}$/); const metadata = JSON.parse(await readFile(result.metadata, "utf8")) as { status: string; originalName: string; sourceUrl: string; + sha256: string; + file?: unknown; }; expect(metadata.status).toBe("pending"); expect(metadata.originalName).toBe("../../installer.exe"); expect(metadata.sourceUrl).toBe("https://example.test/installer.exe"); + expect(metadata.sha256).toBe(result.record.sha256); + expect(metadata.file).toBeUndefined(); }); test("a failed scanner is never treated as clean or approvable", async () => { @@ -116,7 +125,28 @@ describe("download quarantine", () => { ).rejects.toThrow(QuarantineStateError); }); - test("approval requires a clean scan and still does not export or execute the file", async () => { + test("changing the file during a clean scan fails closed", async () => { + const download = await addDownload(); + const scanned = await scanQuarantinedDownload( + root, + "agent-a", + download.id, + async (file) => { + await Bun.write(file, "different bytes after scan started"); + return { + status: "clean", + scanner: "clamav", + detail: "no malware", + scannedAt: new Date().toISOString(), + }; + }, + ); + expect(scanned.status).toBe("scan_failed"); + expect(scanned.scannedSha256).toBeUndefined(); + expect(scanned.scan?.detail).toContain("changed"); + }); + + test("approval and export stay bound to the exact clean bytes", async () => { const download = await addDownload(); const scanned = await scanQuarantinedDownload( root, @@ -125,6 +155,7 @@ describe("download quarantine", () => { fakeScan("clean", "no malware"), ); expect(scanned.status).toBe("clean"); + expect(scanned.scannedSha256).toBe(scanned.sha256); const approved = await approveQuarantinedDownload( root, @@ -133,8 +164,57 @@ describe("download quarantine", () => { ); expect(approved.status).toBe("approved"); expect(approved.approvedAt).toBeTruthy(); - expect(await readFile(download.file, "utf8")).toBe("untrusted bytes"); - expect(download.file.startsWith(join(root, "agent-a"))).toBe(true); + + const exportable = await approvedQuarantineFile( + root, + "agent-a", + download.id, + ); + expect(exportable.record.sha256).toBe(scanned.sha256); + expect(await readFile(exportable.file, "utf8")).toBe("untrusted bytes"); + + await Bun.write(download.file, "tampered but still quarantined"); + await expect( + approvedQuarantineFile(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + await expect( + markQuarantinedDownloadReleased(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + }); + + test("release is a separate transition after native export succeeds", async () => { + const download = await addDownload(); + await scanQuarantinedDownload( + root, + "agent-a", + download.id, + fakeScan("clean"), + ); + await approveQuarantinedDownload(root, "agent-a", download.id); + const released = await markQuarantinedDownloadReleased( + root, + "agent-a", + download.id, + ); + expect(released.status).toBe("released"); + expect(released.releasedAt).toBeTruthy(); + await expect( + approvedQuarantineFile(root, "agent-a", download.id), + ).rejects.toThrow(QuarantineStateError); + }); + + test("delete accepts only the generated id and remains per-Agent", async () => { + const download = await addDownload("agent-a"); + await expect( + deleteQuarantinedDownload(root, "agent-a", "../../installer.exe"), + ).rejects.toThrow(QuarantineStateError); + expect(await deleteQuarantinedDownload(root, "agent-b", download.id)).toBe( + false, + ); + expect(await deleteQuarantinedDownload(root, "agent-a", download.id)).toBe( + true, + ); + expect(await listQuarantinedDownloads(root, "agent-a")).toEqual([]); }); test("one Agent cannot list or scan another Agent's quarantine", async () => { From d0d01f245ee119fde9a439aa59954f1cf504035f Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:58:06 +0700 Subject: [PATCH 03/33] feat(computer): expose quarantine byte identity --- server/src/computer/schema.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/computer/schema.ts b/server/src/computer/schema.ts index ff45fabd6..b6e9514b5 100644 --- a/server/src/computer/schema.ts +++ b/server/src/computer/schema.ts @@ -395,6 +395,10 @@ export type QuarantineRecord = { sourceUrl: string; savedAt: string; sizeBytes: number; + /** SHA-256 identity of the quarantined bytes. No filesystem path is exposed. */ + sha256: string; + /** The exact SHA-256 ClamAV scanned; approval/export must still match it. */ + scannedSha256?: string; scan?: QuarantineScan; approvedAt?: string; releasedAt?: string; From 862ea9975c3c7312b7217e5488d4fa6fef42b658 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:58:28 +0700 Subject: [PATCH 04/33] feat(computer): add guarded quarantine export stream --- agent-computer/src/index.ts | 104 ++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 162bb7e2e..651a809fd 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -25,7 +25,10 @@ import { identity } from "./identity"; import { collectComputerMetrics } from "./metrics"; import { approveQuarantinedDownload, + approvedQuarantineFile, + deleteQuarantinedDownload, listQuarantinedDownloads, + markQuarantinedDownloadReleased, QuarantineStateError, scanQuarantinedDownload, } from "./download-quarantine"; @@ -1028,6 +1031,107 @@ serve({ } } + /** + * Internal byte stream used only by the authenticated native desktop export worker. + * + * The API server never exposes this response to the web UI/model. The helper checks that the + * exact bytes are still approved and still match the SHA-256 ClamAV scanned before opening them. + */ + if ( + url.pathname === "/quarantine/export-internal" && + request.method === "POST" + ) { + const body = (await request.json().catch(() => null)) as { + id?: unknown; + } | null; + if (typeof body?.id !== "string" || !body.id) { + return json({ error: "A quarantine download id is required." }, 400); + } + try { + const exportable = await approvedQuarantineFile( + QUARANTINE_ROOT, + botId, + body.id, + ); + return new Response(Bun.file(exportable.file), { + headers: { + "content-type": "application/octet-stream", + "content-length": String(exportable.record.sizeBytes), + "x-openbot-sha256": exportable.record.sha256, + }, + }); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file is not approved for export.", + ), + }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + + if ( + url.pathname === "/quarantine/released" && + request.method === "POST" + ) { + const body = (await request.json().catch(() => null)) as { + id?: unknown; + } | null; + if (typeof body?.id !== "string" || !body.id) { + return json({ error: "A quarantine download id is required." }, 400); + } + try { + return json( + await markQuarantinedDownloadReleased( + QUARANTINE_ROOT, + botId, + body.id, + ), + ); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file could not be marked released.", + ), + }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + + if (url.pathname === "/quarantine/delete" && request.method === "POST") { + const body = (await request.json().catch(() => null)) as { + id?: unknown; + } | null; + if (typeof body?.id !== "string" || !body.id) { + return json({ error: "A quarantine download id is required." }, 400); + } + try { + return json({ + deleted: await deleteQuarantinedDownload( + QUARANTINE_ROOT, + botId, + body.id, + ), + }); + } catch (error) { + return json( + { + error: describe( + error, + "The quarantined file could not be deleted.", + ), + }, + error instanceof QuarantineStateError ? 409 : 500, + ); + } + } + // The Bot's files. Confined to the workspace by workspace.ts. Nothing here decides whether a Bot // MAY touch a path: the gateway in front of this process does that. if (url.pathname === "/files/read" && request.method === "POST") { From 9e707625223b096c64fa189478ad0d2fce57af38 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:59:00 +0700 Subject: [PATCH 05/33] feat(computer): stream approved quarantine bytes internally --- server/src/computer/client.ts | 66 ++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index bac3890a8..2891b1206 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -95,6 +95,21 @@ export type ComputerTransportOptions = { /** Internal HTTP interface used only by ComputerGateway. */ export interface ComputerTransport { + /** + * Authenticated response without JSON decoding. + * + * Used only for native quarantine export, where hostile file bytes must stream to the desktop + * worker instead of being materialized into an API/model JSON object. + */ + raw( + baseUrl: string, + botId: string, + path: string, + init?: RequestInit, + caller?: AbortSignal, + /** Overrides the transport's own deadline for this one call. */ + timeoutMs?: number, + ): Promise; call( baseUrl: string, botId: string, @@ -132,14 +147,14 @@ export function createComputerTransport( const doFetch = options.fetchImpl ?? fetch; const defaultTimeoutMs = options.timeoutMs ?? 45_000; - async function call( + async function raw( baseUrl: string, botId: string, path: string, init?: RequestInit, caller?: AbortSignal, timeoutMsOverride?: number, - ): Promise { + ): Promise { if (caller?.aborted) { throw new ComputerStoppedError("The action was stopped."); } @@ -148,12 +163,11 @@ export function createComputerTransport( * A browser action either happens in seconds or has gone wrong, so 45s is the right deadline for * it. A command is not that: the shell's own budget is 120s by default and up to 600s, and the * tool description tells the model to install packages. Giving up here first reported failure to - * the person while the command carried on running to completion inside the container, and made - * the shell's own limit unreachable. A caller with a longer limit of its own passes it in, and - * this becomes the backstop rather than the limit. + * the person while the command ran to completion inside the container, and made the shell's own + * limit unreachable. A caller with a longer limit of its own passes it in, and this becomes the + * backstop rather than the limit. */ const timeoutMs = timeoutMsOverride ?? defaultTimeoutMs; - const target = baseUrl.replace(/\/$/, ""); let response: Response; try { @@ -171,15 +185,6 @@ export function createComputerTransport( : AbortSignal.timeout(timeoutMs), }); } catch (error) { - /* - * The caller's own abort is answered first, and says what the check above the fetch says. - * - * The signal is handed to fetch precisely so a Stop can land mid-flight, and a fetch aborted - * that way rejects with an AbortError, which is neither a TimeoutError nor a computer that is - * not running. Both of the other answers are statements about the infrastructure, and this - * message is not only read by the model: the gateway writes it into the action's audit row, - * and the type below is what keeps that row a stop rather than an outage. - */ if (caller?.aborted) { throw new ComputerStoppedError("The action was stopped."); } @@ -190,14 +195,33 @@ export function createComputerTransport( ); } - const body = (await response.json().catch(() => null)) as Record< - string, - unknown - > | null; if (!response.ok) { + const body = (await response.json().catch(() => null)) as Record< + string, + unknown + > | null; throwMappedError(response.status, body); } - return body as T; + return response; + } + + async function call( + baseUrl: string, + botId: string, + path: string, + init?: RequestInit, + caller?: AbortSignal, + timeoutMsOverride?: number, + ): Promise { + const response = await raw( + baseUrl, + botId, + path, + init, + caller, + timeoutMsOverride, + ); + return (await response.json().catch(() => null)) as T; } function post( @@ -238,7 +262,7 @@ export function createComputerTransport( }); } - return { call, post, navigate }; + return { raw, call, post, navigate }; } /** Map agent-computer responses to errors that a caller can act on. */ From 098ccbd8ef662ecb93f18afc88ae05edb0119877 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:59:12 +0700 Subject: [PATCH 06/33] test(computer): keep quarantine export bytes out of JSON --- server/tests/computer-client.test.ts | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/server/tests/computer-client.test.ts b/server/tests/computer-client.test.ts index ce9059eec..d110038f5 100644 --- a/server/tests/computer-client.test.ts +++ b/server/tests/computer-client.test.ts @@ -35,6 +35,42 @@ const ok = (body: unknown) => }); describe("computer client", () => { + test("raw transport preserves approved file bytes and authenticates the computer", async () => { + let seenHeaders: Headers | null = null; + const transport = createComputerTransport({ + token: "computer-secret", + fetchImpl: (async (_url: string, init?: RequestInit) => { + seenHeaders = new Headers(init?.headers); + return new Response(new Uint8Array([0, 1, 2, 255]), { + headers: { + "content-type": "application/octet-stream", + "x-openbot-sha256": + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }); + }) as unknown as typeof fetch, + }); + + const response = await transport.raw( + "http://agent-computer:4100", + "bot-1", + "/quarantine/export-internal", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "download-id" }), + }, + ); + + expect(seenHeaders?.get("x-openbot-bot-id")).toBe("bot-1"); + expect(seenHeaders?.get("x-openbot-computer-token")).toBe( + "computer-secret", + ); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + new Uint8Array([0, 1, 2, 255]), + ); + }); + test("navigates and returns where it landed", async () => { const seen: string[] = []; const client = clientWith((url, init) => { From 1082cebb0b52b0c78253369ac07e411bc8351ab9 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:59:44 +0700 Subject: [PATCH 07/33] feat(computer): define quarantine delete result --- server/src/computer/schema.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/computer/schema.ts b/server/src/computer/schema.ts index b6e9514b5..b930d9f40 100644 --- a/server/src/computer/schema.ts +++ b/server/src/computer/schema.ts @@ -407,3 +407,7 @@ export type QuarantineRecord = { export type QuarantineListResult = { downloads: QuarantineRecord[]; }; + +export type QuarantineDeleteResult = { + deleted: boolean; +}; From b96b504bda3350d0fd2db5f2916679c971eb9c51 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 01:59:52 +0700 Subject: [PATCH 08/33] feat(audit): record quarantine release and delete --- server/src/audit.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/audit.ts b/server/src/audit.ts index 9261bc5eb..c38007725 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -262,6 +262,8 @@ export const auditEventTypes = [ // record the security decision without copying file contents or scanner output into the trail. "computer.quarantine_scanned", "computer.quarantine_approved", + "computer.quarantine_released", + "computer.quarantine_deleted", /** * The boundary this deployment booted with. * From 80944afbf04b0f7ad9f8566228e14067ae9bc3c2 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:00:12 +0700 Subject: [PATCH 09/33] feat(computer): stream approved quarantine through gateway --- server/src/computer/gateway.ts | 64 +++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index 45e0acf1c..f7f1d9677 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -60,6 +60,7 @@ import type { ReadFileInput, ReadFileResult, ReadResult, + QuarantineDeleteResult, QuarantineListResult, QuarantineRecord, RunCommandInput, @@ -145,6 +146,18 @@ export interface ComputerGateway { actor: ActionActor, id: string, ): Promise; + /** Binary stream for the native desktop worker only; never returned to the browser/model. */ + quarantineExportResponse(botId: string, id: string): Promise; + markQuarantineReleased( + botId: string, + actor: ActionActor, + id: string, + ): Promise; + deleteQuarantine( + botId: string, + actor: ActionActor, + id: string, + ): Promise; navigate( botId: string, actor: ActionActor, @@ -693,6 +706,53 @@ export function createComputerGateway( return result; }, + async quarantineExportResponse(botId: string, id: string) { + return transport.raw( + await locate(botId), + botId, + "/quarantine/export-internal", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id }), + }, + undefined, + 10 * 60_000, + ); + }, + + async markQuarantineReleased( + botId: string, + actor: ActionActor, + id: string, + ) { + const result = await post( + botId, + "/quarantine/released", + { id }, + ); + await writeControlEvent(auditStore, "computer.quarantine_released", { + botId, + actor, + reason: `${id}: native export completed without auto-open`, + }); + return result; + }, + + async deleteQuarantine(botId: string, actor: ActionActor, id: string) { + const result = await post( + botId, + "/quarantine/delete", + { id }, + ); + await writeControlEvent(auditStore, "computer.quarantine_deleted", { + botId, + actor, + reason: `${id}: ${result.deleted ? "deleted" : "already absent"}`, + }); + return result; + }, + status(botId: string): Promise { return provider.status(botId); }, @@ -1393,7 +1453,9 @@ async function writeControlEvent( | "computer.stopped" | "computer.reset" | "computer.quarantine_scanned" - | "computer.quarantine_approved", + | "computer.quarantine_approved" + | "computer.quarantine_released" + | "computer.quarantine_deleted", entry: { botId: string; actor: ActionActor; From a1d60189086f8aa7a868c171a9ebe6773cc4446d Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:00:29 +0700 Subject: [PATCH 10/33] feat(host-access): define native quarantine export operation --- server/src/host-access/schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/src/host-access/schema.ts b/server/src/host-access/schema.ts index 4c92f2f1d..547e53525 100644 --- a/server/src/host-access/schema.ts +++ b/server/src/host-access/schema.ts @@ -6,6 +6,7 @@ export type HostAccessOperationKind = | "read_file" | "write_file" | "run_command" + | "export_quarantine" | "cancel" | "stop"; @@ -29,6 +30,13 @@ export type HostAccessDesktopOperation = { content?: string; command?: string; writable?: boolean; + /** Opaque OpenBot download identity; never a container or Windows path. */ + quarantineId?: string; + /** Filename hint only. Native Save As chooses the real destination. */ + suggestedName?: string; + sha256?: string; + sizeBytes?: number; + dangerous?: boolean; expiresAt?: number; }; From a3ca48150938c92ca89aba242a6b598ac234f97c Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:00:49 +0700 Subject: [PATCH 11/33] feat(host-access): broker native quarantine exports --- server/src/host-access/broker.ts | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/server/src/host-access/broker.ts b/server/src/host-access/broker.ts index b7a17b8e6..01060361f 100644 --- a/server/src/host-access/broker.ts +++ b/server/src/host-access/broker.ts @@ -199,6 +199,69 @@ export function createHostAccessBroker( }); }, + /** + * Queue a native Save As operation for bytes that the Computer gateway has already verified as + * clean and explicitly approved. No Windows path crosses this broker in either direction. + */ + requestQuarantineExport(input: { + botId: string; + actorId: string; + quarantineId: string; + suggestedName: string; + sha256: string; + sizeBytes: number; + dangerous: boolean; + }): Promise<{ exported: boolean }> { + expireDesktopLeaseIfNeeded(); + return enqueue<{ exported: boolean }>({ + operationId: randomUUID(), + kind: "export_quarantine", + botId: input.botId, + actorId: input.actorId, + quarantineId: input.quarantineId, + suggestedName: input.suggestedName, + sha256: input.sha256, + sizeBytes: input.sizeBytes, + dangerous: input.dangerous, + }); + }, + + /** + * Resolve one desktop-only byte request back to the queued export. + * + * The operation id is unguessable and the HTTP route calling this is separately protected by + * the fresh native bearer token. A settled/expired/other-kind operation exposes no source. + */ + quarantineExportSource(operationId: string): { + botId: string; + quarantineId: string; + sha256: string; + } | null { + expireDesktopLeaseIfNeeded(); + const state = operations.get(operationId); + if ( + !state || + state.settled || + state.operation.kind !== "export_quarantine" || + typeof state.operation.quarantineId !== "string" || + typeof state.operation.sha256 !== "string" + ) { + return null; + } + if (state.expiresAt !== null && state.expiresAt <= now()) { + failOperation( + state, + "The native quarantine export expired before the bytes were requested.", + ); + return null; + } + return { + botId: state.botId, + quarantineId: state.operation.quarantineId, + sha256: state.operation.sha256, + }; + }, + rememberGrant(grant: HostAccessGrant) { expireDesktopLeaseIfNeeded(); grants.set(grant.id, publicGrant(grant)); From c0e7ea04a04e3e83763052008821cc991e118659 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:01:07 +0700 Subject: [PATCH 12/33] feat(host-access): stream quarantine only to active desktop export --- server/src/host-access/routes.ts | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/server/src/host-access/routes.ts b/server/src/host-access/routes.ts index c446ae5dc..ef6dc428f 100644 --- a/server/src/host-access/routes.ts +++ b/server/src/host-access/routes.ts @@ -4,6 +4,7 @@ import { recordAuditEvent, type AuditStore } from "../audit"; import type { AppVariables } from "../auth/guards"; import { sameToken } from "../agents/callback-token"; import type { BotAccessCheck } from "../agents/profile-policy"; +import type { ComputerGateway } from "../computer/gateway"; import { HostAccessRefusedError, type HostAccessBroker } from "./broker"; import { asHostAccessDesktopResult, @@ -46,6 +47,8 @@ export function createHostAccessRoutes(options: { requireUser: MiddlewareHandler<{ Variables: AppVariables }>; canUseBot: BotAccessCheck; auditStore?: AuditStore; + /** The only source of quarantine bytes for the native desktop worker. */ + computerGateway?: ComputerGateway; botName?: ( botId: string, actor: AppVariables["actor"], @@ -77,6 +80,72 @@ export function createHostAccessRoutes(options: { ); }); + /** + * Stream one already-approved quarantine object to the native worker. + * + * This route has no user-session alternative and no browser-facing token. The fresh desktop bearer + * token plus the live operation id are both required, and the digest queued for Save As must still + * match the Computer's response before any bytes are forwarded. + */ + routes.get( + "/desktop/quarantine/:operationId", + requireDesktop, + async (context) => { + const source = broker.quarantineExportSource( + context.req.param("operationId"), + ); + if (!source) { + return context.json( + { error: "That native quarantine export is not active." }, + 404, + ); + } + const gateway = options.computerGateway; + if (!gateway) { + return context.json( + { error: "Quarantine export is not configured." }, + 503, + ); + } + + try { + const response = await gateway.quarantineExportResponse( + source.botId, + source.quarantineId, + ); + const sha256 = response.headers.get("x-openbot-sha256"); + if (sha256 !== source.sha256) { + await response.body?.cancel().catch(() => undefined); + return context.json( + { + error: + "The quarantined bytes changed after native export approval.", + }, + 409, + ); + } + const headers = new Headers({ + "content-type": "application/octet-stream", + "x-openbot-sha256": sha256, + "cache-control": "no-store", + }); + const length = response.headers.get("content-length"); + if (length) headers.set("content-length", length); + return new Response(response.body, { status: 200, headers }); + } catch (error) { + return context.json( + { + error: + error instanceof Error + ? error.message + : "The quarantined bytes could not be streamed.", + }, + 409, + ); + } + }, + ); + routes.post("/desktop/result", requireDesktop, async (context) => { const parsed = asHostAccessDesktopResult( await context.req.json().catch(() => null), From 3e522f92ea5ca7d5e52df20598a94ee44f867442 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:36:01 +0700 Subject: [PATCH 13/33] feat(desktop): verify and save approved quarantine exports --- desktop/src-tauri/src/host_access.rs | 161 +++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs index c74c43fb3..1422ab7b4 100644 --- a/desktop/src-tauri/src/host_access.rs +++ b/desktop/src-tauri/src/host_access.rs @@ -16,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::engine::{Address, Engine}; use crate::quiet::said as command_said; @@ -141,6 +142,7 @@ pub type HostAccessResult = Result; pub trait HostApprovalUi: Send + Sync + 'static { fn choose_folder(&self, request: &ChooseFolderPrompt) -> HostAccessResult; + fn choose_quarantine_export(&self, request: &QuarantineExportPrompt) -> HostAccessResult; fn confirm_write(&self, request: &WritePrompt) -> HostAccessResult<()>; fn confirm_command(&self, request: &CommandPrompt) -> HostAccessResult<()>; } @@ -154,6 +156,12 @@ impl HostApprovalUi for DenyAllApprovalUi { )) } + fn choose_quarantine_export(&self, _: &QuarantineExportPrompt) -> HostAccessResult { + Err(HostAccessError::Denied( + "Native quarantine export approval is not wired.".into(), + )) + } + fn confirm_write(&self, _: &WritePrompt) -> HostAccessResult<()> { Err(HostAccessError::Denied( "Native write approval is not wired.".into(), @@ -181,6 +189,16 @@ pub struct ApprovedFolder { pub root: PathBuf, } +#[derive(Clone, Debug)] +pub struct QuarantineExportPrompt { + pub operation_id: String, + pub bot_id: String, + pub suggested_name: String, + pub sha256: String, + pub size_bytes: u64, + pub dangerous: bool, +} + #[derive(Clone, Debug)] pub struct WritePrompt { pub operation_id: String, @@ -217,6 +235,11 @@ struct DesktopOperation { content: Option, command: Option, writable: Option, + quarantine_id: Option, + suggested_name: Option, + sha256: Option, + size_bytes: Option, + dangerous: Option, expires_at: Option, #[serde(skip, default = "now_millis")] received_at_ms: u128, @@ -230,6 +253,7 @@ enum HostOperationKind { ReadFile, WriteFile, RunCommand, + ExportQuarantine, Cancel, Stop, } @@ -629,6 +653,7 @@ impl Inner { HostOperationKind::ReadFile => self.read_file(operation), HostOperationKind::WriteFile => self.write_file(operation), HostOperationKind::RunCommand => self.run_command(operation), + HostOperationKind::ExportQuarantine => self.export_quarantine(operation), HostOperationKind::Cancel | HostOperationKind::Stop => unreachable!(), } } @@ -754,6 +779,142 @@ impl Inner { ))) } + fn export_quarantine(&self, operation: &DesktopOperation) -> HostAccessResult { + let quarantine_id = operation + .quarantine_id + .as_deref() + .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its download id.".into()))?; + let suggested_name = operation + .suggested_name + .as_deref() + .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its filename.".into()))?; + let expected_sha = operation + .sha256 + .as_deref() + .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its digest.".into()))?; + let expected_size = operation + .size_bytes + .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its byte size.".into()))?; + + let destination = self.approval.choose_quarantine_export(&QuarantineExportPrompt { + operation_id: operation.operation_id.clone(), + bot_id: operation.bot_id.clone(), + suggested_name: suggested_name.into(), + sha256: expected_sha.into(), + size_bytes: expected_size, + dangerous: operation.dangerous == Some(true), + })?; + self.ensure_operation_fresh(operation)?; + if self.operation_was_canceled(&operation.operation_id) { + return Err(HostAccessError::Denied( + "Quarantine export was canceled before bytes were written.".into(), + )); + } + if destination.exists() { + return Err(HostAccessError::Denied( + "The selected export destination already exists. Choose a new filename.".into(), + )); + } + let parent = destination.parent().ok_or_else(|| { + HostAccessError::Denied("The selected export destination has no parent folder.".into()) + })?; + if !parent.is_dir() { + return Err(HostAccessError::Denied( + "The selected export folder is not available.".into(), + )); + } + + let url = format!( + "{}/api/host-access/desktop/quarantine/{}", + self.config.base_url.trim_end_matches('/'), + operation.operation_id + ); + let mut response = Client::builder() + .timeout(self.config.operation_timeout) + .build() + .map_err(|error| HostAccessError::Http(error.to_string()))? + .get(url) + .bearer_auth(&self.config.token) + .send() + .map_err(|error| HostAccessError::Http(error.to_string()))?; + if !response.status().is_success() { + return Err(HostAccessError::Denied( + "OpenBot refused the quarantine byte stream.".into(), + )); + } + + let temporary = destination.with_file_name(format!( + ".{}.{}.openbot-part", + destination + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("download"), + fresh_id("export") + )); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary) + .map_err(|error| HostAccessError::Io(error.to_string()))?; + let mut hasher = Sha256::new(); + let mut written = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + let result = (|| -> HostAccessResult<()> { + loop { + let read = response + .read(&mut buffer) + .map_err(|error| HostAccessError::Http(error.to_string()))?; + if read == 0 { + break; + } + written = written + .checked_add(read as u64) + .ok_or_else(|| HostAccessError::Denied("Quarantine export size overflowed.".into()))?; + if written > expected_size { + return Err(HostAccessError::Denied( + "Quarantine export was larger than the approved file.".into(), + )); + } + hasher.update(&buffer[..read]); + file.write_all(&buffer[..read]) + .map_err(|error| HostAccessError::Io(error.to_string()))?; + } + if written != expected_size { + return Err(HostAccessError::Denied( + "Quarantine export size changed after approval.".into(), + )); + } + let actual_sha = format!("{:x}", hasher.finalize()); + if !actual_sha.eq_ignore_ascii_case(expected_sha) { + return Err(HostAccessError::Denied( + "Quarantine export digest changed after approval.".into(), + )); + } + file.sync_all() + .map_err(|error| HostAccessError::Io(error.to_string()))?; + Ok(()) + })(); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error); + } + self.ensure_operation_fresh(operation)?; + if self.operation_was_canceled(&operation.operation_id) { + let _ = fs::remove_file(&temporary); + return Err(HostAccessError::Denied( + "Quarantine export was canceled before commit.".into(), + )); + } + fs::rename(&temporary, &destination).map_err(|error| { + let _ = fs::remove_file(&temporary); + HostAccessError::Io(error.to_string()) + })?; + Ok(OperationSuccess::output(format!( + "Exported quarantine download {quarantine_id} after SHA-256 verification; file was not opened." + ))) + } + fn run_command(&self, operation: &DesktopOperation) -> HostAccessResult { let grant = self.require_grant(operation)?; let command = operation From b2d7ce9a30c66c0c4f8a97c8763fade19a7e0b27 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:36:13 +0700 Subject: [PATCH 14/33] feat(desktop): require native Save As approval for quarantine export --- desktop/src-tauri/src/desktop_host_access.rs | 46 +++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/desktop_host_access.rs b/desktop/src-tauri/src/desktop_host_access.rs index 81ed6bcd4..bc45dd186 100644 --- a/desktop/src-tauri/src/desktop_host_access.rs +++ b/desktop/src-tauri/src/desktop_host_access.rs @@ -1,7 +1,7 @@ //! Owner approval is collected by native dialogs, never by an app/tool-provided answer. use openbot_desktop_lib::host_access::{ ApprovedFolder, ChooseFolderPrompt, CommandPrompt, HostAccessError, HostAccessResult, - HostApprovalUi, WritePrompt, + HostApprovalUi, QuarantineExportPrompt, WritePrompt, }; use tauri::Manager; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; @@ -88,6 +88,50 @@ impl HostApprovalUi for NativeApproval { Ok(ApprovedFolder { root }) } + fn choose_quarantine_export( + &self, + request: &QuarantineExportPrompt, + ) -> HostAccessResult { + let window = self + .0 + .get_webview_window("main") + .ok_or_else(|| refused("The local OpenBot window is closed."))?; + window.show().map_err(|error| refused(error.to_string()))?; + window + .set_focus() + .map_err(|error| refused(error.to_string()))?; + + let warning = if request.dangerous { + "\n\nWARNING: this filename looks executable or script-like. OpenBot will save it only; it will not run or open it." + } else { + "\n\nOpenBot will save this file only; it will not open or run it." + }; + self.confirm( + "Export quarantined download?", + format!( + "Bot: {}\nFile: {}\nSize: {} bytes\nSHA-256: {}{}\n\nThe file passed malware scanning and was explicitly approved in OpenBot. Choose the destination yourself. Exporting does not make the file trusted.", + request.bot_id, + request.suggested_name, + request.size_bytes, + request.sha256, + warning + ), + )?; + + let picked = self + .0 + .dialog() + .file() + .set_title("Save approved quarantined download") + .set_file_name(&request.suggested_name) + .set_parent(&window) + .blocking_save_file() + .ok_or_else(|| refused("No export destination was selected."))?; + picked + .into_path() + .map_err(|error| refused(error.to_string())) + } + fn confirm_write(&self, request: &WritePrompt) -> HostAccessResult<()> { reviewable(&request.content)?; let bot = bot_label(request.bot_name.as_deref(), &request.bot_id); From 6549e5ac7b6957f00d0085d71b892d66f5152bf5 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:53:38 +0700 Subject: [PATCH 15/33] style: satisfy quarantine formatter checks --- agent-computer/src/download-quarantine.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/agent-computer/src/download-quarantine.ts b/agent-computer/src/download-quarantine.ts index 4b31045dd..ce1dab8dc 100644 --- a/agent-computer/src/download-quarantine.ts +++ b/agent-computer/src/download-quarantine.ts @@ -218,13 +218,10 @@ async function readStoredMetadata( : filename.slice(id.length + 1), sourceUrl: typeof raw.sourceUrl === "string" ? raw.sourceUrl : "", savedAt: - typeof raw.savedAt === "string" - ? raw.savedAt - : new Date().toISOString(), + typeof raw.savedAt === "string" ? raw.savedAt : new Date().toISOString(), sizeBytes: recordedSize, sha256: recordedSha, - ...(typeof raw.scannedSha256 === "string" && - SHA256.test(raw.scannedSha256) + ...(typeof raw.scannedSha256 === "string" && SHA256.test(raw.scannedSha256) ? { scannedSha256: raw.scannedSha256 } : {}), ...(raw.scan && typeof raw.scan === "object" From 38abf21b36b77642677497e49ef53329a4d7499d Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:53:41 +0700 Subject: [PATCH 16/33] style: satisfy quarantine formatter checks --- agent-computer/src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 651a809fd..ceb54efb6 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -1073,10 +1073,7 @@ serve({ } } - if ( - url.pathname === "/quarantine/released" && - request.method === "POST" - ) { + if (url.pathname === "/quarantine/released" && request.method === "POST") { const body = (await request.json().catch(() => null)) as { id?: unknown; } | null; From 2be0202f929bbca8c0dca0d1c516c07d234a0225 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:53:54 +0700 Subject: [PATCH 17/33] fix(desktop): update quarantine export test fixtures and formatting --- desktop/src-tauri/src/host_access.rs | 78 ++++++++++++++++++---------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs index 1422ab7b4..247fa67b1 100644 --- a/desktop/src-tauri/src/host_access.rs +++ b/desktop/src-tauri/src/host_access.rs @@ -142,7 +142,10 @@ pub type HostAccessResult = Result; pub trait HostApprovalUi: Send + Sync + 'static { fn choose_folder(&self, request: &ChooseFolderPrompt) -> HostAccessResult; - fn choose_quarantine_export(&self, request: &QuarantineExportPrompt) -> HostAccessResult; + fn choose_quarantine_export( + &self, + request: &QuarantineExportPrompt, + ) -> HostAccessResult; fn confirm_write(&self, request: &WritePrompt) -> HostAccessResult<()>; fn confirm_command(&self, request: &CommandPrompt) -> HostAccessResult<()>; } @@ -779,31 +782,33 @@ impl Inner { ))) } - fn export_quarantine(&self, operation: &DesktopOperation) -> HostAccessResult { - let quarantine_id = operation - .quarantine_id - .as_deref() - .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its download id.".into()))?; - let suggested_name = operation - .suggested_name - .as_deref() - .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its filename.".into()))?; - let expected_sha = operation - .sha256 - .as_deref() - .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its digest.".into()))?; - let expected_size = operation - .size_bytes - .ok_or_else(|| HostAccessError::Denied("Quarantine export is missing its byte size.".into()))?; - - let destination = self.approval.choose_quarantine_export(&QuarantineExportPrompt { - operation_id: operation.operation_id.clone(), - bot_id: operation.bot_id.clone(), - suggested_name: suggested_name.into(), - sha256: expected_sha.into(), - size_bytes: expected_size, - dangerous: operation.dangerous == Some(true), + fn export_quarantine( + &self, + operation: &DesktopOperation, + ) -> HostAccessResult { + let quarantine_id = operation.quarantine_id.as_deref().ok_or_else(|| { + HostAccessError::Denied("Quarantine export is missing its download id.".into()) })?; + let suggested_name = operation.suggested_name.as_deref().ok_or_else(|| { + HostAccessError::Denied("Quarantine export is missing its filename.".into()) + })?; + let expected_sha = operation.sha256.as_deref().ok_or_else(|| { + HostAccessError::Denied("Quarantine export is missing its digest.".into()) + })?; + let expected_size = operation.size_bytes.ok_or_else(|| { + HostAccessError::Denied("Quarantine export is missing its byte size.".into()) + })?; + + let destination = self + .approval + .choose_quarantine_export(&QuarantineExportPrompt { + operation_id: operation.operation_id.clone(), + bot_id: operation.bot_id.clone(), + suggested_name: suggested_name.into(), + sha256: expected_sha.into(), + size_bytes: expected_size, + dangerous: operation.dangerous == Some(true), + })?; self.ensure_operation_fresh(operation)?; if self.operation_was_canceled(&operation.operation_id) { return Err(HostAccessError::Denied( @@ -867,9 +872,9 @@ impl Inner { if read == 0 { break; } - written = written - .checked_add(read as u64) - .ok_or_else(|| HostAccessError::Denied("Quarantine export size overflowed.".into()))?; + written = written.checked_add(read as u64).ok_or_else(|| { + HostAccessError::Denied("Quarantine export size overflowed.".into()) + })?; if written > expected_size { return Err(HostAccessError::Denied( "Quarantine export was larger than the approved file.".into(), @@ -1787,6 +1792,13 @@ mod tests { }) } + fn choose_quarantine_export( + &self, + _: &QuarantineExportPrompt, + ) -> HostAccessResult { + unreachable!("choose_folder regression must not ask for quarantine export approval") + } + fn confirm_write(&self, _: &WritePrompt) -> HostAccessResult<()> { unreachable!("choose_folder regression must not ask for write approval") } @@ -2215,6 +2227,11 @@ mod tests { content: None, command: None, writable: Some(false), + quarantine_id: None, + suggested_name: None, + sha256: None, + size_bytes: None, + dangerous: None, expires_at: None, received_at_ms: now_millis(), } @@ -2288,6 +2305,11 @@ mod tests { content: None, command: None, writable: None, + quarantine_id: None, + suggested_name: None, + sha256: None, + size_bytes: None, + dangerous: None, expires_at: None, received_at_ms: now_millis(), }; From 13a1704947c4600c6fe0074d6140ca44d6529903 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:55:16 +0700 Subject: [PATCH 18/33] fix(desktop): return structured quarantine export success --- desktop/src-tauri/src/host_access.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs index 247fa67b1..176a49357 100644 --- a/desktop/src-tauri/src/host_access.rs +++ b/desktop/src-tauri/src/host_access.rs @@ -288,7 +288,7 @@ struct DesktopGrantResult { } struct OperationSuccess { - output: Option, + output: Option, grant: Option, } @@ -300,6 +300,13 @@ struct OperationGrant { impl OperationSuccess { fn output(output: String) -> Self { + Self { + output: Some(serde_json::Value::String(output)), + grant: None, + } + } + + fn json(output: serde_json::Value) -> Self { Self { output: Some(output), grant: None, @@ -569,7 +576,7 @@ impl Inner { Ok(success) => DesktopResult { operation_id: operation.operation_id.clone(), ok: true, - result: success.output.map(serde_json::Value::String), + result: success.output, grant: success.grant.map(|grant| DesktopGrantResult { grant_id: grant.grant_id, display_name: grant.display_name, @@ -915,9 +922,11 @@ impl Inner { let _ = fs::remove_file(&temporary); HostAccessError::Io(error.to_string()) })?; - Ok(OperationSuccess::output(format!( - "Exported quarantine download {quarantine_id} after SHA-256 verification; file was not opened." - ))) + Ok(OperationSuccess::json(serde_json::json!({ + "exported": true, + "quarantineId": quarantine_id, + "autoOpened": false + }))) } fn run_command(&self, operation: &DesktopOperation) -> HostAccessResult { From 1bab26df3708bb50480e50f33646e8c1cfc9b352 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:55:38 +0700 Subject: [PATCH 19/33] feat(host-access): release quarantine only after native export success --- server/src/host-access/routes.ts | 114 +++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/server/src/host-access/routes.ts b/server/src/host-access/routes.ts index ef6dc428f..644b96c2e 100644 --- a/server/src/host-access/routes.ts +++ b/server/src/host-access/routes.ts @@ -22,6 +22,22 @@ function desktopAuthorized(request: Request, token: string) { return !!given && given.length === token.length && sameToken(given, token); } +function safeExportName(input: string): string { + const leaf = input.replaceAll("\\", "/").split("/").pop() ?? ""; + const safe = leaf + .replace(/[<>:"/\\|?*\p{Cc}]/gu, "_") + .trim() + .slice(0, 160); + return !safe || safe === "." || safe === ".." ? "download" : safe; +} + +function dangerousExportName(name: string): boolean { + return /\.(?:exe|msi|com|bat|cmd|ps1|psm1|vbs|vbe|js|jse|wsf|wsh|scr|cpl|jar|hta|reg|lnk|url|sh|bash|zsh|fish|py|rb|pl|php|apk|appx|appxbundle|msix|msixbundle|docm|xlsm|pptm)$/i.test( + name, + ); +} + + async function audit( auditStore: AuditStore | undefined, input: { @@ -200,6 +216,104 @@ export function createHostAccessRoutes(options: { } }); + routes.post("/quarantine/export", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + botId?: unknown; + id?: unknown; + confirm?: unknown; + } | null; + const botId = typeof body?.botId === "string" ? body.botId.trim() : ""; + const id = typeof body?.id === "string" ? body.id.trim() : ""; + if ( + !botId || + !id || + body?.confirm !== "EXPORT_QUARANTINED_FILE" + ) { + return context.json( + { + error: + "Export requires EXPORT_QUARANTINED_FILE confirmation, a Bot id, and a quarantine download id.", + }, + 400, + ); + } + + const actor = context.var.actor; + if (!(await canUseBot(actor, botId))) { + return context.json({ error: "That Bot is not available to you." }, 404); + } + const gateway = options.computerGateway; + if (!gateway) { + return context.json({ error: "Quarantine export is not configured." }, 503); + } + if (!broker.statusFor(actor.id).connected) { + return context.json( + { + error: + "The native OpenBot desktop is not connected. Open the desktop app before exporting.", + }, + 409, + ); + } + + try { + const listed = await gateway.listQuarantine(botId); + const record = listed.downloads.find((entry) => entry.id === id); + if ( + !record || + record.status !== "approved" || + record.scan?.status !== "clean" || + record.scannedSha256 !== record.sha256 + ) { + throw new HostAccessRefusedError( + "Only unchanged bytes from a clean scan that were explicitly approved can be exported.", + ); + } + + const suggestedName = safeExportName(record.originalName); + const result = await broker.requestQuarantineExport({ + botId, + actorId: actor.id, + quarantineId: id, + suggestedName, + sha256: record.sha256, + sizeBytes: record.sizeBytes, + dangerous: dangerousExportName(suggestedName), + }); + if (!result || result.exported !== true) { + throw new HostAccessRefusedError( + "The native desktop did not confirm a completed export.", + ); + } + + const released = await gateway.markQuarantineReleased( + botId, + { + id: actor.id, + ...(actor.email === "dev@openbot.local" ? {} : { userId: actor.id }), + }, + id, + ); + await audit(auditStore, { + actorUserId: + actor.email === "dev@openbot.local" ? undefined : actor.id, + targetId: id, + change: "quarantine_exported", + botId, + }); + return context.json({ record: released }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "The quarantined file could not be exported."; + return context.json( + { error: message }, + error instanceof HostAccessRefusedError ? 409 : 500, + ); + } + }); + routes.delete("/grants/:id", requireUser, async (context) => { const actor = context.var.actor; // The catch below maps every error to 404, so a malformed id would read as "not found" From cd3dd922606ebfe29a30bb5ae1621424c0442e55 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:55:58 +0700 Subject: [PATCH 20/33] feat(app): query per-Agent quarantine lifecycle --- app/src/lib/computers/queries.ts | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/app/src/lib/computers/queries.ts b/app/src/lib/computers/queries.ts index e6dd4f50b..a0127b886 100644 --- a/app/src/lib/computers/queries.ts +++ b/app/src/lib/computers/queries.ts @@ -13,6 +13,39 @@ export type ComputerResourceMetrics = { browserRunning?: boolean; }; +export type QuarantineStatus = + | "pending" + | "clean" + | "blocked" + | "scan_failed" + | "approved" + | "released"; + +export type QuarantineEntry = { + version: 2; + status: QuarantineStatus; + id: string; + botId: string; + originalName: string; + sourceUrl: string; + savedAt: string; + sizeBytes: number; + sha256: string; + scannedSha256?: string; + scan?: { + status: "clean" | "blocked" | "scan_failed"; + scanner: "clamav"; + detail: string; + scannedAt: string; + }; + approvedAt?: string; + releasedAt?: string; +}; + +export type QuarantineList = { + downloads: QuarantineEntry[]; +}; + export type ComputerProfile = { botId: string; running: boolean; @@ -51,6 +84,7 @@ export const computerKeys = { all: ["computers"] as const, fleet: () => ["computers", "fleet"] as const, policy: () => ["computers", "policy"] as const, + quarantine: (botId: string) => ["computers", "quarantine", botId] as const, }; /** @@ -78,6 +112,21 @@ export function computerFleetQueryOptions() { }); } +export function quarantineQueryOptions(botId: string) { + return queryOptions({ + queryKey: computerKeys.quarantine(botId), + queryFn: async (): Promise => { + const response = await client( + `/api/computers/${encodeURIComponent(botId)}/quarantine`, + { + fallback: "The quarantine could not be listed.", + }, + ); + return response.json(); + }, + }); +} + export function actionPolicyQueryOptions() { return queryOptions({ queryKey: computerKeys.policy(), From ed3fc37c928e0425d2835069ae9336f1f91dbaf9 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:56:01 +0700 Subject: [PATCH 21/33] feat(app): add quarantine scan approve export actions --- app/src/lib/computers/mutations.ts | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/app/src/lib/computers/mutations.ts b/app/src/lib/computers/mutations.ts index d59b125f8..71d66da23 100644 --- a/app/src/lib/computers/mutations.ts +++ b/app/src/lib/computers/mutations.ts @@ -55,6 +55,77 @@ export function stopAllComputersMutationOptions(queryClient: QueryClient) { }); } +export function scanQuarantinedDownloadMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { botId: string; id: string }) => { + const response = await client( + `/api/computers/${encodeURIComponent(variables.botId)}/quarantine/scan`, + { + method: "POST", + body: { id: variables.id }, + fallback: "The quarantined file could not be scanned.", + }, + ); + return response.json(); + }, + onSuccess: (_result, variables) => + queryClient.invalidateQueries({ + queryKey: computerKeys.quarantine(variables.botId), + }), + }); +} + +export function approveQuarantinedDownloadMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { botId: string; id: string }) => { + const response = await client( + `/api/computers/${encodeURIComponent(variables.botId)}/quarantine/approve`, + { + method: "POST", + body: { + id: variables.id, + botId: variables.botId, + confirm: "APPROVE", + }, + fallback: "The quarantined file could not be approved.", + }, + ); + return response.json(); + }, + onSuccess: (_result, variables) => + queryClient.invalidateQueries({ + queryKey: computerKeys.quarantine(variables.botId), + }), + }); +} + +export function exportQuarantinedDownloadMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { botId: string; id: string }) => { + const response = await client("/api/host-access/quarantine/export", { + method: "POST", + body: { + botId: variables.botId, + id: variables.id, + confirm: "EXPORT_QUARANTINED_FILE", + }, + fallback: "The quarantined file could not be exported.", + }); + return response.json(); + }, + onSuccess: (_result, variables) => + queryClient.invalidateQueries({ + queryKey: computerKeys.quarantine(variables.botId), + }), + }); +} + /** * Replace the whole policy. * From 38bd1911f33489a4535fb904a99d278089266fad Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:56:21 +0700 Subject: [PATCH 22/33] feat(app): add quarantine scan approve export dialog --- .../computers/computer-quarantine-dialog.tsx | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 app/src/components/computers/computer-quarantine-dialog.tsx diff --git a/app/src/components/computers/computer-quarantine-dialog.tsx b/app/src/components/computers/computer-quarantine-dialog.tsx new file mode 100644 index 000000000..c9e9e9a41 --- /dev/null +++ b/app/src/components/computers/computer-quarantine-dialog.tsx @@ -0,0 +1,239 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + approveQuarantinedDownloadMutationOptions, + exportQuarantinedDownloadMutationOptions, + scanQuarantinedDownloadMutationOptions, +} from "@/lib/computers/mutations"; +import { + quarantineQueryOptions, + type QuarantineEntry, +} from "@/lib/computers/queries"; + +export function ComputerQuarantineDialog({ + botId, + botName, + open, + onOpenChange, +}: { + botId: string; + botName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const quarantine = useQuery({ + ...quarantineQueryOptions(botId), + enabled: open, + }); + const scan = useMutation(scanQuarantinedDownloadMutationOptions(queryClient)); + const approve = useMutation( + approveQuarantinedDownloadMutationOptions(queryClient), + ); + const exportFile = useMutation( + exportQuarantinedDownloadMutationOptions(queryClient), + ); + + const problem = + quarantine.error instanceof Error + ? quarantine.error.message + : scan.error instanceof Error + ? scan.error.message + : approve.error instanceof Error + ? approve.error.message + : exportFile.error instanceof Error + ? exportFile.error.message + : null; + + const busyId = + (scan.isPending ? scan.variables?.id : null) ?? + (approve.isPending ? approve.variables?.id : null) ?? + (exportFile.isPending ? exportFile.variables?.id : null) ?? + null; + + return ( + + + + {botName}'s quarantine + + Browser downloads stay isolated until they pass malware scanning, + are explicitly approved, and you choose a Windows destination. + + + +
+ Untrusted files. Export uses a native Save As dialog, + verifies the approved SHA-256 and byte size again, and never + auto-opens or runs the file. +
+ + {problem ? ( +

+ {problem} +

+ ) : null} + +
+ {quarantine.isPending ? ( +

Loading…

+ ) : (quarantine.data?.downloads.length ?? 0) === 0 ? ( +

+ No quarantined downloads. +

+ ) : ( +
+ {quarantine.data?.downloads.map((entry) => ( +
+
+
+

+ {entry.originalName} +

+

+ {formatBytes(entry.sizeBytes)} · downloaded{" "} + {new Date(entry.savedAt).toLocaleString()} +

+
+ +
+ +
+
Source
+
+ {entry.sourceUrl} +
+
SHA-256
+
{entry.sha256}
+ {entry.scan ? ( + <> +
Scanner
+
+ {entry.scan.scanner} · {entry.scan.detail} +
+ + ) : null} +
+ +
+ {entry.status === "pending" || + entry.status === "blocked" || + entry.status === "scan_failed" ? ( + + ) : null} + + {entry.status === "clean" ? ( + + ) : null} + + {entry.status === "approved" ? ( + + ) : null} +
+
+ ))} +
+ )} +
+ + + + A failed scan is never treated as clean. Export requires native + confirmation every time. + + + +
+
+ ); +} + +function StatusBadge({ entry }: { entry: QuarantineEntry }) { + const label = { + pending: "Needs scan", + clean: "Clean · approval needed", + blocked: "Blocked", + scan_failed: "Scan failed", + approved: "Approved · not exported", + released: "Exported", + }[entry.status]; + + const tone = + entry.status === "blocked" || entry.status === "scan_failed" + ? "border-destructive/40 text-destructive" + : entry.status === "clean" || + entry.status === "approved" || + entry.status === "released" + ? "border-emerald-500/40 text-emerald-700 dark:text-emerald-300" + : "border-amber-500/40 text-amber-700 dark:text-amber-300"; + + return ( + + {label} + + ); +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"] as const; + let value = bytes; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + return `${value >= 10 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`; +} From 079bda4ace471c6350e75c4057fd7c6ac73b326f Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:56:35 +0700 Subject: [PATCH 23/33] feat(app): expose quarantine manager in Computer Manager --- app/src/routes/_authed/admin/computers.tsx | 41 ++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/app/src/routes/_authed/admin/computers.tsx b/app/src/routes/_authed/admin/computers.tsx index 378a703d7..2e91d50e1 100644 --- a/app/src/routes/_authed/admin/computers.tsx +++ b/app/src/routes/_authed/admin/computers.tsx @@ -9,6 +9,7 @@ import { } from "@/components/layout/page-shell"; import { StaggerItem } from "@/components/layout/stagger"; import { ComputerFilesDialog } from "@/components/computers/computer-files-dialog"; +import { ComputerQuarantineDialog } from "@/components/computers/computer-quarantine-dialog"; import { ComputerScreenDialog } from "@/components/computers/computer-screen-dialog"; import { Button } from "@/components/ui/button"; import { @@ -56,6 +57,8 @@ function ComputersPage() { const [filesFor, setFilesFor] = useState(null); /** Computer whose browser is being watched or driven by the administrator. */ const [screenFor, setScreenFor] = useState(null); + /** Computer whose untrusted browser downloads are being reviewed. */ + const [quarantineFor, setQuarantineFor] = useState(null); const queryClient = useQueryClient(); const nameFor = useBotNames(); @@ -118,6 +121,18 @@ function ComputersPage() { } }; + const showQuarantine = async (botId: string, running: boolean) => { + setBusy(botId); + try { + if (!running) { + await setState.mutateAsync({ action: "start", botId }); + } + setQuarantineFor(botId); + } finally { + setBusy(null); + } + }; + return ( Files + From a3c725edc93ed2cddeaa85af4b8a4cd272bca8ab Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:58:38 +0700 Subject: [PATCH 28/33] style(app): format Computer Manager quarantine copy --- app/src/routes/_authed/admin/computers.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/routes/_authed/admin/computers.tsx b/app/src/routes/_authed/admin/computers.tsx index 2e91d50e1..884903eae 100644 --- a/app/src/routes/_authed/admin/computers.tsx +++ b/app/src/routes/_authed/admin/computers.tsx @@ -400,9 +400,9 @@ function ComputersPage() { Quarantine scans and explicitly exports untrusted downloads, Restart cycles it without deleting saved state, and Stop releases runtime resources while - keeping its profile and workspace. Reset deletes its profile, - workspace and quarantine and starts clean. Lifecycle actions are - recorded in{" "} + keeping its profile and workspace. Reset deletes its + profile, workspace and quarantine and starts clean. Lifecycle actions + are recorded in{" "} Audit From 2e7f13f00fc84090125a091d0002d5241f924cda Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:58:41 +0700 Subject: [PATCH 29/33] style(server): format quarantine export route --- server/src/host-access/routes.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/server/src/host-access/routes.ts b/server/src/host-access/routes.ts index 644b96c2e..339cd914b 100644 --- a/server/src/host-access/routes.ts +++ b/server/src/host-access/routes.ts @@ -37,7 +37,6 @@ function dangerousExportName(name: string): boolean { ); } - async function audit( auditStore: AuditStore | undefined, input: { @@ -224,11 +223,7 @@ export function createHostAccessRoutes(options: { } | null; const botId = typeof body?.botId === "string" ? body.botId.trim() : ""; const id = typeof body?.id === "string" ? body.id.trim() : ""; - if ( - !botId || - !id || - body?.confirm !== "EXPORT_QUARANTINED_FILE" - ) { + if (!botId || !id || body?.confirm !== "EXPORT_QUARANTINED_FILE") { return context.json( { error: @@ -244,7 +239,10 @@ export function createHostAccessRoutes(options: { } const gateway = options.computerGateway; if (!gateway) { - return context.json({ error: "Quarantine export is not configured." }, 503); + return context.json( + { error: "Quarantine export is not configured." }, + 503, + ); } if (!broker.statusFor(actor.id).connected) { return context.json( @@ -295,8 +293,7 @@ export function createHostAccessRoutes(options: { id, ); await audit(auditStore, { - actorUserId: - actor.email === "dev@openbot.local" ? undefined : actor.id, + actorUserId: actor.email === "dev@openbot.local" ? undefined : actor.id, targetId: id, change: "quarantine_exported", botId, From b495f69ed9ce499fe6b7a68c108b690c2eb72024 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:58:44 +0700 Subject: [PATCH 30/33] style(server): format quarantine broker tests --- server/tests/host-access-broker.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/tests/host-access-broker.test.ts b/server/tests/host-access-broker.test.ts index 70af9b95c..f73ce51f2 100644 --- a/server/tests/host-access-broker.test.ts +++ b/server/tests/host-access-broker.test.ts @@ -417,5 +417,4 @@ describe("host access broker", () => { await expect(pending).rejects.toThrow("denied"); expect(broker.quarantineExportSource(operation!.operationId)).toBeNull(); }); - }); From 79a31bab429ad8c7a982fcdeaca955ef5686063c Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 02:58:57 +0700 Subject: [PATCH 31/33] style(server): format quarantine export route tests --- server/tests/host-access-routes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/host-access-routes.test.ts b/server/tests/host-access-routes.test.ts index 446805fa5..0fe07aa8a 100644 --- a/server/tests/host-access-routes.test.ts +++ b/server/tests/host-access-routes.test.ts @@ -115,6 +115,7 @@ describe("host access routes", () => { error: "That Bot is not available to you.", }); }); + test("quarantine is marked released only after native export succeeds", async () => { let released = 0; const approved = { @@ -239,5 +240,4 @@ describe("host access routes", () => { expect(response.status).toBe(409); expect(released).toBe(0); }); - }); From e0633b6370a5fdf321c9534840b1a4ae255c6dda Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 03:01:29 +0700 Subject: [PATCH 32/33] fix(server): satisfy quarantine export lint guards --- server/src/host-access/routes.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/src/host-access/routes.ts b/server/src/host-access/routes.ts index 339cd914b..de3fc5834 100644 --- a/server/src/host-access/routes.ts +++ b/server/src/host-access/routes.ts @@ -258,8 +258,7 @@ export function createHostAccessRoutes(options: { const listed = await gateway.listQuarantine(botId); const record = listed.downloads.find((entry) => entry.id === id); if ( - !record || - record.status !== "approved" || + record?.status !== "approved" || record.scan?.status !== "clean" || record.scannedSha256 !== record.sha256 ) { @@ -278,7 +277,7 @@ export function createHostAccessRoutes(options: { sizeBytes: record.sizeBytes, dangerous: dangerousExportName(suggestedName), }); - if (!result || result.exported !== true) { + if (result?.exported !== true) { throw new HostAccessRefusedError( "The native desktop did not confirm a completed export.", ); From 969f7913827bc9d9f13e82fb84e7ba346ca7acb7 Mon Sep 17 00:00:00 2001 From: duc15052006-dotcom Date: Sat, 19 Sep 2026 03:03:46 +0700 Subject: [PATCH 33/33] fix(release): align quarantine export preflight with guarded code --- scripts/release-preflight.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/release-preflight.ts b/scripts/release-preflight.ts index 4b98b6fe5..b772bd0e8 100644 --- a/scripts/release-preflight.ts +++ b/scripts/release-preflight.ts @@ -421,7 +421,7 @@ function checkInteractiveComputerControls(): void { for (const evidence of [ 'routes.post("/quarantine/export"', 'body?.confirm !== "EXPORT_QUARANTINED_FILE"', - 'record.status !== "approved"', + 'record?.status !== "approved"', "record.scannedSha256 !== record.sha256", "broker.requestQuarantineExport", "gateway.markQuarantineReleased", @@ -454,7 +454,7 @@ function checkInteractiveComputerControls(): void { "approveQuarantinedDownloadMutationOptions", "exportQuarantinedDownloadMutationOptions", "Export to Windows…", - "never auto-opens or runs the file", + "auto-opens or runs the file.", ]) { if (!quarantineDialog.includes(evidence)) { fail(`computer: quarantine manager is missing ${evidence}`);