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()),