diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 93e60739b1..45797f650b 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -21,6 +21,42 @@
The Electron desktop app: `main` (Node/Electron main process) + `preload` (context bridge) + `renderer` (React UI). This file covers the three-layer split and the IPC contract. For build/test commands and the test-layer selection guide, see the top-level `README.md`; for the renderer interior, see `src/renderer/README.md`.
+## Managed HTML Artifact previews
+
+Generated Files → the HTML file's menu → **Open in default app** opens a
+Desktop-owned HTTP snapshot in the system browser. The session-bound
+`ArtifactPreview({ artifactId })` tool prepares the same kind of endpoint for
+browser tools without starting a shell server or relaxing the `file://` policy.
+Save As and Show in Folder still export the original, unrestricted file.
+
+Snapshots support self-contained interactive HTML: inline scripts/styles and
+embedded images/fonts. A response CSP sandbox blocks same-origin authority,
+fetch requests, remote subresources, forms, frames and popups. This is not an
+OS network sandbox: an external browser can still navigate away from the
+document. Referenced workspace files are not served. Use Save As for documents
+requiring external resources.
+
+Each snapshot gets its own loopback port and a 256-bit bearer URL. Do not share
+the URL. There is no directory listing, CORS access, persistent disk copy or
+cache. The server checks the exact Host and path, accepts GET/HEAD only, caps
+each snapshot at 8 MiB and reserves at most 16 concurrent snapshots. Listeners
+and memory are released after 30 minutes, on Artifact deletion through the
+Desktop, on host-target retirement, or when the app exits. A remote Host's
+Artifact is streamed to the Desktop through the existing authenticated client;
+the resulting URL belongs to the Desktop machine, not the Host's localhost.
+
+`reachable: true` is evidence that a bounded Desktop HTTP probe succeeded.
+`loaded: false` deliberately does **not** assert browser rendering. An OS
+launch success also does not prove page load; browser observation is required
+before reporting that the page loaded or its interaction worked (#5235).
+
+Focused regression checks (build workspace dependencies first):
+
+```sh
+npm run build:main --workspace apps/desktop
+node --test apps/desktop/dist/main/__tests__/managed-artifact-preview.test.js apps/desktop/dist/main/__tests__/runtime-host-artifacts-ipc-main.test.js
+```
+
## macOS development permissions
`npm run dev` and `npm start` use the plain Electron executable on every
diff --git a/apps/desktop/src/main/__tests__/managed-artifact-preview.test.ts b/apps/desktop/src/main/__tests__/managed-artifact-preview.test.ts
new file mode 100644
index 0000000000..61c99344a7
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/managed-artifact-preview.test.ts
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { request } from 'node:http';
+import { test } from 'node:test';
+import { z } from 'zod';
+import { ManagedArtifactPreview, PREVIEW_MAX_BYTES } from '../managed-artifact-preview.js';
+import { buildManagedArtifactPreviewTools } from '../managed-artifact-preview-tools.js';
+import type { DesktopRuntimeHostClient } from '../runtime-host-client.js';
+
+const html = '
Counter';
+function client(content = html): Pick {
+ const bytes = Buffer.from(content);
+ return {
+ getArtifact: async () => ({ id: 'a1', sessionId: 's1', turnId: 't1', createdAt: 0, name: 'counter.html', kind: 'html', sizeBytes: bytes.length, source: 'tool_result' }),
+ streamArtifact: async (_sessionId, _artifactId, write) => { await write(bytes); return bytes.length; },
+ };
+}
+
+function status(url: string, options: { host?: string; method?: string; path?: string } = {}): Promise {
+ return new Promise((resolve, reject) => {
+ const req = request(url, {
+ method: options.method ?? 'GET',
+ ...(options.path ? { path: options.path } : {}),
+ ...(options.host ? { headers: { Host: options.host } } : {}),
+ }, (res) => { res.resume(); resolve(res.statusCode!); });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+test('serves exact registered bytes over loopback without claiming a browser load', async () => {
+ const service = new ManagedArtifactPreview();
+ try {
+ const endpoint = await service.prepare('host1', client(), 's1', 'a1');
+ assert.equal(new URL(endpoint.url).hostname, '127.0.0.1');
+ assert.match(new URL(endpoint.url).pathname, /^\/[a-f0-9]{64}\/index.html$/);
+ assert.equal(endpoint.reachable, true);
+ assert.equal(endpoint.loaded, false);
+ assert.ok(endpoint.expiresAt > Date.now());
+ const response = await fetch(endpoint.url);
+ assert.equal(response.status, 200);
+ assert.equal(await response.text(), html);
+ assert.equal(response.headers.get('cache-control'), 'no-store');
+ assert.equal(response.headers.get('referrer-policy'), 'no-referrer');
+ assert.equal(response.headers.get('access-control-allow-origin'), null);
+ const csp = response.headers.get('content-security-policy')!;
+ assert.match(csp, /sandbox allow-scripts;/);
+ assert.doesNotMatch(csp, /allow-same-origin|allow-popups|allow-top-navigation/);
+ assert.match(csp, /connect-src 'none'/);
+ assert.equal(await status(endpoint.url, { method: 'HEAD' }), 200);
+ assert.equal(await status(endpoint.url, { method: 'POST' }), 405);
+ assert.equal(await status(endpoint.url, { host: 'attacker.example' }), 404);
+ for (const path of ['/', '/favicon.ico', '/wrong/index.html', '/../secret', new URL(endpoint.url).pathname + '?query=1']) {
+ assert.equal(await status(endpoint.url, { path }), 404);
+ }
+ } finally { await service.close(); }
+});
+
+test('isolates leases by origin and rejects credentials for another preview', async () => {
+ const service = new ManagedArtifactPreview();
+ try {
+ const first = await service.prepare('host1', client(), 's1', 'a1');
+ const second = await service.prepare('host2', client('second'), 's1', 'a1');
+ assert.notEqual(new URL(first.url).origin, new URL(second.url).origin);
+ assert.equal(await status(first.url, { path: new URL(second.url).pathname }), 404);
+ await service.closeScope('host1');
+ await assert.rejects(fetch(first.url));
+ assert.equal(await (await fetch(second.url)).text(), 'second');
+ await assert.rejects(service.prepare('host1', client(), 's1', 'a1'), /closed/);
+ await service.revoke('host2', 's1', 'a1');
+ await assert.rejects(fetch(second.url));
+ } finally { await service.close(); }
+});
+
+test('expires and closes its listener without deleting the durable Artifact', async () => {
+ const service = new ManagedArtifactPreview(25);
+ try {
+ const source = client();
+ const endpoint = await service.prepare('host1', source, 's1', 'a1');
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ await assert.rejects(fetch(endpoint.url));
+ assert.equal((await source.getArtifact('s1', 'a1'))?.kind, 'html');
+ } finally { await service.close(); }
+});
+
+test('rejects missing, non-HTML, oversized, inconsistent and malformed artifacts', async () => {
+ const service = new ManagedArtifactPreview();
+ try {
+ await assert.rejects(service.prepare('h', client(), '../session', 'a1'), /identity/);
+ await assert.rejects(service.prepare('h', { ...client(), getArtifact: async () => null }, 's1', 'a1'), /existing HTML/);
+ const source = client();
+ const artifact = (await source.getArtifact('s1', 'a1'))!;
+ for (const patch of [{ kind: 'file' as const }, { sizeBytes: PREVIEW_MAX_BYTES + 1 }, { sizeBytes: -1 }]) {
+ await assert.rejects(service.prepare('h', { ...source, getArtifact: async () => ({ ...artifact, ...patch }) }, 's1', 'a1'));
+ }
+ await assert.rejects(service.prepare('h', { ...source, streamArtifact: async () => 0 }, 's1', 'a1'), /size mismatch/);
+ await assert.rejects(service.prepare('h', { ...source, streamArtifact: async (_s, _a, write) => { await write(Buffer.alloc(PREVIEW_MAX_BYTES + 1)); return 0; } }, 's1', 'a1'), /size mismatch/);
+ await assert.rejects(service.prepare('h', { ...source, streamArtifact: async () => { throw new Error('Read failed'); } }, 's1', 'a1'), /Read failed/);
+ // Failed preparations release reservations and do not poison subsequent attempts.
+ assert.equal((await service.prepare('h', source, 's1', 'a1')).reachable, true);
+ } finally { await service.close(); }
+});
+
+test('close and cancellation during a stream cannot publish a live endpoint', async () => {
+ for (const cancel of [false, true]) {
+ const service = new ManagedArtifactPreview();
+ const abort = new AbortController();
+ const source = client();
+ try {
+ await assert.rejects(service.prepare('h', {
+ ...source,
+ streamArtifact: async (...args) => {
+ if (cancel) abort.abort(); else await service.closeScope('h');
+ return source.streamArtifact(...args);
+ },
+ }, 's1', 'a1', abort.signal));
+ } finally { await service.close(); }
+ }
+});
+
+test('bounds concurrent preparations before allocating buffers or ports', async () => {
+ const service = new ManagedArtifactPreview();
+ let resume!: () => void;
+ const gate = new Promise((resolve) => { resume = resolve; });
+ const source = client();
+ const slow = { ...source, getArtifact: async (s: string, a: string) => { await gate; return source.getArtifact(s, a); } };
+ const pending = Array.from({ length: 16 }, () => service.prepare('h', slow, 's1', 'a1'));
+ try {
+ await assert.rejects(service.prepare('h', slow, 's1', 'a1'), /Too many/);
+ resume();
+ assert.equal((await Promise.all(pending)).length, 16);
+ } finally { resume(); await Promise.allSettled(pending); await service.close(); }
+});
+
+test('tool binds to the admitted session and returns endpoint evidence only', async () => {
+ const service = new ManagedArtifactPreview();
+ try {
+ const [tool] = buildManagedArtifactPreviewTools((sessionId, artifactId, signal) => {
+ assert.equal(sessionId, 's1');
+ return service.prepare('h', client(), sessionId, artifactId, signal);
+ });
+ assert.equal((tool!.parameters as z.ZodType).safeParse({ artifactId: 'a1', sessionId: 'other' }).success, false);
+ const result = await tool!.impl({ artifactId: 'a1' }, { sessionId: 's1', turnId: 't1', cwd: '/tmp', toolCallId: 'c1', abortSignal: new AbortController().signal, emitOutput: () => {} });
+ assert.equal((result as { loaded: boolean }).loaded, false);
+ } finally { await service.close(); }
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts
index a884d15d2d..420765c7e0 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts
@@ -24,6 +24,46 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { registerRuntimeHostArtifactsIpc } from "../runtime-host-artifacts-ipc-main.js";
+import { ManagedArtifactPreview } from '../managed-artifact-preview.js';
+
+for (const launchFails of [false, true]) {
+ test(`HTML external open uses the managed endpoint and reports launch failure=${launchFails}`, async () => {
+ const service = new ManagedArtifactPreview();
+ const handlers = new Map();
+ const bytes = Buffer.from('Managed preview');
+ let openedUrl = '';
+ try {
+ registerRuntimeHostArtifactsIpc({
+ uiLocale: () => 'en',
+ ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) },
+ client: {
+ hostEpoch: 'h',
+ getArtifact: async () => previewArtifact({ name: 'preview.html', kind: 'html', sizeBytes: bytes.length }),
+ streamArtifact: async (_s: string, _a: string, write: (chunk: Uint8Array) => Promise) => { await write(bytes); return bytes.length; },
+ deleteArtifact: async () => ({ ok: true }),
+ } as never,
+ mainWindowController: {} as never,
+ showItemInFolder: () => assert.fail('HTML must not silently fall back to Finder'),
+ openPath: async () => assert.fail('Managed preview must not open file URLs'),
+ preview: { service, scope: 'h', openExternal: async (url) => {
+ openedUrl = url;
+ assert.equal(await (await fetch(url)).text(), bytes.toString());
+ if (launchFails) throw new Error('No browser available');
+ } },
+ });
+ const result = await handlers.get('app:openArtifactPath')!({}, 's1', 'a1');
+ if (launchFails) {
+ assert.deepEqual(result, { ok: false, reason: 'open-failed' });
+ await assert.rejects(fetch(openedUrl));
+ } else {
+ assert.equal((result as { loaded: boolean }).loaded, false);
+ assert.equal((result as { reachable: boolean }).reachable, true);
+ await handlers.get('artifacts:delete')!({}, 's1', 'a1');
+ await assert.rejects(fetch(openedUrl));
+ }
+ } finally { await service.close(); }
+ });
+}
type Handler = (event: unknown, ...args: any[]) => unknown;
type StreamArtifact = (
diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts
index 8e1e9df174..6bca312df3 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts
@@ -31,6 +31,7 @@ import {
} from '@maka/runtime-host/protocol';
import { z } from 'zod';
import { buildClientSettingsTools } from '../client-settings-tools.js';
+import { buildManagedArtifactPreviewTools } from '../managed-artifact-preview-tools.js';
import { browserOriginAdmission } from '../browser/browser-origin-admission.js';
import { buildRiveWorkflowTool } from '../rive-workflow-tool.js';
import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js';
@@ -41,6 +42,32 @@ function jsonSchema(schema: Record): {
return { jsonSchema: schema };
}
+test('Artifact preview is discoverable and admitted without a pre-existing browser origin', async () => {
+ let invoked = false;
+ const provider = createDesktopNativeCapabilityProvider({
+ browserTools: [],
+ resolveBrowserUrl: () => { throw new Error('No browser page exists'); },
+ releaseBrowserSession() {},
+ computerUseTools: computerTools(),
+ releaseDesktopInteractionSession() {},
+ additionalGroups: () => [{
+ offerId: 'desktop_artifact_preview', label: 'HTML preview', description: 'Prepare HTML preview',
+ tools: buildManagedArtifactPreviewTools(async (sessionId, artifactId, signal) => {
+ assert.equal(sessionId, 'session-1');
+ assert.equal(artifactId, 'artifact-1');
+ signal.throwIfAborted();
+ invoked = true;
+ return { url: 'http://127.0.0.1:12345/token/index.html', expiresAt: 123456, reachable: true, loaded: false };
+ }),
+ }],
+ }, { nativeSessionId: (sessionId) => `native:${sessionId}` });
+ assert.doesNotThrow(() => decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', offers: provider.offers() }));
+ assert.ok(provider.offers().some((offer) => offer.offerId === 'desktop_artifact_preview' && offer.tools.some((tool) => tool.name === 'ArtifactPreview')));
+ const result = await call(provider, capabilityFrame({ offerId: 'desktop_artifact_preview', serverId: 'desktop_artifact_preview', toolName: 'ArtifactPreview', arguments: { artifactId: 'artifact-1' } }));
+ assert.equal(invoked, true);
+ assert.deepEqual(result.structuredContent, { url: 'http://127.0.0.1:12345/token/index.html', expiresAt: 123456, reachable: true, loaded: false });
+});
+
test('publishes self-described session-affine Browser and Computer Use offers', () => {
const provider = createDesktopNativeCapabilityProvider({
browserTools: [tool('browser_snapshot', z.object({ includeHidden: z.boolean().optional() }), async () => 'ok')],
diff --git a/apps/desktop/src/main/managed-artifact-preview-tools.ts b/apps/desktop/src/main/managed-artifact-preview-tools.ts
new file mode 100644
index 0000000000..939a78c2f5
--- /dev/null
+++ b/apps/desktop/src/main/managed-artifact-preview-tools.ts
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { MakaTool } from '@maka/runtime/tool-runtime';
+import { z } from 'zod';
+import type { ArtifactPreviewEndpoint } from './managed-artifact-preview.js';
+
+export function buildManagedArtifactPreviewTools(
+ prepare: (sessionId: string, artifactId: string, signal: AbortSignal) => Promise,
+): readonly MakaTool[] {
+ const tool: MakaTool<{ artifactId: string }, ArtifactPreviewEndpoint> = {
+ name: 'ArtifactPreview',
+ displayName: 'Prepare HTML preview',
+ description: 'Create a Desktop-managed, temporary HTTP URL for an HTML Artifact in the current session. No shell server or file:// navigation is needed. The URL is a bearer capability: do not share it. It expires after 30 minutes or when the client disconnects. Only self-contained HTML is supported: inline scripts/styles and embedded images; remote subresources, fetch requests, forms and local file access are blocked. This is not OS network isolation: an external browser can navigate away from the document. reachable confirms a Desktop HTTP check, NOT browser load. Use browser navigation and observation to verify rendering and interactions. On failure, use Generated Files → Save As or Show in Folder; do not claim the preview opened.',
+ parameters: z.object({ artifactId: z.string().min(1).max(128) }).strict(),
+ categoryHint: 'custom_tool',
+ recoveryMode: 'never_auto_retry',
+ impl: (input, context) => prepare(context.sessionId, input.artifactId, context.abortSignal),
+ };
+ return [tool];
+}
diff --git a/apps/desktop/src/main/managed-artifact-preview.ts b/apps/desktop/src/main/managed-artifact-preview.ts
new file mode 100644
index 0000000000..16d7bf5b75
--- /dev/null
+++ b/apps/desktop/src/main/managed-artifact-preview.ts
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { randomBytes } from 'node:crypto';
+import { createServer, request, type Server } from 'node:http';
+import { isCanonicalArtifactEntityId } from '@maka/core/artifacts';
+import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
+
+export const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
+const MAX_PREVIEWS = 16;
+const PREVIEW_TTL_MS = 30 * 60 * 1000;
+const READ_DEADLINE_MS = 30_000;
+
+export interface ArtifactPreviewEndpoint {
+ readonly url: string;
+ readonly expiresAt: number;
+ readonly reachable: true;
+ readonly loaded: false;
+}
+
+type ArtifactClient = Pick;
+interface Lease {
+ scope: string;
+ sessionId: string;
+ artifactId: string;
+ server: Server;
+ timer?: ReturnType;
+ url?: string;
+}
+
+/** Desktop-owned, bounded, ephemeral HTML snapshots. No workspace directory is served. */
+export class ManagedArtifactPreview {
+ private readonly leases = new Set();
+ private readonly retiredScopes = new Set();
+ private closed = false;
+
+ constructor(private readonly ttlMs = PREVIEW_TTL_MS) {}
+
+ async releaseUrl(url: string): Promise {
+ const lease = [...this.leases].find((entry) => entry.url === url);
+ if (lease) await this.release(lease);
+ }
+
+ async prepare(
+ scope: string,
+ client: ArtifactClient,
+ sessionId: string,
+ artifactId: string,
+ signal?: AbortSignal,
+ ): Promise {
+ if (!isCanonicalArtifactEntityId(sessionId) || !isCanonicalArtifactEntityId(artifactId)) {
+ throw new Error('Invalid Artifact identity');
+ }
+ if (this.closed || this.retiredScopes.has(scope)) throw new Error('Preview owner is closed');
+ if (this.leases.size >= MAX_PREVIEWS) throw new Error('Too many active previews; wait for expiry');
+ signal?.throwIfAborted();
+ // Reserve before asynchronous reads, so concurrent preparations cannot exceed the bound.
+ const lease: Lease = { scope, sessionId, artifactId, server: createServer() };
+ this.leases.add(lease);
+ const assertActive = () => {
+ signal?.throwIfAborted();
+ if (!this.leases.has(lease)) throw new Error('Preview owner is closed');
+ };
+ try {
+ const artifact = await withDeadline(client.getArtifact(sessionId, artifactId), signal);
+ assertActive();
+ if (!artifact || artifact.kind !== 'html') throw new Error('An existing HTML Artifact is required');
+ if (!Number.isSafeInteger(artifact.sizeBytes) || artifact.sizeBytes < 0 || artifact.sizeBytes > PREVIEW_MAX_BYTES) {
+ throw new Error('HTML preview exceeds the 8 MiB limit; use Save As instead');
+ }
+ const chunks: Buffer[] = [];
+ let size = 0;
+ const total = await withDeadline(client.streamArtifact(sessionId, artifactId, async (chunk) => {
+ assertActive();
+ size += chunk.byteLength;
+ if (size > artifact.sizeBytes || size > PREVIEW_MAX_BYTES) throw new Error('Artifact size mismatch');
+ chunks.push(Buffer.from(chunk));
+ }), signal);
+ assertActive();
+ if (size !== artifact.sizeBytes || total !== size) throw new Error('Artifact size mismatch');
+ const bytes = Buffer.concat(chunks, size);
+ const path = `/${randomBytes(32).toString('hex')}/index.html`;
+ let host = '';
+ // One origin per lease and a sandbox without same-origin authority keep previews isolated.
+ lease.server.on('request', (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('Referrer-Policy', 'no-referrer');
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ if (req.headers.host !== host || req.url !== path || !this.leases.has(lease)) {
+ res.writeHead(404).end();
+ return;
+ }
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ res.writeHead(405, { Allow: 'GET, HEAD' }).end();
+ return;
+ }
+ res.setHeader('Content-Security-Policy', "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': bytes.length });
+ res.end(req.method === 'HEAD' ? undefined : bytes);
+ });
+ lease.server.requestTimeout = 5_000;
+ lease.server.headersTimeout = 5_000;
+ lease.server.maxConnections = 16;
+ await new Promise((resolve, reject) => {
+ lease.server.once('error', reject);
+ lease.server.listen(0, '127.0.0.1', () => {
+ lease.server.removeListener('error', reject);
+ resolve();
+ });
+ });
+ assertActive();
+ const address = lease.server.address();
+ if (!address || typeof address === 'string') throw new Error('Preview listener is unavailable');
+ host = `127.0.0.1:${address.port}`;
+ const url = `http://${host}${path}`;
+ lease.url = url;
+ // A listening socket alone is not readiness evidence. Check the exact authorized route.
+ await new Promise((resolve, reject) => {
+ const probe = request(url, { method: 'HEAD', signal: AbortSignal.timeout(3_000) }, (response) => {
+ response.resume();
+ if (response.statusCode === 200 && response.headers['content-length'] === String(bytes.length)) resolve();
+ else reject(new Error('Preview endpoint health check failed'));
+ });
+ probe.on('error', reject);
+ probe.end();
+ });
+ assertActive();
+ const expiresAt = Date.now() + this.ttlMs;
+ lease.timer = setTimeout(() => { void this.release(lease); }, this.ttlMs);
+ lease.timer.unref();
+ lease.server.unref();
+ return { url, expiresAt, reachable: true, loaded: false };
+ } catch (error) {
+ await this.release(lease);
+ throw error;
+ }
+ }
+
+ async revoke(scope: string, sessionId: string, artifactId: string): Promise {
+ await Promise.all([...this.leases].filter((lease) => lease.scope === scope && lease.sessionId === sessionId && lease.artifactId === artifactId).map((lease) => this.release(lease)));
+ }
+
+ async closeScope(scope: string): Promise {
+ this.retiredScopes.add(scope);
+ await Promise.all([...this.leases].filter((lease) => lease.scope === scope).map((lease) => this.release(lease)));
+ }
+
+ async close(): Promise {
+ this.closed = true;
+ await Promise.all([...this.leases].map((lease) => this.release(lease)));
+ }
+
+ private async release(lease: Lease): Promise {
+ this.leases.delete(lease);
+ clearTimeout(lease.timer);
+ await new Promise((resolve) => {
+ lease.server.close(() => resolve());
+ lease.server.closeAllConnections();
+ });
+ lease.server.removeAllListeners('request');
+ }
+}
+
+async function withDeadline(promise: Promise, signal?: AbortSignal): Promise {
+ const timeout = new Promise((_, reject) => {
+ const timer = setTimeout(() => reject(new Error('Artifact preview read timed out')), READ_DEADLINE_MS);
+ timer.unref();
+ });
+ const cancelled = signal ? new Promise((_, reject) => {
+ if (signal.aborted) reject(signal.reason ?? new Error('The preview request was cancelled'));
+ else signal.addEventListener('abort', () => reject(signal.reason ?? new Error('The preview request was cancelled')), { once: true });
+ }) : undefined;
+ return Promise.race([promise, timeout, ...(cancelled ? [cancelled] : [])]);
+}
diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
index c0dc66b1df..88a7b14e90 100644
--- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
@@ -35,6 +35,7 @@ import {
} from "./ipc-reconnect-policy.js";
import type { createMainWindowController } from "./main-window.js";
import type { DesktopRuntimeHostClient } from "./runtime-host-client.js";
+import type { ManagedArtifactPreview } from './managed-artifact-preview.js';
interface RuntimeHostArtifactsIpcDeps {
uiLocale(): UiLocale;
@@ -43,6 +44,7 @@ interface RuntimeHostArtifactsIpcDeps {
readonly mainWindowController: ReturnType;
readonly showItemInFolder: (path: string) => void;
readonly openPath?: (path: string) => Promise;
+ readonly preview?: { service: ManagedArtifactPreview; scope: string; openExternal: (url: string) => Promise };
readonly presentationRoot?: string;
}
@@ -79,8 +81,11 @@ export function registerRuntimeHostArtifactsIpc(
);
deps.ipcMain.handle(
"artifacts:delete",
- (_event, sessionId: string, artifactId: string) =>
- deps.client.deleteArtifact(sessionId, artifactId),
+ async (_event, sessionId: string, artifactId: string) => {
+ const result = await deps.client.deleteArtifact(sessionId, artifactId);
+ await deps.preview?.service.revoke(deps.preview.scope, sessionId, artifactId);
+ return result;
+ },
);
registerRuntimeHostAttachmentPreviewIpc(deps);
const materializePresentationArtifact = async (
@@ -105,6 +110,16 @@ export function registerRuntimeHostArtifactsIpc(
return { ok: false as const, reason: "missing" as const };
}
try {
+ if (artifact.kind === 'html' && deps.preview) {
+ const endpoint = await deps.preview.service.prepare(deps.preview.scope, deps.client, sessionId, artifactId);
+ try {
+ await deps.preview.openExternal(endpoint.url);
+ } catch (error) {
+ await deps.preview.service.releaseUrl(endpoint.url);
+ throw error;
+ }
+ return { ok: true as const, opened: artifact.name, ...endpoint };
+ }
const path = await materializePresentationArtifact(sessionId, artifactId, artifact);
if (artifact.kind === 'html' && deps.openPath) {
const error = await deps.openPath(path);
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 6757924296..fe49fdff85 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -190,6 +190,8 @@ import { registerClientSettingsIpc } from "./client-settings-ipc-main.js";
import { startClientSettingsWatcher } from "./client-settings-watcher.js";
import { registerRuntimeHostGitHubCopilotIpc } from "./runtime-host-github-copilot-ipc-main.js";
import { registerRuntimeHostArtifactsIpc } from "./runtime-host-artifacts-ipc-main.js";
+import { ManagedArtifactPreview } from './managed-artifact-preview.js';
+import { buildManagedArtifactPreviewTools } from './managed-artifact-preview-tools.js';
import type { DesktopRuntimeHostClient } from "./runtime-host-client.js";
import type {
DesktopRuntimeHostCandidateControls,
@@ -1024,6 +1026,7 @@ const clientSettingsTools = buildClientSettingsTools({
return result.response === 0;
},
});
+const managedArtifactPreview = new ManagedArtifactPreview();
const clientSettingsWatcher = startClientSettingsWatcher(
workspaceRoot,
() => {
@@ -1159,6 +1162,17 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager(
}
return [
workHubControl.group(scope),
+ {
+ offerId: 'desktop_artifact_preview',
+ label: 'HTML Artifact preview',
+ description: 'Prepare an isolated, temporary HTTP preview of a generated HTML Artifact.',
+ tools: buildManagedArtifactPreviewTools(async (sessionId, artifactId, signal) => {
+ if (!scope || !runtimeHostManager?.ownsScope(scope)) throw new Error('Preview target is unavailable');
+ const target = runtimePolicyTargetsByEpoch.get(scope.targetEpoch);
+ if (!target?.isActive()) throw new Error('Preview target is no longer active');
+ return managedArtifactPreview.prepare(scope.targetEpoch, target.client, sessionId, artifactId, signal);
+ }),
+ },
{
offerId: "desktop_settings",
label: "Client settings",
@@ -1666,6 +1680,7 @@ function registerHostClientIpc(
mainWindowController,
showItemInFolder: (path) => shell.showItemInFolder(path),
openPath: (path) => shell.openPath(path),
+ preview: { service: managedArtifactPreview, scope: scope.targetEpoch, openExternal: (url) => shell.openExternal(url) },
});
registerExternalAgentSetupIpc({ ipcMain: scopedIpc, client, presentation: oauthPresentation,
selectExecutable: async () => {
@@ -1876,6 +1891,7 @@ function registerHostClientIpc(
registerTaskSubmissionReadinessIpc(taskSubmissionReadinessService, scopedIpc);
return async () => {
unsubscribeConfigurationChanges();
+ await managedArtifactPreview.closeScope(scope.targetEpoch);
unsubscribeConnectionCatalogChanges();
unsubscribeSessionCatalogChanges();
unsubscribeProjectCatalogChanges();
@@ -2178,6 +2194,7 @@ async function disposeRuntimeHostDesktop(): Promise {
}
});
const results = await Promise.allSettled([
+ managedArtifactPreview.close(),
Promise.resolve().then(() => windowsAppTray.dispose()),
workHubControl.close(),
Promise.resolve().then(() => workHubPresentation.dispose()),