Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions apps/desktop/src/main/__tests__/managed-artifact-preview.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<!doctype html><title>Counter</title><button onclick="this.textContent=Number(this.textContent)+1">0</button>';
function client(content = html): Pick<DesktopRuntimeHostClient, 'getArtifact' | 'streamArtifact'> {
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<number> {
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<void>((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(); }
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Handler>();
const bytes = Buffer.from('<!doctype html><title>Managed preview</title><button>Interact</button>');
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<void>) => { 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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,6 +42,32 @@ function jsonSchema(schema: Record<string, unknown>): {
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')],
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/main/managed-artifact-preview-tools.ts
Original file line number Diff line number Diff line change
@@ -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<ArtifactPreviewEndpoint>,
): 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];
}
Loading