diff --git a/apps/desktop/src/main/__tests__/preview-preflight.test.ts b/apps/desktop/src/main/__tests__/preview-preflight.test.ts new file mode 100644 index 0000000000..7a434ed356 --- /dev/null +++ b/apps/desktop/src/main/__tests__/preview-preflight.test.ts @@ -0,0 +1,425 @@ +/* + * 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. + */ + +/** + * Local preview preflight: the verified / unsupported / unknown classification + * for every capability, the rule that a connection outcome may never become + * `unsupported`, the rule that no result claims a page is ready, and the + * loopback probe driven against real sockets. + */ + +import { strict as assert } from 'node:assert'; +import { createServer, type Server } from 'node:http'; +import type { Socket } from 'node:net'; +import { after, describe, it } from 'node:test'; +import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime'; +import { parseNavigable } from '../browser/logic.js'; +import { + ARTIFACT_PREVIEW_HANDOFF, + assertLoopbackOrigin, + buildPreviewPreflightTools, + classifyBrowserView, + classifyGuiSurface, + classifyLoopbackEndpoint, + classifyUrlSchemes, + probeNavigableSchemes, + type PreviewCapability, + type PreviewCapabilityId, + type PreviewPreflightAuthority, + type PreviewPreflightResult, +} from '../preview-preflight.js'; +import { + createPreviewPreflightAuthority, + LOOPBACK_PROBE_TIMEOUT_MS, +} from '../preview-preflight-probes.js'; +import { buildArtifactPreviewOfferTools } from '../managed-artifact-preview-tools.js'; + +function ctx(signal?: AbortSignal): MakaToolContext { + return { + sessionId: 's1', + turnId: 't1', + cwd: '/tmp', + toolCallId: 'c1', + abortSignal: signal ?? new AbortController().signal, + emitOutput: () => {}, + }; +} + +/** Everything observable and healthy; each test spoils only what it is about. */ +function healthyAuthority( + overrides: Partial = {}, +): PreviewPreflightAuthority { + return { + guiSurfaceAvailable: () => true, + browserDrivable: () => true, + probeLoopback: () => ({ kind: 'answered', status: 200 }), + ...overrides, + }; +} + +function run( + authority: PreviewPreflightAuthority, + args: { origin?: string } = {}, + signal?: AbortSignal, +): Promise { + const [tool] = buildPreviewPreflightTools(authority); + assert.ok(tool, 'the preflight publishes a tool'); + const preflight = tool as MakaTool<{ origin?: string }, PreviewPreflightResult>; + return Promise.resolve(preflight.impl(args, ctx(signal))); +} + +function capability(result: PreviewPreflightResult, id: PreviewCapabilityId): PreviewCapability { + const found = result.capabilities.find((entry) => entry.id === id); + assert.ok(found, `the report covers ${id}`); + return found; +} + +/** No status may read as a cause the probe did not establish. */ +function assertClaimsNoCause(capability: PreviewCapability): void { + const text = `${capability.evidence} ${capability.boundary ?? ''}`; + assert.doesNotMatch( + text, + /(?:is|are|was|were) (?:sandbox|isolat)/iu, + `${capability.id} must not assert an isolation cause: ${text}`, + ); +} + +describe('preview preflight capability classification', () => { + it('reports exactly what the address policy answers', () => { + const probes = probeNavigableSchemes(); + assert.deepEqual( + probes.map((probe) => probe.scheme), + ['https:', 'http:', 'file:', 'data:'], + ); + for (const probe of probes) { + assert.equal( + probe.navigable, + parseNavigable(probe.sample) !== null, + `${probe.scheme} agrees with parseNavigable`, + ); + } + }); + + it('calls a rejected file:// an unsupported boundary and names the way around it', () => { + const capability = classifyUrlSchemes(probeNavigableSchemes()); + assert.equal(capability.status, 'unsupported'); + assert.match(capability.evidence, /rejected file:/u); + assert.match(capability.boundary ?? '', /requires an HTTP origin/u); + assert.match(capability.boundary ?? '', /use ArtifactPreview/u); + }); + + it('follows the policy if file:// ever becomes navigable', () => { + const capability = classifyUrlSchemes([ + { sample: 'https://x.invalid/', scheme: 'https:', navigable: true }, + { sample: 'file:///x', scheme: 'file:', navigable: true }, + ]); + assert.equal(capability.status, 'verified'); + }); + + it('separates a missing view host from a check that did not complete', () => { + assert.equal(classifyGuiSurface({ ok: true, value: true }).status, 'verified'); + assert.equal(classifyGuiSurface({ ok: true, value: false }).status, 'unsupported'); + + const unreadable = classifyGuiSurface({ ok: false, cause: 'provider exploded' }); + assert.equal(unreadable.status, 'unknown'); + assert.match(unreadable.evidence, /provider exploded/u); + }); + + it('never calls a refused view unsupported', () => { + assert.equal(classifyBrowserView({ ok: true, value: true }).status, 'verified'); + + const refused = classifyBrowserView({ ok: true, value: false }); + assert.equal(refused.status, 'unknown'); + assert.match(refused.boundary ?? '', /does not prove the browser is unreachable/u); + assertClaimsNoCause(refused); + + assert.equal(classifyBrowserView({ ok: false, cause: 'host gone' }).status, 'unknown'); + }); + + it('treats any HTTP status as proof of a listener, and of nothing more', () => { + const capability = classifyLoopbackEndpoint({ + origin: 'http://127.0.0.1:8765', + observed: { ok: true, value: { kind: 'answered', status: 404 } }, + }); + assert.equal(capability.status, 'verified'); + assert.match(capability.evidence, /HTTP 404/u); + assert.match(capability.boundary ?? '', /not that any particular page exists there/u); + }); + + it('never turns a failed connection into an unsupported capability', () => { + for (const cause of ['ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH', 'DEPTH_ZERO_SELF_SIGNED_CERT']) { + const capability = classifyLoopbackEndpoint({ + origin: 'http://127.0.0.1:8765', + observed: { ok: true, value: { kind: 'no_answer', cause } }, + }); + assert.equal(capability.status, 'unknown', `${cause} is not a settled boundary`); + assert.match(capability.evidence, new RegExp(cause, 'u')); + // Something may well have listened: a TLS failure means it did. + assert.doesNotMatch(capability.evidence, /nothing answered/iu); + assert.match(capability.boundary ?? '', /does not prove sandbox isolation/u); + assertClaimsNoCause(capability); + } + }); +}); + +describe('preview preflight loopback argument', () => { + it('reduces an accepted URL to its origin', () => { + assert.equal(assertLoopbackOrigin('http://127.0.0.1:8765/preview.html'), 'http://127.0.0.1:8765'); + assert.equal(assertLoopbackOrigin('http://[::1]:8765/'), 'http://[::1]:8765'); + }); + + it('rejects anything that would make the check an arbitrary outbound request', () => { + assert.throws(() => assertLoopbackOrigin('example.com'), /Not a URL/u); + assert.throws(() => assertLoopbackOrigin('file:///tmp/preview.html'), /Only http:\/\/ and https:\/\//u); + assert.throws(() => assertLoopbackOrigin('http://192.168.1.4:8765/'), /Only 127\.0\.0\.1 and \[::1\]/u); + assert.throws(() => assertLoopbackOrigin('http://example.com/'), /Only 127\.0\.0\.1 and \[::1\]/u); + assert.throws(() => assertLoopbackOrigin('http://localhost:8765/'), /"localhost" is excluded/u); + assert.throws(() => assertLoopbackOrigin('http://user:pw@127.0.0.1:8765/'), /Credentials are not accepted/u); + assert.throws(() => assertLoopbackOrigin('http://127.0.0.1:8765/?token=x'), /no query or fragment/u); + assert.throws(() => assertLoopbackOrigin('http://127.0.0.1:8765/#frag'), /no query or fragment/u); + }); +}); + +describe('preview preflight tool', () => { + it('reports surface and endpoint separately and claims no page readiness', async () => { + const result = await run(healthyAuthority(), { origin: 'http://127.0.0.1:8765/preview.html' }); + assert.equal(result.surface, 'verified'); + assert.equal(result.endpoint, 'verified'); + assert.equal('ready' in result, false, 'a listener is not a loaded page; #5235 owns that'); + assert.equal(result.alternative, undefined); + }); + + it('does not report a missing page as showable', async () => { + // A 404 proves a listener, not the page. The summary must not claim more. + const result = await run( + healthyAuthority({ probeLoopback: () => ({ kind: 'answered', status: 404 }) }), + { origin: 'http://127.0.0.1:8765/missing.html' }, + ); + assert.equal(result.endpoint, 'verified'); + assert.doesNotMatch(result.summary, /can be shown/iu); + assert.match(result.summary, /does not prove the page you want exists there or loads/u); + }); + + it('keeps an unnamed endpoint from reading as a missing capability', async () => { + const result = await run(healthyAuthority()); + assert.equal(result.surface, 'verified'); + assert.equal(result.endpoint, 'not_checked'); + assert.equal(result.capabilities.some((entry) => entry.id === 'loopback_endpoint'), false); + }); + + it('gives each summary branch its own answer', async () => { + const summaries = { + unsupported: (await run(healthyAuthority({ guiSurfaceAvailable: () => false }))).summary, + listening: (await run(healthyAuthority(), { origin: 'http://127.0.0.1:8765' })).summary, + unnamed: (await run(healthyAuthority())).summary, + unnamedUnproven: (await run(healthyAuthority({ browserDrivable: () => false }))).summary, + mixed: ( + await run( + healthyAuthority({ probeLoopback: () => ({ kind: 'no_answer', cause: 'ECONNREFUSED' }) }), + { origin: 'http://127.0.0.1:8765' }, + ) + ).summary, + }; + assert.match(summaries.unsupported, /cannot display a page locally/u); + assert.match(summaries.listening, /something is listening/u); + assert.match(summaries.unnamed, /No endpoint was named/u); + assert.match(summaries.unnamedUnproven, /surface itself is unproven/u); + assert.match(summaries.mixed, /2 verified, 1 unsupported, 1 unknown/u); + assert.equal(new Set(Object.values(summaries)).size, 5, 'no two branches share a summary'); + }); + + it('points an uncovered report at ArtifactPreview without restating its guarantees', async () => { + const result = await run(healthyAuthority()); + assert.match(result.alternative ?? '', /ArtifactPreview/u); + assert.match(result.alternative ?? '', /Its description states what the URL/u); + }); + + it('reports a headless client without asking a view host that cannot answer', async () => { + let asked = 0; + const result = await run( + healthyAuthority({ + guiSurfaceAvailable: () => false, + browserDrivable: () => { + asked += 1; + throw new Error('Browser automation is only available inside the desktop app.'); + }, + }), + ); + assert.equal(asked, 0, 'a settled unsupported answer is not downgraded to unknown by a throw'); + assert.equal(result.surface, 'unsupported'); + assert.equal(capability(result, 'browser_view').status, 'unsupported'); + assert.ok(result.alternative); + }); + + it('keeps an off-screen conversation unproven rather than unsupported', async () => { + const result = await run(healthyAuthority({ browserDrivable: () => false })); + assert.equal(result.surface, 'unknown'); + assert.equal(capability(result, 'browser_view').status, 'unknown'); + }); + + it('rejects a bad origin as an argument error instead of an unknown capability', async () => { + await assert.rejects( + () => run(healthyAuthority(), { origin: 'http://192.168.1.4:8765/' }), + /Only 127\.0\.0\.1 and \[::1\]/u, + ); + }); + + it('propagates cancellation instead of reporting it as a finding', async () => { + const controller = new AbortController(); + await assert.rejects( + () => + run( + healthyAuthority({ + guiSurfaceAvailable: () => { + controller.abort(new Error('turn ended')); + throw new Error('turn ended'); + }, + }), + {}, + controller.signal, + ), + /turn ended/u, + ); + await assert.rejects(() => run(healthyAuthority(), {}, AbortSignal.abort(new Error('gone'))), /gone/u); + }); +}); + +describe('artifact preview offer', () => { + it('publishes the preflight together with the tool it hands off to', () => { + const names = buildArtifactPreviewOfferTools({ + preflight: healthyAuthority(), + prepare: () => Promise.reject(new Error('not called')), + }).map((tool) => tool.name); + assert.deepEqual(names, ['preview_preflight', 'ArtifactPreview']); + // The handoff may only name a tool this same offer grants. + const named = names.filter((name) => ARTIFACT_PREVIEW_HANDOFF.includes(name)); + assert.deepEqual(named, ['ArtifactPreview']); + }); +}); + +describe('preview preflight loopback probe', () => { + const servers: Server[] = []; + const sockets: Socket[] = []; + + after(async () => { + for (const socket of sockets) socket.destroy(); + for (const server of servers) await new Promise((resolve) => server.close(resolve)); + }); + + async function listen(server: Server): Promise { + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined))); + const address = server.address(); + assert.ok(address && typeof address === 'object', 'the probe server is bound'); + return `http://127.0.0.1:${address.port}`; + } + + const signal = new AbortController().signal; + + it('sends HEAD / and never requests the path it was given', async () => { + const seen: string[] = []; + const origin = await listen( + createServer((request, response) => { + seen.push(`${request.method} ${request.url}`); + response.writeHead(404).end(); + }), + ); + const authority = createPreviewPreflightAuthority(); + const [tool] = buildPreviewPreflightTools(authority); + const preflight = tool as MakaTool<{ origin?: string }, PreviewPreflightResult>; + await preflight.impl({ origin: `${origin}/delete-everything` }, ctx()); + assert.deepEqual(seen, ['HEAD /']); + }); + + it('records the status a live endpoint answers with', async () => { + const origin = await listen(createServer((_request, response) => response.writeHead(404).end())); + assert.deepEqual(await createPreviewPreflightAuthority().probeLoopback({ origin, signal }), { + kind: 'answered', + status: 404, + }); + }); + + it('settles on a 101 that never becomes a response', async () => { + // Node routes a 101 to `upgrade` and closes the request without emitting + // `response` or `error`; this is the path that used to hang. + const server = createServer(); + server.on('connection', (socket) => { + socket.once('data', () => { + socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n'); + }); + }); + const origin = await listen(server); + const started = Date.now(); + assert.deepEqual(await createPreviewPreflightAuthority().probeLoopback({ origin, signal }), { + kind: 'answered', + status: 101, + }); + assert.ok(Date.now() - started < LOOPBACK_PROBE_TIMEOUT_MS, 'answered without waiting out the deadline'); + }); + + it('propagates cancellation from a server that is saying nothing', async () => { + const server = createServer(); + server.on('connection', (socket) => sockets.push(socket)); + const origin = await listen(server); + const controller = new AbortController(); + const cancelled = Promise.resolve( + createPreviewPreflightAuthority().probeLoopback({ origin, signal: controller.signal }), + ); + controller.abort(new Error('turn ended')); + await assert.rejects(() => cancelled, /turn ended/u); + }); + + it('enforces its deadline against a server that never answers', async () => { + const server = createServer(); + server.on('connection', (socket) => sockets.push(socket)); + const origin = await listen(server); + const probe = await createPreviewPreflightAuthority({ loopbackTimeoutMs: 120 }).probeLoopback({ + origin, + signal, + }); + assert.deepEqual(probe, { kind: 'no_answer', cause: 'no response within 120ms' }); + }); + + it('reports a TLS handshake against a plain listener as no usable response', async () => { + // Exercises the https branch without a certificate: something listens, + // but nothing usable comes back, which is exactly the case "nothing + // answered" would have misdescribed. + const plain = await listen(createServer((_request, response) => response.writeHead(200).end())); + const origin = plain.replace('http://', 'https://'); + const probe = await createPreviewPreflightAuthority().probeLoopback({ origin, signal }); + assert.equal(probe.kind, 'no_answer'); + const capability = classifyLoopbackEndpoint({ origin, observed: { ok: true, value: probe } }); + assert.equal(capability.status, 'unknown'); + assert.match(capability.evidence, /No usable HTTP response/u); + }); + + it('returns a refused connection as a value, not a throw', async () => { + const server = createServer(); + const origin = await listen(server); + await new Promise((resolve) => server.close(resolve)); + servers.splice(servers.indexOf(server), 1); + const probe = await createPreviewPreflightAuthority().probeLoopback({ origin, signal }); + assert.equal(probe.kind, 'no_answer'); + assert.match(probe.kind === 'no_answer' ? probe.cause : '', /^E[A-Z]+$/u); + }); + + it('reports no GUI surface when no Desktop view host is registered', () => { + assert.equal(createPreviewPreflightAuthority().guiSurfaceAvailable(), false); + }); +}); diff --git a/apps/desktop/src/main/managed-artifact-preview-tools.ts b/apps/desktop/src/main/managed-artifact-preview-tools.ts index 939a78c2f5..fd6f1c528c 100644 --- a/apps/desktop/src/main/managed-artifact-preview-tools.ts +++ b/apps/desktop/src/main/managed-artifact-preview-tools.ts @@ -20,6 +20,19 @@ import type { MakaTool } from '@maka/runtime/tool-runtime'; import { z } from 'zod'; import type { ArtifactPreviewEndpoint } from './managed-artifact-preview.js'; +import { buildPreviewPreflightTools, type PreviewPreflightAuthority } from './preview-preflight.js'; + +/** + * Every tool the `desktop_artifact_preview` offer publishes. The preflight and + * the route it hands off to share one grant unit, so a caller told to use + * ArtifactPreview is never told about a tool it was not granted. + */ +export function buildArtifactPreviewOfferTools(input: { + readonly preflight: PreviewPreflightAuthority; + readonly prepare: (sessionId: string, artifactId: string, signal: AbortSignal) => Promise; +}): readonly MakaTool[] { + return [...buildPreviewPreflightTools(input.preflight), ...buildManagedArtifactPreviewTools(input.prepare)]; +} export function buildManagedArtifactPreviewTools( prepare: (sessionId: string, artifactId: string, signal: AbortSignal) => Promise, diff --git a/apps/desktop/src/main/preview-preflight-probes.ts b/apps/desktop/src/main/preview-preflight-probes.ts new file mode 100644 index 0000000000..c62c73482b --- /dev/null +++ b/apps/desktop/src/main/preview-preflight-probes.ts @@ -0,0 +1,133 @@ +/* + * 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. + */ + +/** + * The OS-touching half of the local preview preflight: the probes that decide + * a capability's status by actually trying something. Classification lives in + * preview-preflight.ts and never imports this file, so the statuses stay + * testable without a socket. + * + * A probe reports a connection outcome as a value, never as a throw, so the + * classifier — not an exception handler — decides what the outcome proves. + */ + +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { browserAutomationAvailable, browserViewHost } from './browser/browser-host.js'; +import type { LoopbackProbe, PreviewPreflightAuthority } from './preview-preflight.js'; + +/** + * A preview endpoint on loopback either answers immediately or is not up yet; + * a long wait here would only turn a fast, honest `unknown` into a slow one. + */ +export const LOOPBACK_PROBE_TIMEOUT_MS = 2_000; + +export interface PreviewPreflightProbeDeps { + /** Overrides the loopback deadline so a test can reach it without a real wait. */ + readonly loopbackTimeoutMs?: number; +} + +export function createPreviewPreflightAuthority( + deps: PreviewPreflightProbeDeps = {}, +): PreviewPreflightAuthority { + const loopbackTimeoutMs = deps.loopbackTimeoutMs ?? LOOPBACK_PROBE_TIMEOUT_MS; + return { + guiSurfaceAvailable: () => browserAutomationAvailable(), + browserDrivable: ({ sessionId, signal }) => { + signal.throwIfAborted(); + return browserViewHost().canDrive(sessionId, 'observe', { signal }); + }, + probeLoopback: ({ origin, signal }) => probeLoopback(origin, signal, loopbackTimeoutMs), + }; +} + +/** + * `HEAD /` against the origin, settled by the first of: a response, an + * upgrade, an error, the caller's abort, or the deadline. + * + * HEAD to the root, never the caller's path: a dev server may attach side + * effects to a route, and a capability check must not trip them. + * + * The deadline lives here rather than on the socket. A server answering + * `101 Switching Protocols` makes Node route the exchange to `upgrade` and + * close the request without emitting `response` or `error`, taking any + * socket-bound timeout with it. The deadline is therefore the one catch-all: + * whatever else does or does not fire, the probe settles within it. + */ +function probeLoopback( + origin: string, + signal: AbortSignal, + timeoutMs: number, +): Promise { + signal.throwIfAborted(); + const url = new URL('/', origin); + const send = url.protocol === 'https:' ? httpsRequest : httpRequest; + return new Promise((resolve, reject) => { + let settled = false; + let deadline: ReturnType | undefined; + + function finish(settle: () => void): void { + if (settled) return; + settled = true; + if (deadline !== undefined) clearTimeout(deadline); + signal.removeEventListener('abort', abandon); + // Marked settled first, so the teardown's own error cannot re-enter. + probe.destroy(); + settle(); + } + function answer(result: LoopbackProbe): void { + finish(() => resolve(result)); + } + // An aborted turn is a cancellation, not a statement about the endpoint. + function abandon(): void { + finish(() => reject(signal.reason)); + } + + const probe = send(url, { method: 'HEAD' }, (response) => { + response.resume(); + answer({ kind: 'answered', status: response.statusCode ?? 0 }); + }); + probe.on('upgrade', (response, socket) => { + // The upgraded socket is detached from the request, so destroying the + // request would leave it open. A 101 is still an answer: something + // listened at that address, which is all this capability claims. + socket.destroy(); + answer({ kind: 'answered', status: response.statusCode ?? 0 }); + }); + probe.on('error', (error) => { + if (signal.aborted) abandon(); + else answer({ kind: 'no_answer', cause: describeCause(error) }); + }); + deadline = setTimeout( + () => answer({ kind: 'no_answer', cause: `no response within ${timeoutMs}ms` }), + timeoutMs, + ); + signal.addEventListener('abort', abandon, { once: true }); + probe.end(); + }); +} + +/** Prefer the errno: ECONNREFUSED says more to a caller than its sentence does. */ +function describeCause(error: unknown): string { + if (typeof error === 'object' && error !== null && 'code' in error) { + const code = (error as { readonly code?: unknown }).code; + if (typeof code === 'string' && code !== '') return code; + } + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/desktop/src/main/preview-preflight.ts b/apps/desktop/src/main/preview-preflight.ts new file mode 100644 index 0000000000..bdab08627d --- /dev/null +++ b/apps/desktop/src/main/preview-preflight.ts @@ -0,0 +1,440 @@ +/* + * 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. + */ + +/** + * Local preview capability preflight — what this client can actually show, + * asked before anything is attempted. + * + * Showing a page locally depends on independent things that each fail + * differently: the client may have no GUI surface at all, the embedded browser + * refuses `file://` by policy, and the conversation's own view may not be the + * one on screen. Attempting the preview and reading the first error conflates + * them. A refused loopback connection is the worst case: "nothing listening + * yet", "server still starting", and "separate network namespace" are + * indistinguishable at the socket, yet the refusal reads like proof of the + * last one. + * + * So every capability below answers with one of three statuses and names the + * observation behind it. `verified` requires a positive observation. + * `unsupported` is a settled product boundary this code can state without + * probing. Everything else is `unknown` — explicitly NOT a claim that a + * sandbox, an isolated localhost, or a missing capability caused it. + * + * No result here claims that a page is ready. The loopback probe observes the + * origin, not the page, and whether a page loads is #5235's question; a + * readiness flag built from a listener's answer would claim more than the + * evidence holds. + * + * Pure by construction: every observation arrives through + * PreviewPreflightAuthority, so each status — including the ones that need a + * refused socket — is reachable from a plain unit test. The probes that touch + * the OS live in preview-preflight-probes.ts. + */ + +import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { z } from 'zod'; +import { parseNavigable } from './browser/logic.js'; + +export type PreviewCapabilityStatus = 'verified' | 'unsupported' | 'unknown'; + +export type PreviewCapabilityId = 'gui_surface' | 'url_schemes' | 'browser_view' | 'loopback_endpoint'; + +export interface PreviewCapability { + readonly id: PreviewCapabilityId; + readonly status: PreviewCapabilityStatus; + /** The observation this status rests on. Every status names one. */ + readonly evidence: string; + /** What the status does not prove, or why the boundary exists. */ + readonly boundary?: string; +} + +/** An endpoint nobody named was not found wanting; it was never asked about. */ +export type PreviewEndpointStatus = PreviewCapabilityStatus | 'not_checked'; + +export interface PreviewPreflightResult { + readonly kind: 'preview_preflight'; + /** + * Whether this client can display a page at all, independent of any + * particular endpoint: a GUI surface plus a view that accepts actions. + */ + readonly surface: PreviewCapabilityStatus; + /** + * Whether something listens at the named origin. `verified` proves a + * listener, not that any page exists there or that it loads. + */ + readonly endpoint: PreviewEndpointStatus; + readonly capabilities: readonly PreviewCapability[]; + readonly summary: string; + /** Present unless the caller already has a verified surface and listener. */ + readonly alternative?: string; +} + +/** An observation that may not have been obtainable, kept distinct from a negative one. */ +export type Observed = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly cause: string }; + +export type LoopbackProbe = + // Any HTTP status answers the question: a 404 still proves something listened. + | { readonly kind: 'answered'; readonly status: number } + | { readonly kind: 'no_answer'; readonly cause: string }; + +export interface PreviewPreflightAuthority { + /** Whether a Desktop browser view host is registered for this client. */ + guiSurfaceAvailable(): boolean | Promise; + /** Whether this conversation's embedded view accepts an observe action right now. */ + browserDrivable(input: { + readonly sessionId: string; + readonly signal: AbortSignal; + }): boolean | Promise; + /** Bounded `HEAD /` against an origin. A connection outcome is a result, never a throw. */ + probeLoopback(input: { + readonly origin: string; + readonly signal: AbortSignal; + }): LoopbackProbe | Promise; +} + +/** + * Names the supported route and defers to it for what that route promises. + * Restating ArtifactPreview's guarantees here would give two answers to the + * same question, and the copy would drift from the tool it describes. + */ +export const ARTIFACT_PREVIEW_HANDOFF = + 'To show an HTML Artifact without a shell server or file:// navigation, use the ArtifactPreview tool in this same offer. Its description states what the URL it returns does and does not guarantee; read that before reporting that a preview opened.'; + +/** Addresses the embedded browser is asked about, one per scheme worth reporting. */ +const SCHEME_PROBES = [ + 'https://example.invalid/preview.html', + 'http://127.0.0.1:8765/preview.html', + 'file:///tmp/preview.html', + 'data:text/html,

preview

', +] as const; + +/** + * Stricter than `isLoopbackHost` in @maka/core/mcp on purpose, so do not widen + * it to match. That predicate decides whether traffic may be trusted as local; + * this one names the exact address a probe dials, because the evidence it + * returns says which address answered or refused. `localhost` can resolve to + * either 127.0.0.1 or [::1] depending on the resolver's order, so a refusal + * there would not say which address was tried — a server bound to one family + * reads as down. The rest of 127/8 is excluded for the same precision. + */ +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]']); + +export interface SchemeProbeResult { + readonly sample: string; + readonly scheme: string; + readonly navigable: boolean; +} + +/** + * Ask the real address policy rather than restating it. A hardcoded list would + * keep reporting `file:` as rejected long after someone changed the rule. + */ +export function probeNavigableSchemes(): readonly SchemeProbeResult[] { + return SCHEME_PROBES.map((sample) => ({ + sample, + scheme: sample.slice(0, sample.indexOf(':') + 1), + navigable: parseNavigable(sample) !== null, + })); +} + +export function classifyGuiSurface(observed: Observed): PreviewCapability { + if (!observed.ok) { + return { + id: 'gui_surface', + status: 'unknown', + evidence: `Could not determine whether a browser view host is registered: ${observed.cause}.`, + boundary: 'This does not mean the client is headless; the check itself did not complete.', + }; + } + if (observed.value) { + return { + id: 'gui_surface', + status: 'verified', + evidence: 'A Desktop browser view host is registered for this client.', + }; + } + return { + id: 'gui_surface', + status: 'unsupported', + evidence: 'No Desktop browser view host is registered.', + boundary: + 'Browser automation exists only inside the desktop app, so this runtime has no embedded view to preview into. Nothing this runtime does will make one appear.', + }; +} + +export function classifyUrlSchemes(probes: readonly SchemeProbeResult[]): PreviewCapability { + const navigable = probes.filter((probe) => probe.navigable).map((probe) => probe.scheme); + const rejected = probes.filter((probe) => !probe.navigable).map((probe) => probe.scheme); + const evidence = `The embedded browser's address policy accepted ${describeSchemes(navigable)} and rejected ${describeSchemes(rejected)}.`; + if (probes.some((probe) => probe.scheme === 'file:' && probe.navigable)) { + return { id: 'url_schemes', status: 'verified', evidence }; + } + return { + id: 'url_schemes', + status: 'unsupported', + evidence, + boundary: + 'Admission requires an HTTP origin. file:// is rejected so that a typed address or an in-page link can never reach the local filesystem, so a generated file cannot be previewed by pointing the embedded browser at its path. This is a deliberate boundary, not a missing feature: use ArtifactPreview to serve the same content over http:// instead of working around it.', + }; +} + +/** + * Reached only when a view host exists, so this answers `verified` or + * `unknown` and never `unsupported`: a refused observe is a visibility + * condition of the moment, not a boundary. + */ +export function classifyBrowserView(drivable: Observed): PreviewCapability { + if (!drivable.ok) { + return { + id: 'browser_view', + status: 'unknown', + evidence: `Could not determine whether this conversation's view accepts actions: ${drivable.cause}.`, + }; + } + if (drivable.value) { + return { + id: 'browser_view', + status: 'verified', + evidence: "This conversation's embedded view accepts an observe action, so a page can be driven into it now.", + }; + } + return { + id: 'browser_view', + status: 'unknown', + evidence: "This conversation's embedded view refused an observe action right now.", + boundary: + 'Every browser action must run in the conversation the user is looking at. A refusal usually means this conversation is not the one on screen, which is transient and visibility-scoped — it does not prove the browser is unreachable or that a sandbox separates it.', + }; +} + +/** The view half when there is demonstrably no view host to ask. */ +export const BROWSER_VIEW_WITHOUT_HOST: PreviewCapability = { + id: 'browser_view', + status: 'unsupported', + evidence: 'There is no registered browser view host, so this client has no embedded view to drive.', +}; + +export function classifyLoopbackEndpoint(input: { + readonly origin: string; + readonly observed: Observed; +}): PreviewCapability { + if (!input.observed.ok) { + return { + id: 'loopback_endpoint', + status: 'unknown', + evidence: `Could not probe ${input.origin}: ${input.observed.cause}.`, + }; + } + const probe = input.observed.value; + if (probe.kind === 'answered') { + return { + id: 'loopback_endpoint', + status: 'verified', + evidence: `HEAD / at ${input.origin} answered with HTTP ${probe.status} to the Desktop main process.`, + boundary: + 'An answer proves a listener at this origin and nothing more: not that any particular page exists there, not that it loads, and not that the embedded browser shares this loopback namespace.', + }; + } + // The rule this whole tool exists for: a connection outcome can produce + // `verified` or `unknown`, never `unsupported`. Nothing observable at a + // refused socket distinguishes a slow start from an isolated namespace. + return { + id: 'loopback_endpoint', + status: 'unknown', + // "No usable response", not "nothing answered": a TLS handshake failure, + // garbage bytes, or a reset after accept all mean something did listen. + evidence: `No usable HTTP response from ${input.origin}: ${probe.cause}.`, + boundary: + 'This does not prove sandbox isolation, a separate localhost namespace, or that the endpoint is unusable. It shows only that no usable HTTP response reached this process from that address at this moment. Retry once the server reports that it is listening, or read the server\'s own startup output.', + }; +} + +/** + * Reject anything that would turn a capability check into an arbitrary + * outbound request, and say what is accepted instead. Returns the origin only: + * the probe never requests the caller's path, so a dev-server route with side + * effects on GET cannot be tripped by a capability check. + */ +export function assertLoopbackOrigin(raw: string): string { + const guidance = 'Pass a loopback origin such as http://127.0.0.1:8765.'; + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error(`Not a URL: ${JSON.stringify(raw)}. ${guidance}`); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Only http:// and https:// origins can be probed, not ${url.protocol}. ${guidance}`); + } + if (url.username !== '' || url.password !== '') { + throw new Error(`Credentials are not accepted in a probe URL. ${guidance}`); + } + if (url.search !== '' || url.hash !== '') { + throw new Error(`A probe URL carries no query or fragment. ${guidance}`); + } + if (!LOOPBACK_HOSTS.has(url.hostname)) { + throw new Error( + `Only 127.0.0.1 and [::1] can be probed, not ${JSON.stringify(url.hostname)}. "localhost" is excluded because it can resolve to either address, so a refusal would not say which one was tried. ${guidance}`, + ); + } + return url.origin; +} + +/** + * Whether this client can display a page at all. Deliberately independent of + * any endpoint, so "nothing was named to check" cannot read as "this client + * cannot preview". A settled boundary on either half settles the whole. + */ +export function derivePreviewSurface(input: { + readonly guiSurface: PreviewCapability; + readonly browserView: PreviewCapability; +}): PreviewCapabilityStatus { + const statuses = [input.guiSurface.status, input.browserView.status]; + if (statuses.includes('unsupported')) return 'unsupported'; + return statuses.every((status) => status === 'verified') ? 'verified' : 'unknown'; +} + +export function summarizePreviewPreflight(input: { + readonly capabilities: readonly PreviewCapability[]; + readonly surface: PreviewCapabilityStatus; + readonly endpoint: PreviewEndpointStatus; +}): string { + if (input.surface === 'unsupported') { + return 'This client cannot display a page locally. The unsupported entries below say why, and that will not change at runtime — take the alternative rather than retrying.'; + } + if (input.surface === 'verified' && input.endpoint === 'verified') { + return 'This client can display a page, and something is listening at the named origin. That does not prove the page you want exists there or loads — navigate to it and observe the result before reporting it shown.'; + } + if (input.endpoint === 'not_checked') { + return input.surface === 'verified' + ? 'This client has a usable preview surface. No endpoint was named, so none was checked — an absent question, not a negative result.' + : 'No endpoint was named, so none was checked, and the surface itself is unproven. The entries below report what was observed, not a cause.'; + } + const counts = { verified: 0, unsupported: 0, unknown: 0 }; + for (const capability of input.capabilities) counts[capability.status] += 1; + return ( + `Some of what a preview needs is unproven — ${counts.verified} verified, ${counts.unsupported} unsupported, ${counts.unknown} unknown. ` + + 'Only the unsupported entries are settled boundaries; an unknown entry reports what was observed and not a cause.' + ); +} + +function describeSchemes(schemes: readonly string[]): string { + return schemes.length === 0 ? 'nothing' : schemes.join(', '); +} + +/** + * Cancellation is not an observation. Without this the abort that ends a turn + * would be reported as an `unknown` capability, which reads like a finding. + */ +async function observe(signal: AbortSignal, run: () => T | Promise): Promise> { + try { + return { ok: true, value: await run() }; + } catch (error) { + if (signal.aborted) throw error; + return { ok: false, cause: error instanceof Error ? error.message : String(error) }; + } +} + +/** The local-preview capability preflight, published beside ArtifactPreview. */ +export function buildPreviewPreflightTools( + authority: PreviewPreflightAuthority, +): readonly MakaTool[] { + const preflight: MakaTool<{ origin?: string }, PreviewPreflightResult> = { + name: 'preview_preflight', + displayName: 'Check local preview capabilities', + description: + 'Report what this Maka client can actually preview locally BEFORE attempting it: GUI availability, which URL schemes the embedded browser accepts, and whether this conversation\'s view accepts actions. ' + + 'file:// is never navigable in the embedded browser; that is a settled boundary, not a failure to retry. ' + + 'Pass `origin` to also check whether something listens at a loopback origin you started; only `HEAD /` is sent, so the path you pass is not requested. ' + + 'Each capability answers verified, unsupported, or unknown, with the observation behind it — an unknown reports what was seen and never asserts a sandbox or isolation cause. ' + + '`surface` says whether this client can display a page at all; `endpoint` is reported separately and is `not_checked` when no origin was passed. Neither claims that a page loads. ' + + 'Call this instead of inferring a boundary from a failed navigation or a refused connection.', + parameters: z + .object({ + origin: z + .string() + .min(1) + .max(2000) + .optional() + .describe( + 'Optional loopback origin to probe, such as http://127.0.0.1:8765. Only 127.0.0.1 and [::1] are accepted; no credentials, query, or fragment. Any path is ignored: the probe sends HEAD / to the origin.', + ), + }) + .strict(), + // No categoryHint or recoveryMode: this tool is published as a client + // capability, whose descriptor carries neither, and the Runtime Host + // assigns both itself. Declaring them here would only mislead a reader. + impl: async ({ origin: rawOrigin }, { sessionId, abortSignal }) => { + abortSignal.throwIfAborted(); + // Validate before probing so a malformed origin is an argument error the + // caller can fix, not an `unknown` capability that reads like a finding. + const origin = rawOrigin === undefined ? undefined : assertLoopbackOrigin(rawOrigin); + + const guiSurface = classifyGuiSurface( + await observe(abortSignal, () => authority.guiSurfaceAvailable()), + ); + const urlSchemes = classifyUrlSchemes(probeNavigableSchemes()); + // Skip the drive check when there is demonstrably no view host: asking + // would throw, and an exception dressed as `unknown` would hide the + // settled `unsupported` answer the caller needs. Desktop, the only + // publisher today, registers its host before any offer is published, so + // this branch is unreachable there; it is kept because GUI availability + // is part of the classification #5235 builds on, and that contract must + // answer correctly for a publisher without a view host rather than throw. + const browserView = + guiSurface.status === 'unsupported' + ? BROWSER_VIEW_WITHOUT_HOST + : classifyBrowserView( + await observe(abortSignal, () => + authority.browserDrivable({ sessionId, signal: abortSignal }), + ), + ); + const loopbackEndpoint = + origin === undefined + ? undefined + : classifyLoopbackEndpoint({ + origin, + observed: await observe(abortSignal, () => + authority.probeLoopback({ origin, signal: abortSignal }), + ), + }); + + const capabilities = [ + guiSurface, + urlSchemes, + browserView, + ...(loopbackEndpoint ? [loopbackEndpoint] : []), + ]; + const surface = derivePreviewSurface({ guiSurface, browserView }); + const endpoint: PreviewEndpointStatus = loopbackEndpoint?.status ?? 'not_checked'; + const covered = surface === 'verified' && endpoint === 'verified'; + return { + kind: 'preview_preflight', + surface, + endpoint, + capabilities, + summary: summarizePreviewPreflight({ capabilities, surface, endpoint }), + ...(covered ? {} : { alternative: ARTIFACT_PREVIEW_HANDOFF }), + }; + }, + }; + return [preflight as MakaTool]; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 8ecd08ec8f..b0f37b1e51 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -188,7 +188,8 @@ 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 { buildArtifactPreviewOfferTools } from './managed-artifact-preview-tools.js'; +import { createPreviewPreflightAuthority } from './preview-preflight-probes.js'; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import type { DesktopRuntimeHostCandidateControls, @@ -1060,12 +1061,22 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( { 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); + description: 'Check what this client can preview locally, and prepare an isolated, temporary HTTP preview of a generated HTML Artifact.', + // The preflight lives in this offer, not the Browser one, for two + // reasons. Every Browser tool is admitted against an Origin read + // from the view's current URL, so a capability check there would + // need a page already loaded and would fail with the very + // "requires an HTTP origin" error it exists to explain. And the + // route it hands off to is ArtifactPreview, so the two must be + // granted together or the handoff names a tool the caller lacks. + tools: buildArtifactPreviewOfferTools({ + preflight: createPreviewPreflightAuthority(), + prepare: 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); + }, }), }, {