diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 9c976825..67839310 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -114,6 +114,15 @@ jobs: node --import tsx --test packages/server/src/opencode/pruning-installation.test.ts if ($LASTEXITCODE) { exit $LASTEXITCODE } node scripts/run-session-pruning-native.mjs $cli --ui + - name: Design companion end to end with an isolated official runtime + shell: pwsh + run: | + $platform = node -p "process.platform === 'win32' ? 'windows' : process.platform" + $arch = node -p "process.arch" + $extension = node -p "process.platform === 'win32' ? '.exe' : ''" + $cli = Join-Path $env:PRUNING_NATIVE_INSTALL "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension" + # Scripted chat and fake image models: series, proof, lineage, pixels and the late read-back. + node scripts/test-design-companion-native.mjs $cli - name: Upload isolated native failure diagnostics if: ${{ failure() && env.PRUNING_NATIVE_FIXTURES != '' }} uses: actions/upload-artifact@v7.0.2 @@ -125,6 +134,7 @@ jobs: ${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/discovery.json ${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/ui-*.png ${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/test-diagnostics.jsonl + ${{ env.PRUNING_NATIVE_FIXTURES }}/design-companion-*/daemon.log ${{ env.PRUNING_NATIVE_FIXTURES }}/native-fixture-run-*/ if-no-files-found: ignore retention-days: 7 diff --git a/dev-docs/PANEL_EXTENSIONS.md b/dev-docs/PANEL_EXTENSIONS.md index f34680c4..394ee1d3 100644 --- a/dev-docs/PANEL_EXTENSIONS.md +++ b/dev-docs/PANEL_EXTENSIONS.md @@ -402,9 +402,11 @@ only future online installation; installed copies and local ZIP installation rem node --import tsx --test packages/server/src/panel-extensions/extension.test.ts node --import tsx --test packages/server/src/panel-extensions/catalog.test.ts node --import tsx --test packages/server/src/opencode/session-pruning/assets.test.ts packages/server/src/server/routes/panel-extension-assets.test.ts -node scripts/test-panel-extension-native.mjs # Windows, isolated Tauri/WebView2 +node scripts/test-panel-extension-native.mjs # isolated Tauri: WebView2, WKWebView or WebKitGTK node scripts/test-panel-extension-native.mjs --api2 node scripts/test-panel-extension-native.mjs --api3 +node scripts/test-panel-extension-native.mjs --api4 +node scripts/test-panel-extension-electron.mjs # real preload, createWindow webPreferences; Linux may add --no-sandbox node --import tsx --test packages/server/src/forge/forge.test.ts packages/server/src/server/routes/panel-extension-work-items.test.ts node --conditions=browser --import tsx --test packages/ui/src/components/panel-extensions/work-items-channel.test.ts node --import tsx --test packages/ui/src/components/panel-extensions/image-bounds.test.ts @@ -422,6 +424,38 @@ concurrent changes and corrupt-state preservation. Catalogue checks cover SSRF/r verification, metadata-only discovery and withdrawn selections after warm reads. Rendered tests use the real right panel, installer, routes and event dispatcher for online browse/search/consent, offline fallback, incompatibility and lifecycle. +The native runners share `scripts/panel-extension-native-probe.mjs`: the frame is +built from `PANEL_EXTENSION_SANDBOX` and `panelExtensionDocument` (the real panel +must use that constant as its only sandbox), and must report `self.origin === "null"`, +an unreachable `parent.document`, no native line besides the host's positive +control, no request to the loopback leak counter (fetch, XHR, beacons, sockets, +event streams, workers, images, scripts, styles, fonts, media, frames, objects, +forms, popups) and exactly one port answer per declared method. Each run also hosts +the same frame with `allow-same-origin` added and with the CSP meta removed, and +fails unless the probe detects both. + +The macOS (WKWebView) and Linux (WebKitGTK) runs are manual, on the macOS and +Ubuntu test VMs, after each change to the frame document, bridge, panel iframe or +desktop hosts. Each host needs a checkout of the change with `npm ci` (Electron's +binary included), Node 22, Rust's `cargo` on `PATH` and Tauri 2's platform +prerequisites (Xcode Command Line Tools; WebKitGTK 4.1 and libsoup 3 development +packages). Both webviews need the desktop session of a logged-in user: + +- **Ubuntu (GNOME Wayland)**: with the user logged in at the console, a shell over + SSH reaches that session by exporting + `WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus XDG_SESSION_TYPE=wayland`, + then runs `node scripts/test-panel-extension-native.mjs` with no option and with + `--api2`, `--api3` and `--api4`, and `node scripts/test-panel-extension-electron.mjs`. + Electron's OS sandbox needs `node_modules/electron/dist/chrome-sandbox` owned by + root with mode 4755; otherwise Electron refuses to start, and + `--no-sandbox` runs the probe without that OS layer. +- **macOS**: log in to the desktop at the VM console (after resuming, correct the + clock with `sudo sntp -sS time.apple.com`), open Terminal in that session, and run + the same five commands. Over SSH without a desktop login the WKWebView host + never loads its page and the runner fails on its 60-second limit. + +A run counts only when every command prints its summary and exits 0; record the +host, OS version and each summary's `real` line in the review. ## Agent conventions diff --git a/dev-docs/SESSION_PRUNING_RPC.md b/dev-docs/SESSION_PRUNING_RPC.md index 3f224156..d48d34ca 100644 --- a/dev-docs/SESSION_PRUNING_RPC.md +++ b/dev-docs/SESSION_PRUNING_RPC.md @@ -210,6 +210,13 @@ restart) and gallery membership on that stored tool file. Unit tests cover rollb and client projection/read races; the gallery tests replay their proof mutations on copies of two OpenCode 2.2.1 Images transcripts, with and without media blobs (`assets-gallery-fixture.json`). +`scripts/test-design-companion-native.mjs` drives an Images conversation end to end with a +scripted chat model and a fake image model on the shipped bundle: two series of read images +under `.codenomad/design/`, proven with prompt, lineage and pixels, then a completed +generation without read-back whose reference is sent and read more than 32 assistant +messages later (pixels, unverifiable origin). With `--ui ` the same request is +prefilled from the installed Design extension through the real right panel, API 4 routes +and composer, sent, and its relative Markdown image is checked in the real transcript. The mock's usage numbers are synthetic; never cite them as provider token savings. Unvalidated: native WSL runs, interactive TUI, two desktop windows/scroll, @@ -225,6 +232,8 @@ node --import tsx --test packages/server/src/server/routes/images-conversation.t node --import tsx --test tests/browser/images-conversation.test.ts # from packages/ui node --import tsx --conditions=browser --test --test-force-exit packages/ui/src/stores/session-actions.test.ts packages/ui/src/stores/session-pruning-events.test.ts packages/ui/src/stores/session-pruning-projection.test.ts packages/ui/src/stores/opencode-data.test.ts node scripts/test-session-pruning-native.mjs C:/isolated-install/opencode2.exe +node scripts/test-design-companion-native.mjs C:/isolated-install/opencode2.exe # gallery end to end, scripted chat and image models +node scripts/test-design-companion-native.mjs C:/isolated-install/opencode2.exe --ui C:/abs/neuralnomads.design-1.0.0.zip # plus the Design panel, composer and transcript npm run typecheck --workspace @neuralnomads/codenomad npm run typecheck --workspace @codenomad/ui npm run build --workspace @codenomad/ui diff --git a/packages/tauri-app/tests/panel-extension/src/main.rs b/packages/tauri-app/tests/panel-extension/src/main.rs index 4160e27b..13feb2b7 100644 --- a/packages/tauri-app/tests/panel-extension/src/main.rs +++ b/packages/tauri-app/tests/panel-extension/src/main.rs @@ -4,6 +4,7 @@ fn probe(role: String) { println!("NATIVE:{role}"); } fn main() { let url = std::env::args().nth(1).expect("isolated fixture URL"); let profile = std::env::args().nth(2).expect("isolated browser profile"); + let seconds = std::env::args().nth(3).map_or(6, |value| value.parse().expect("lifetime in seconds")); tauri::Builder::default() .invoke_handler(tauri::generate_handler![probe]) .setup(move |app| { @@ -13,7 +14,7 @@ fn main() { .build()?; let handle = app.handle().clone(); std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(6)); + std::thread::sleep(std::time::Duration::from_secs(seconds)); handle.exit(0); }); Ok(()) diff --git a/packages/tauri-app/tests/panel-extension/tauri.conf.json b/packages/tauri-app/tests/panel-extension/tauri.conf.json index 17238809..0be8b274 100644 --- a/packages/tauri-app/tests/panel-extension/tauri.conf.json +++ b/packages/tauri-app/tests/panel-extension/tauri.conf.json @@ -12,5 +12,5 @@ "permissions": ["allow-probe"] }] } }, - "bundle": { "active": false, "icon": ["../../src-tauri/icon.ico"] } + "bundle": { "active": false, "icon": ["../../src-tauri/icon.ico", "../../src-tauri/icon.png"] } } diff --git a/packages/ui/tests/browser/fixture-exemptions.ts b/packages/ui/tests/browser/fixture-exemptions.ts index b674d27a..a0b3ec51 100644 --- a/packages/ui/tests/browser/fixture-exemptions.ts +++ b/packages/ui/tests/browser/fixture-exemptions.ts @@ -35,6 +35,7 @@ export const exemptions: Record = { "hotkeys-settings.test.ts": "Vite config diverges from the canonical daemon config", "http-admission-responsiveness.test.ts": "Vite config diverges from the canonical daemon config", "icon-toggle-active.test.ts": "served by a self-hosting harness or inline server that owns its Vite server", + "images-conversation.test.ts": "served by a harness that borrows from the daemon", "interruption-dock.test.ts": "served by a self-hosting harness or inline server that owns its Vite server", "markdown-latest-request.test.ts": "custom Vite transform/load hook", "message-scroll.test.ts": "stateful SSE middleware driven by the test", @@ -47,6 +48,7 @@ export const exemptions: Record = { "mission-recurrence-predispatch.test.ts": "no fixture page of its own", "native-mission-integration.qualification.ts": "qualification harness with capture middleware on its own server", "notification-icon-badge.test.ts": "Vite config diverges from the canonical daemon config", + "panel-extension-api4.test.ts": "served by a harness that borrows from the daemon", "panel-extensions.test.ts": "Vite config diverges: stateful /api proxy to an app the test drives", "plugin-packages.test.ts": "served by a harness that borrows from the daemon", "preferences-startup-state.test.ts": "Vite config diverges from the canonical daemon config", diff --git a/packages/ui/tests/browser/fixtures/design-companion.tsx b/packages/ui/tests/browser/fixtures/design-companion.tsx new file mode 100644 index 00000000..e0dad529 --- /dev/null +++ b/packages/ui/tests/browser/fixtures/design-companion.tsx @@ -0,0 +1,83 @@ +import { Show, createSignal } from "solid-js" +import { render } from "solid-js/web" +import SessionView from "../../../src/components/session/session-view" +import RightPanel from "../../../src/components/instance/shell/right-panel/RightPanel" +import type { PromptInputApi } from "../../../src/components/prompt-input/types" +import { ConfigProvider } from "../../../src/stores/preferences" +import { I18nProvider, useI18n } from "../../../src/lib/i18n" +import { ThemeProvider } from "../../../src/lib/theme" +import { sdkManager } from "../../../src/lib/sdk-manager" +import { serverEvents } from "../../../src/lib/server-events" +import { addInstance, setActiveInstanceId } from "../../../src/stores/instances" +import { activeSessionId, sessions, setActiveParentSession, setProviders } from "../../../src/stores/session-state" +import { fetchAgents, hydrateRestoredSessionChain } from "../../../src/stores/session-api" +import { updateSessionAgent } from "../../../src/stores/session-actions" +import { applyUiSettings } from "./ui-settings" +import "../../../src/index.css" + +// The Design companion against an isolated OpenCode runtime (scripts/test-design-companion-ui.mjs): +// the native client, transcript, composer, right panel, extension bridge and API 4 channel are +// real; only workspace chrome is seeded and native events arrive through the fixture stream. +await applyUiSettings({ locale: "en" }) +const params = new URLSearchParams(location.search) +const instanceId = "design-ui" +const sessionId = params.get("session")! +const directory = params.get("directory")! +const proxyPath = `/workspaces/${instanceId}/instance` +const client = sdkManager.createClient(instanceId, proxyPath) +addInstance({ id: instanceId, folder: directory, port: 0, pid: 0, proxyPath, status: "ready", client }) +setActiveInstanceId(instanceId) +setProviders(prev => new Map(prev).set(instanceId, [{ id: "fixture", name: "Fixture", defaultModelId: "vision", models: [ + { id: "vision", name: "Fixture vision", providerId: "fixture", limit: { context: 100000, output: 1000 }, cost: { input: 0, output: 0 } }, +] }])) +// Like the app, history loads once the native event stream is connected; events wait for the +// session's own read, as the app's session list precedes them. +let eventsReady = false, buffered: unknown[] | undefined = [] +const dispatch = (event: unknown) => (serverEvents as any).dispatchBatch([{ type: "instance.event", instanceId, event }]) +const connected = new Promise(resolve => { + const events = new EventSource("/fixture-events") + events.onmessage = message => { + eventsReady = true + const event = JSON.parse(message.data) + if (buffered) buffered.push(event) + else dispatch(event) + resolve() + } + window.addEventListener("pagehide", () => events.close(), { once: true }) +}) +await connected +await fetchAgents(instanceId, { directory }, true) +await hydrateRestoredSessionChain(instanceId, [sessionId]) +setActiveParentSession(instanceId, sessionId) +for (const event of buffered.splice(0)) dispatch(event) +buffered = undefined + +const [apis, setApis] = createSignal>({}) +const active = () => activeSessionId().get(instanceId) ?? null +const promptApi = () => { const id = active(); return id ? apis()[id] ?? null : null } + +function Fixture() { + const { t } = useI18n() + return
+
+ {id => + updateSessionAgent(instanceId, id, agentId)} onModelChange={async () => {}} + registerSessionPromptApi={(id, api) => setApis(current => ({ ...current, [id]: api }))} /> + } +
+
+ true} t={t} activeSessionId={active} activeSession={() => null} isPhoneLayout={() => false} + rightDrawerWidth={() => 460} rightDrawerWidthInitialized={() => true} onCloseRightDrawer={() => {}} promptInputApi={promptApi} setContentEl={() => {}} /> +
+
+} +;(window as any).fixture = { + eventsReady: () => eventsReady, + sessions: () => JSON.parse(JSON.stringify([...(sessions().get(instanceId)?.values() ?? [])])), + snapshot: () => ({ active: active(), + draft: (document.querySelector("textarea.prompt-input") as HTMLTextAreaElement | null)?.value ?? null, + focused: document.activeElement?.classList.contains("prompt-input") === true }), +} +render(() => , document.getElementById("root")!) diff --git a/scripts/native-product-fixture.mjs b/scripts/native-product-fixture.mjs index 5df62b1d..7590434c 100644 --- a/scripts/native-product-fixture.mjs +++ b/scripts/native-product-fixture.mjs @@ -9,13 +9,9 @@ import { setTimeout as delay } from "node:timers/promises" import { OpenCode } from "@opencode/client" import { stopFixtureChild } from "./native-fixture-guards.mjs" -// Shared by product-contract fixtures. Explicit owned process, random password, -// private discovery/database/home, and a loopback synthetic provider only. -// `projectConfig` lets the daemon read a fixture project's own `.opencode/` folder. Project discovery -// walks every ancestor directory, so outside CI the fixture must live outside the user's home -// (`CODENOMAD_NATIVE_FIXTURE_PARENT`), or it would read the user's own agents and skills. -export async function withProductRuntime(cli, prepare, run, { projectConfig = false } = {}) { - assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path") +/** The resolved `opencode` folder fixtures are created in. With `projectConfig` the daemon reads + * every ancestor's `.opencode/`, so outside CI it must lie outside the user's home. */ +export async function fixtureParent({ projectConfig = false } = {}) { const requested = path.join(projectConfig && process.env.CODENOMAD_NATIVE_FIXTURE_PARENT || os.tmpdir(), "opencode") await mkdir(requested, { recursive: true }) // The resolved path is both checked and used, so a link out of the home cannot reintroduce its ancestors. @@ -25,20 +21,53 @@ export async function withProductRuntime(cli, prepare, run, { projectConfig = fa assert(path.isAbsolute(relative) || relative.split(path.sep)[0] === "..", "Project-config fixtures read every ancestor directory: set CODENOMAD_NATIVE_FIXTURE_PARENT outside your home") } - const root = await mkdtemp(path.join(parent, "product-native-")) - const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_|npm_config_)/i.test(key))) - for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key) - // 2.0.29+ reads system-managed settings from %ProgramData%\opencode on Windows. - if (process.platform === "win32") { - for (const key of Object.keys(env)) if (key.toLowerCase() === "programdata") delete env[key] - env.ProgramData = path.join(root, "ProgramData") - } + return parent +} + +// Inherited only to run binaries: never provider credentials, cloud SDK settings, Git +// variables or user profile paths (a daemon reads all of them for provider discovery). +const INHERITED = /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|SYSTEMDRIVE|OS|NUMBER_OF_PROCESSORS|PROCESSOR_ARCHITECTURE|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|COMMONPROGRAMFILES|COMMONPROGRAMFILES\(X86\)|COMMONPROGRAMW6432|LANG|LANGUAGE|LC_[A-Z_]+|TZ|CI|SHELL|USER|LOGNAME|TERM)$/i + +/** The environment of a fixture-owned child: allowlisted process variables, then a private + * home, profile, temp, XDG and npm tree under `root`, then `extra`. */ +export async function fixtureEnvironment(root, extra = {}) { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => INHERITED.test(key))) + const temp = path.join(root, "tmp") + Object.assign(env, { + HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root, + APPDATA: path.join(root, "AppData", "Roaming"), LOCALAPPDATA: path.join(root, "AppData", "Local"), + // 2.0.29+ reads system-managed settings from %ProgramData%\opencode on Windows. + ProgramData: path.join(root, "ProgramData"), TEMP: temp, TMP: temp, TMPDIR: temp, + XDG_CONFIG_HOME: path.join(root, "XDG_CONFIG_HOME"), XDG_DATA_HOME: path.join(root, "XDG_DATA_HOME"), + XDG_STATE_HOME: path.join(root, "XDG_STATE_HOME"), XDG_CACHE_HOME: path.join(root, "XDG_CACHE_HOME"), + npm_config_userconfig: path.join(root, ".npmrc"), npm_config_globalconfig: path.join(root, "global.npmrc"), npm_config_cache: path.join(root, "npm-cache"), + ...extra, + }) + for (const directory of [env.APPDATA, env.LOCALAPPDATA, env.ProgramData, temp]) await mkdir(directory, { recursive: true }) + assertFixtureEnvironment(env) + return env +} + +/** Every key a fixture child receives from this process is allowlisted; the rest is the fixture's own. */ +export function assertFixtureEnvironment(env) { + const leaked = Object.keys(env).filter(key => process.env[key] !== undefined && process.env[key] === env[key] && !INHERITED.test(key)) + assert.deepEqual(leaked, [], "A fixture child inherits only allowlisted variables") + assert(Object.keys(env).every(key => !/^(GIT_|CLOUDSDK_|GOOGLE_|AWS_|AZURE_)|_API_KEY$/i.test(key)), "No Git, cloud or provider credential variable reaches a fixture child") +} + +// Shared by product-contract fixtures. Explicit owned process, random password, +// private discovery/database/home, and a loopback synthetic provider only. +// `projectConfig` lets the daemon read a fixture project's own `.opencode/` folder. Project discovery +// walks every ancestor directory, so outside CI the fixture must live outside the user's home +// (`CODENOMAD_NATIVE_FIXTURE_PARENT`), or it would read the user's own agents and skills. +export async function withProductRuntime(cli, prepare, run, { projectConfig = false } = {}) { + assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path") + const root = await mkdtemp(path.join(await fixtureParent({ projectConfig }), "product-native-")) const password = randomUUID() - Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root, + const env = await fixtureEnvironment(root, { OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"), ...(projectConfig ? {} : { OPENCODE_CONFIG_PROJECT_DISABLE: "1" }), OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1", OPENCODE_SERVER_PASSWORD: password }) - Object.assign(env, { npm_config_userconfig: path.join(root, ".npmrc"), npm_config_globalconfig: path.join(root, "global.npmrc"), npm_config_cache: path.join(root, "npm-cache") }) await mkdir(env.OPENCODE_CONFIG_DIR) const configuration = await prepare({ root, configDirectory: env.OPENCODE_CONFIG_DIR }) const requests = [] diff --git a/scripts/panel-extension-native-probe.mjs b/scripts/panel-extension-native-probe.mjs new file mode 100644 index 00000000..9ea08176 --- /dev/null +++ b/scripts/panel-extension-native-probe.mjs @@ -0,0 +1,281 @@ +// Shared native isolation probe for panel extension frames (Tauri and Electron hosts). +// A loopback server serves one host page per mode, answers the frame's port requests, +// collects the frame's report and counts every other request as a leak. +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { createServer } from "node:http" +import { tsImport } from "tsx/esm/api" + +const product = new URL("../packages/", import.meta.url) +const HANDSHAKE = "fixture-handshake" +const GRACE_MS = 1500 + +// Frame methods per API 4 permission (dev-docs/PANEL_EXTENSIONS.md#api-4); permissions +// without frame methods map to none. A new permission must be added here. +const API4_FRAME_METHODS = { + "session.images.read": { images: ["status", "list", "read"] }, + "session.images.prompts": {}, + "composer.prefill": { composer: ["prefill"] }, + "images.conversation": { conversation: ["status", "open", "startNew"] }, + "opencode.designAgent": {}, + "panel.preferences": { preferences: ["get", "set"] }, +} +const LEGACY_FRAME_METHODS = { 1: {}, 2: { assets: ["list", "read"] }, 3: { workItems: ["list", "open"] } } +const LISTENERS = { 2: { assets: ["onChanged"] }, 4: { images: ["onChanged"] } } +const ARGS = { + "assets.read": ["probe-target", { thumbnail: true }], "workItems.list": [{ kind: "issue" }], "workItems.open": ["issue", 1], + "images.read": ["probe-target", { thumbnail: true }], "composer.prefill": [{ text: "probe" }], "preferences.set": [{ probe: true }], +} + +export const MODES = ["real", "same-origin", "no-csp"] + +/** Loads the product's frame policy and binds the probe to the real panel's iframe attributes. */ +export async function loadFramePolicy() { + const frame = await tsImport(new URL("ui/src/components/panel-extensions/frame-document.ts", product).href, import.meta.url) + const contract = await tsImport(new URL("server/src/panel-extensions/contract.ts", product).href, import.meta.url) + const panel = await readFile(new URL("ui/src/components/panel-extensions/extension-panel.tsx", product), "utf8") + assert.deepEqual(panel.match(/\bsandbox=\S+/g), ["sandbox={PANEL_EXTENSION_SANDBOX}"], "The real panel must use PANEL_EXTENSION_SANDBOX as its only sandbox") + assert.match(panel, /panelExtensionDocument\(/, "The real panel must build its srcdoc with panelExtensionDocument") + for (const permission of contract.PANEL_EXTENSION_API4_PERMISSIONS) { + assert.ok(Object.hasOwn(API4_FRAME_METHODS, permission), `Probe has no frame method list for API 4 permission ${permission}`) + } + return { ...frame, api4Permissions: ["session.context", ...contract.PANEL_EXTENSION_API4_PERMISSIONS] } +} + +function frameMethods(apiVersion, permissions) { + if (apiVersion !== 4) return LEGACY_FRAME_METHODS[apiVersion] + const methods = {} + for (const permission of permissions) for (const [object, names] of Object.entries(API4_FRAME_METHODS[permission] ?? {})) { + methods[object] = [...(methods[object] ?? []), ...names] + } + return methods +} + +// Author code: identity, reachability, native and network attempts, then each declared method once. +function authorHtml({ leakBase, methods, apiVersion }) { + return `

Native probe

` +} + +// Host page: positive native control, one-use port handshake, one answer per request. +function hostPage({ mode, srcdoc, sandbox, apiVersion }) { + const attribute = value => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<") + return `Native probe host` +} + +/** Starts the loopback server serving `/host/`; everything outside host, report and favicon counts as a leak. */ +export async function startProbeServer({ policy, apiVersion }) { + const permissions = apiVersion === 4 ? policy.api4Permissions : [] + const methods = frameMethods(apiVersion, permissions) + const state = Object.fromEntries(MODES.map(mode => [mode, { reports: [], reportedAt: null, leaks: [] }])) + let current = MODES[0] + const record = (request, kind) => state[current].leaks.push(`${kind} ${request.method} ${request.url}`) + const server = createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1") + const [, area, mode] = url.pathname.split("/") + if (request.method === "GET" && area === "host" && MODES.includes(mode)) { + response.setHeader("Content-Type", "text/html; charset=utf-8") + response.end(hostPage({ mode, apiVersion, ...frameFor(mode) })) + return + } + if (request.method === "POST" && area === "report" && MODES.includes(mode)) { + let body = "" + request.on("data", chunk => { body += chunk }) + request.on("end", () => { state[mode].reports.push(JSON.parse(body)); state[mode].reportedAt = Date.now(); response.end("ok") }) + return + } + if (url.pathname !== "/favicon.ico") record(request, "http") + response.statusCode = url.pathname === "/favicon.ico" ? 404 : 204 + response.end() + }) + server.on("upgrade", (request, socket) => { record(request, "upgrade"); socket.destroy() }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + const port = server.address().port + const leakBase = `http://127.0.0.1:${port}/leak/` + const document = policy.panelExtensionDocument(authorHtml({ leakBase, methods, apiVersion }), HANDSHAKE, apiVersion, permissions) + const cspMeta = `` + assert.ok(document.includes(cspMeta), "Cannot build the CSP-removed negative control: the CSP meta changed shape") + function frameFor(mode) { + if (mode === "same-origin") return { sandbox: `${policy.PANEL_EXTENSION_SANDBOX} allow-same-origin`, srcdoc: document } + if (mode === "no-csp") return { sandbox: policy.PANEL_EXTENSION_SANDBOX, srcdoc: document.replace(cspMeta, "") } + return { sandbox: policy.PANEL_EXTENSION_SANDBOX, srcdoc: document } + } + return { + port, methods, + url: mode => { current = mode; return `http://127.0.0.1:${port}/host/${mode}` }, + outcome: (mode, { output, exitedAt }) => ({ mode, apiVersion, methods, output, exitedAt, ...state[mode] }), + close: () => new Promise(resolve => { server.closeAllConnections(); server.close(resolve) }), + } +} + +/** Every isolation violation of one mode's outcome; the real mode must have none. */ +export function probeViolations(outcome) { + const violations = [] + const fail = (category, message) => violations.push({ category, message }) + const nativeLines = outcome.output.split(/\r?\n/).map(line => line.trim()).filter(line => line.startsWith("NATIVE:")) + const children = nativeLines.filter(line => line !== "NATIVE:parent") + if (nativeLines.length - children.length !== 1) fail("positive-control", `expected one NATIVE:parent line, got ${JSON.stringify(nativeLines)}`) + if (children.length) fail("native", `frame reached native commands: ${JSON.stringify(children)}`) + if (outcome.leaks.length) fail("leak", `${outcome.leaks.length} leaked request(s): ${outcome.leaks.slice(0, 8).join(", ")}`) + if (outcome.reports.length !== 1) { fail("report", `expected one frame report, got ${outcome.reports.length}`); return violations } + if (outcome.exitedAt - outcome.reportedAt < GRACE_MS) fail("grace", `host exited ${outcome.exitedAt - outcome.reportedAt} ms after the report`) + const { child, answers, unexpected } = outcome.reports[0] + if (child.origin !== "null") fail("origin", `self.origin is ${JSON.stringify(child.origin)}`) + if (child.parentDocument !== "blocked") fail("parent", `parent.document is ${child.parentDocument}`) + if (child.context !== "native-session") fail("context", `context ${JSON.stringify(child.context)}`) + if (child.errors.length) fail("api", `frame method errors: ${child.errors.join("; ")}`) + if (unexpected.length) fail("api", `unexpected port messages: ${JSON.stringify(unexpected)}`) + const expected = Object.entries(outcome.methods).flatMap(([object, names]) => names.map(method => `${object}.${method}`)) + const answered = answers.map(answer => `${answer.capability}.${answer.method}`) + if (JSON.stringify(answered.toSorted()) !== JSON.stringify(expected.toSorted())) fail("api", `port answers ${JSON.stringify(answered)}, expected ${JSON.stringify(expected)}`) + for (const key of expected) if (child.api[key]?.probe !== key) fail("api", `frame saw ${JSON.stringify(child.api[key])} for ${key}`) + const surface = { apiVersion: "number", getContext: "function", onContext: "function" } + const listeners = LISTENERS[outcome.apiVersion] ?? {} + for (const object of new Set([...Object.keys(outcome.methods), ...Object.keys(listeners)])) surface[object] = [...(outcome.methods[object] ?? []), ...(listeners[object] ?? [])] + const sorted = value => JSON.stringify(Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, Array.isArray(entry) ? entry.toSorted() : entry]).toSorted())) + if (sorted(child.surface) !== sorted(surface)) fail("api", `frame surface ${JSON.stringify(child.surface)}, expected ${JSON.stringify(surface)}`) + const change = outcome.apiVersion === 4 ? { gallery: true, status: false } : outcome.apiVersion === 2 ? "called" : null + if (JSON.stringify(child.changed) !== JSON.stringify(change)) fail("api", `change notification ${JSON.stringify(child.changed)}`) + return violations +} + +const NEGATIVE_CATEGORIES = { "same-origin": ["origin", "parent"], "no-csp": ["leak"] } + +/** Runs every mode through `launch(url, mode)` (resolving to the host's output) and asserts real isolation plus detected negative controls. */ +export async function runProbe({ host, apiVersion, launch }) { + const policy = await loadFramePolicy() + const server = await startProbeServer({ policy, apiVersion }) + try { + const summary = { host, apiVersion, sandbox: policy.PANEL_EXTENSION_SANDBOX } + for (const mode of MODES) { + const output = await launch(server.url(mode), mode) + await new Promise(resolve => setTimeout(resolve, 300)) + const outcome = server.outcome(mode, { output, exitedAt: Date.now() }) + const violations = probeViolations(outcome) + const report = outcome.reports[0] + const line = { + origin: report?.child.origin, parentDocument: report?.child.parentDocument, positive: report?.positive, + nativeLines: output.split(/\r?\n/).map(entry => entry.trim()).filter(entry => entry.startsWith("NATIVE:")), + leakCount: outcome.leaks.length, answers: report?.answers.map(answer => `${answer.capability}.${answer.method}`), + frameGlobals: report && Object.fromEntries(Object.entries(report.child.globals).filter(([, type]) => type !== "undefined")), + violations: violations.map(violation => violation.category), + } + if (mode === "real") { + assert.deepEqual(violations, [], `${host} real mode failed:\n${violations.map(violation => violation.message).join("\n")}\n${output}`) + } else { + const missing = NEGATIVE_CATEGORIES[mode].filter(category => !line.violations.includes(category)) + assert.equal(missing.length, 0, `${host} negative control ${mode} was not detected (${missing.join(", ")}): ${JSON.stringify(line)}\n${output}`) + line.detected = NEGATIVE_CATEGORIES[mode] + } + summary[mode] = line + } + return summary + } finally { + await server.close() + } +} diff --git a/scripts/test-design-activation-native.mjs b/scripts/test-design-activation-native.mjs index 7c9a5117..60432915 100644 --- a/scripts/test-design-activation-native.mjs +++ b/scripts/test-design-activation-native.mjs @@ -7,15 +7,15 @@ import assert from "node:assert/strict" import { spawn, spawnSync } from "node:child_process" import { createHash, randomUUID } from "node:crypto" import { createServer } from "node:http" -import { mkdir, mkdtemp, readdir, readFile, realpath, writeFile } from "node:fs/promises" -import os from "node:os" +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" import readline from "node:readline" import { setTimeout as delay } from "node:timers/promises" import { fileURLToPath } from "node:url" import { tsImport } from "tsx/esm/api" import { OpenCode } from "@opencode/client" -import { clearFixtureGitEnvironment, stopFixtureChild } from "./native-fixture-guards.mjs" +import { stopFixtureChild } from "./native-fixture-guards.mjs" +import { fixtureEnvironment, fixtureParent } from "./native-product-fixture.mjs" const server = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "packages", "server", "src", "opencode") const load = file => tsImport(`../packages/server/src/opencode/${file}`, import.meta.url) @@ -60,19 +60,14 @@ const sources = { apacheLicense: await readSource(DESIGN_SOURCE_FILES.apacheLicense), } const content = parseDesignContent(sources) -const version = spawnSync(cli, ["--version"], { encoding: "utf8" }).stdout.trim() // Project discovery walks every ancestor directory: outside CI the fixture must live // outside the user's home, or it would read the user's own agents and skills. -const requested = path.join(process.env.CODENOMAD_NATIVE_FIXTURE_PARENT || os.tmpdir(), "opencode") -await mkdir(requested, { recursive: true }) -const parent = await realpath(requested) -const relative = path.relative(await realpath(os.homedir()), parent) -assert(process.env.CI || relative.startsWith("..") || path.isAbsolute(relative), - "Project-config fixtures read every ancestor directory: set CODENOMAD_NATIVE_FIXTURE_PARENT outside your home") -const root = await mkdtemp(path.join(parent, "design-activation-")) +const root = await mkdtemp(path.join(await fixtureParent({ projectConfig: true }), "design-activation-")) const configDirectory = path.join(root, "config") const project = name => path.join(root, name) -const gitEnv = clearFixtureGitEnvironment({ ...process.env }) +// Children (Git, the CLI, the daemon, the backends) get only allowlisted variables and a private profile. +const childEnv = await fixtureEnvironment(root) +const version = spawnSync(cli, ["--version"], { env: childEnv, encoding: "utf8" }).stdout.trim() const USER_AGENT = "USER-DESIGN-AGENT" const USER_SKILL = "USER-DESIGN-CORE" const files = { @@ -85,7 +80,7 @@ const files = { for (const name of ["p1", "p2-ask", "p3-user", "p4-late"]) { await mkdir(project(name), { recursive: true }) // A Git root stops OpenCode's upward project-configuration search. - assert.equal(spawnSync("git", ["init", "-q", project(name)], { env: gitEnv }).status, 0) + assert.equal(spawnSync("git", ["init", "-q", project(name)], { env: childEnv }).status, 0) } for (const [file, content] of Object.entries(files)) { await mkdir(path.dirname(file), { recursive: true }) @@ -126,22 +121,16 @@ const provider = createServer(async (req, res) => { await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve)) const settings = { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only" } -const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_|CODENOMAD_|npm_config_)/i.test(key))) -for (const key of Object.keys(env)) if (["programdata", "temp", "tmp", "appdata", "localappdata"].includes(key.toLowerCase())) delete env[key] -for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key) const password = randomUUID() -Object.assign(env, { - HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root, ProgramData: path.join(root, "ProgramData"), - TEMP: path.join(root, "tmp"), TMP: path.join(root, "tmp"), APPDATA: path.join(root, "AppData", "Roaming"), LOCALAPPDATA: path.join(root, "AppData", "Local"), +// Only the fixture providers load, and the fake image model is named, so automatic selection can never pick another provider. +const env = await fixtureEnvironment(root, { OPENCODE_CONFIG_DIR: configDirectory, OPENCODE_DB: path.join(root, "fixture.db"), OPENCODE_SERVER_PASSWORD: password, OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1", - npm_config_userconfig: path.join(root, ".npmrc"), npm_config_globalconfig: path.join(root, "global.npmrc"), npm_config_cache: path.join(root, "npm-cache"), - OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "fixture/vision", small_model: "fixture/vision", experimental: { image_generate: true }, providers: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "fixture/vision", small_model: "fixture/vision", enabled_providers: ["fixture", "fiximg"], experimental: { image_generate: { model: "fiximg/image" } }, providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", settings, models: { vision: { name: "Fixture", capabilities: { tools: true, input: ["text", "image/png"], output: ["text"] } } } }, fiximg: { package: "@opencode/ai/providers/openai-compatible/images", settings, models: { image: { name: "Fixture image", type: "image", capabilities: { tools: false, input: ["text"], output: ["image"] } } } }, } }), }) -for (const directory of [env.TEMP, env.ProgramData]) await mkdir(directory, { recursive: true }) let output = "" const daemon = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true }) @@ -177,13 +166,16 @@ try { } const located = route => async directory => (await api("GET", `${route}?location[directory]=${encodeURIComponent(directory)}`)).json?.data ?? [] const agents = located("/api/agent"), skills = located("/api/skill"), plugins = located("/api/plugin") + // Discovery sees only the fixture's loopback providers, whatever the caller's environment holds. + results.models = (await located("/api/model")(project("p1"))).map(model => `${model.providerID}/${model.id}`) + assert(results.models.every(ref => /^(fixture|fiximg)\//.test(ref)), `only fixture models are discovered: ${results.models}`) const designSkills = async directory => (await skills(directory)).filter(skill => skill.id.startsWith("design-")) const hasDesign = async directory => (await agents(directory)).some(agent => agent.id === "design") const allHave = async (names, wanted) => (await Promise.all(names.map(name => hasDesign(project(name))))).every(value => value === wanted) const loaded = ["p1", "p2-ask", "p3-user"] async function startBackend(design) { - const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "--backend", url, password, design ? "design" : "none"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }) + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "--backend", url, password, design ? "design" : "none"], { env: childEnv, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }) backends.add(child) const closed = new Promise(resolve => child.once("close", resolve)) const lines = readline.createInterface({ input: child.stdout })[Symbol.asyncIterator]() diff --git a/scripts/test-design-companion-native.mjs b/scripts/test-design-companion-native.mjs new file mode 100644 index 00000000..1fedeb1c --- /dev/null +++ b/scripts/test-design-companion-native.mjs @@ -0,0 +1,337 @@ +// The Design companion end to end against an explicit isolated CLI: private home, config, +// database, ProgramData and temp, loopback scripted chat and fake image models (no paid +// call), the shipped session-pruning bundle requested by an in-process backend lease, and +// only processes this script starts. The scripted model applies the Design rules (files under +// `.codenomad/design//`, one `read` per image, a refinement naming its parent); this +// validates the wiring, never a real model's behaviour. +// AC-16: 3 files, one `read` part per image, `images` gives the read images in 2 series, +// proven, with lineage and pixels. +// AC-52: a completed generation without read-back, its reference, the request sent, a read +// more than 32 messages later, shown with its pixels and an unverifiable origin. +// Usage: node scripts/test-design-companion-native.mjs [--ui ] +// `--ui` plays the AC-52 request through the real panel, composer and transcript (AC-54). +// Requires the session-pruning bundle (npm run build:pruning --workspace @neuralnomads/codenomad). +import assert from "node:assert/strict" +import { spawn, spawnSync } from "node:child_process" +import { createHash, randomUUID } from "node:crypto" +import { createServer } from "node:http" +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { setTimeout as delay } from "node:timers/promises" +import { crc32, deflateSync } from "node:zlib" +import { tsImport } from "tsx/esm/api" +import { OpenCode } from "@opencode/client" +import { clearFixtureGitEnvironment, stopFixtureChild } from "./native-fixture-guards.mjs" +import { fixtureEnvironment, fixtureParent } from "./native-product-fixture.mjs" + +const load = file => tsImport(`../packages/server/src/${file}`, import.meta.url) +const { DesktopPluginLifecycle, prepareDesktopPluginPresence } = await load("opencode/desktop-plugin-lifecycle.ts") +const { resolveDesktopPluginPaths } = await load("opencode/desktop-plugin-paths.ts") +const { designLeaseContent } = await load("opencode/design/activation.ts") +const { locationRequestOptions } = await load("opencode/compatibility/location.ts") +const { imagesResultSchema } = await load("opencode/session-pruning/assets-gallery-contract.ts") + +const cli = process.argv[2] +assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path") +const uiIndex = process.argv.indexOf("--ui") +const designZip = uiIndex > 0 ? process.argv[uiIndex + 1] : undefined +assert(uiIndex < 0 || (designZip && path.isAbsolute(designZip)), "--ui takes the absolute path of the packaged Design ZIP") +// Browser dependencies load before the native preamble (see test-session-pruning-ui.mjs). +const companionUI = designZip ? await import("./test-design-companion-ui.mjs") : undefined +await companionUI?.loadDesignCompanionUIDependencies() +// Project discovery walks every ancestor directory: outside CI the fixture lives outside the home. +const root = await mkdtemp(path.join(await fixtureParent({ projectConfig: true }), "design-companion-")) +const configDirectory = path.join(root, "config") +const project = path.join(root, "project") +await mkdir(configDirectory, { recursive: true }) +await mkdir(project, { recursive: true }) +await writeFile(path.join(configDirectory, "opencode.json"), "{}\n") +await writeFile(path.join(project, "README.md"), "Design companion fixture\n") +// Children (Git, the CLI, the daemon) get only allowlisted variables and a private profile; +// the in-process host routes' Git also runs without inherited GIT_* (a GIT_DIR or +// GIT_WORK_TREE would send its writes outside the fixture). +const childEnv = await fixtureEnvironment(root) +clearFixtureGitEnvironment(process.env) +const version = spawnSync(cli, ["--version"], { env: childEnv, encoding: "utf8" }).stdout.trim() +const git = args => spawnSync("git", args, { cwd: project, env: childEnv, encoding: "utf8" }) +assert.equal(git(["init", "-q", project]).status, 0) +const samePath = value => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value) +assert.equal(samePath(git(["rev-parse", "--absolute-git-dir"]).stdout.trim()), samePath(path.join(await realpath(project), ".git")), + "Git writes only the fixture's own repository") + +// Distinct, decodable 16 × 16 PNGs, so each file's pixels identify its generation. +function png() { + const chunk = (type, data) => { + const out = Buffer.alloc(12 + data.length) + out.writeUInt32BE(data.length, 0); out.write(type, 4, "ascii"); data.copy(out, 8) + out.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 8 + data.length) + return out + } + const side = 16, header = Buffer.alloc(13) + header.writeUInt32BE(side, 0); header.writeUInt32BE(side, 4); header[8] = 8; header[9] = 2 + const rows = Buffer.alloc((side * 3 + 1) * side) + for (let y = 0; y < side; y++) for (let x = 0; x < side * 3; x++) rows[y * (side * 3 + 1) + 1 + x] = Math.floor(Math.random() * 256) + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", deflateSync(rows)), chunk("IEND", Buffer.alloc(0))]) +} +const sha = bytes => createHash("sha256").update(bytes).digest("hex") + +// Scripted chat model. "RUN " emits those tool calls in one response. A finished image +// notice reads every saved, unread image in one response once all generations have their +// notice (or only says "Noted." while `readBack` is off). The AC-52 request ("Read the +// generated image `X`") takes PAD separate steps, then reads X, then answers with a relative +// Markdown image of it. +const PAD = 34 +const state = { readBack: true, imageCalls: [], chat: 0, providerError: undefined } +const text = content => typeof content === "string" ? content : Array.isArray(content) ? content.map(part => part.text ?? "").join("\n") : "" +const carriesImage = message => message.role === "user" && Array.isArray(message.content) && message.content.some(part => part.type === "image_url") +const sse = (res, delta, finish) => { + res.setHeader("content-type", "text/event-stream") + for (const [d, f] of [[delta, null], [{}, finish]]) res.write(`data: ${JSON.stringify({ id: "f", object: "chat.completion.chunk", model: "fixture", + choices: [{ index: 0, delta: d, finish_reason: f }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`) + res.end("data: [DONE]\n\n") +} +const say = (res, value) => sse(res, { role: "assistant", content: value }, "stop") +const call = (res, calls) => sse(res, { role: "assistant", tool_calls: calls.map((entry, index) => ({ index, id: `call_${randomUUID()}`, type: "function", + function: { name: entry.tool, arguments: JSON.stringify(entry.args) } })) }, "tool_calls") +const relative = file => (path.isAbsolute(file) ? path.relative(project, file) : file).replaceAll("\\", "/") +function respond(res, body) { + const messages = body.messages ?? [] + if (!body.tools?.length) return say(res, "Images") + const pathKey = name => { + const tool = body.tools.find(entry => entry.function?.name === name)?.function + assert(tool, `the ${name} tool is available`) + return Object.keys(tool.parameters.properties).find(key => /^(path|file_?path)$/i.test(key)) + } + const trigger = messages.findLastIndex(message => message.role === "user" && !carriesImage(message)) + const steps = messages.slice(trigger + 1).filter(message => message.role === "assistant" && message.tool_calls?.length).length + const instruction = text(messages[trigger]?.content) + const calls = messages.flatMap(message => message.tool_calls ?? []).map(entry => ({ name: entry.function?.name, args: JSON.parse(entry.function?.arguments || "{}") })) + const run = /RUN (\[.*\])/s.exec(instruction) + if (run) return steps ? say(res, "Started.") : call(res, JSON.parse(run[1])) + // Reads name the absolute file, as the notice does; answers cite it as relative Markdown. + const markdown = file => `![${path.posix.basename(relative(file))}](${relative(file)})` + const show = /Read the generated image `([^`]+)`/.exec(instruction) + if (show) { + if (steps < PAD) return call(res, [{ tool: "glob", args: { pattern: "*.md" } }]) + if (steps === PAD) return call(res, [{ tool: "read", args: { [pathKey("read")]: path.resolve(project, show[1]) } }]) + return say(res, `Here it is: ${markdown(show[1])}\n\nCritique: clean edges.`) + } + if (/Saved image: /.test(instruction)) { + const answeredReads = messages.slice(trigger + 1).flatMap(message => message.tool_calls ?? []).filter(entry => entry.function?.name === "read") + if (steps) return say(res, `Read back: ${answeredReads.map(entry => markdown(Object.values(JSON.parse(entry.function.arguments))[0])).join(" ")}`) + const saved = [...messages.filter(message => message.role === "user").map(message => text(message.content)).join("\n").matchAll(/Saved image: (.+?) \(/g)].map(match => match[1]) + const read = new Set(calls.filter(entry => entry.name === "read").map(entry => path.resolve(project, Object.values(entry.args)[0] ?? ""))) + const unread = [...new Set(saved)].filter(file => !read.has(path.resolve(project, file))) + const generations = calls.filter(entry => entry.name === "image_generate").length + if (!state.readBack || saved.length < generations || !unread.length) return say(res, "Noted.") + return call(res, unread.map(file => ({ tool: "read", args: { [pathKey("read")]: file } }))) + } + return say(res, "FIXTURE_OK") +} +const provider = createServer(async (req, res) => { + try { + let raw = "" + for await (const chunk of req) raw += chunk + if (req.url.startsWith("/v1/images/")) { + const bytes = png() + state.imageCalls.push({ url: req.url, sha: sha(bytes), edit: req.url.includes("edits") }) + res.setHeader("content-type", "application/json") + return res.end(JSON.stringify({ created: 1, data: [{ b64_json: bytes.toString("base64") }] })) + } + state.chat++ + respond(res, JSON.parse(raw)) + } catch (error) { state.providerError = error; res.destroy(error) } +}) +await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve)) +const settings = { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only" } + +const password = randomUUID() +// Only the fixture providers load, and the fake image model is named, so automatic selection can never pick another provider. +const env = await fixtureEnvironment(root, { + OPENCODE_CONFIG_DIR: configDirectory, OPENCODE_DB: path.join(root, "fixture.db"), OPENCODE_SERVER_PASSWORD: password, + OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "fixture/vision", small_model: "fixture/vision", enabled_providers: ["fixture", "fiximg"], experimental: { image_generate: { model: "fiximg/image" } }, providers: { + fixture: { package: "@opencode/ai/providers/openai-compatible", settings, models: { vision: { name: "Fixture vision", capabilities: { tools: true, input: ["text", "image/png"], output: ["text"] } } } }, + fiximg: { package: "@opencode/ai/providers/openai-compatible/images", settings, models: { image: { name: "Fixture image", type: "image", capabilities: { tools: false, input: ["text", "image/png"], output: ["image"] } } } }, + } }), +}) + +let output = "" +const daemon = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true }) +const daemonStopped = new Promise(resolve => daemon.once("close", resolve)) +daemon.stdout.on("data", data => { output += data }) +daemon.stderr.on("data", data => { output += data }) +const lifecycle = new DesktopPluginLifecycle("session-pruning") +const results = { version } +let passed = false + +async function until(label, predicate, ms = 60_000) { + const end = Date.now() + ms + for (;;) { + if (state.providerError) throw state.providerError + const value = await predicate() + if (value) return value + if (daemon.exitCode !== null) throw new Error(`Isolated CLI exited: ${output.slice(-3000)}`) + if (Date.now() > end) throw new Error(`Timed out: ${label}`) + await delay(150) + } +} + +try { + await until("daemon URL", () => /http:\/\/127\.0\.0\.1:\d+/.test(output)) + const url = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0] + const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` + const client = OpenCode.make({ baseUrl: url, headers: { authorization } }) + const location = { directory: project } + const locationOptions = locationRequestOptions(location) + const rpc = async (method, input) => (await client.rpc.call({ rpcID: "codenomad.session-pruning", method, location, input }, locationOptions)).output + await client.location.get({ location }) + // Discovery sees only the fixture's loopback providers, whatever the caller's environment holds. + const models = async types => (await (await fetch(`${url}/api/model?${types ? `types=${types}&` : ""}location[directory]=${encodeURIComponent(project)}`, + { headers: { authorization }, signal: AbortSignal.timeout(30_000) })).json()).data.map(model => `${model.providerID}/${model.id}`) + results.models = await models() + assert(results.models.every(ref => /^(fixture|fiximg)\//.test(ref)), `only fixture models are discovered: ${results.models}`) + assert.deepEqual(await models("image"), ["fiximg/image"]) + await lifecycle.setLeaseContent(designLeaseContent(true)) + await prepareDesktopPluginPresence(await resolveDesktopPluginPaths({ client, assertCurrent() {} }, { kind: "host", platform: process.platform, binary: "fixture" }), + () => {}, { pruning: lifecycle }) + await until("Design agent and gallery RPC", async () => (await client.agent.list({ location })).data.some(agent => agent.id === "design") + && await rpc("imagesConversation", { mode: "lookup" }).then(answer => answer.status === "absent", () => false)) + + const opened = await rpc("imagesConversation", { mode: "open", title: "Images", agent: "design", model: { providerID: "fixture", id: "vision" } }) + assert.equal(opened.created, true, JSON.stringify(opened)) + const sessionID = opened.sessionID + const messages = async () => { + const { data } = await client.message.list({ sessionID, limit: 200, order: "asc" }) + assert(data.length < 200, "the whole fixture conversation fits one native page") + return data + } + const notices = list => list.filter(message => message.type === "synthetic" && message.metadata?.source === "image_generate") + const readParts = list => list.flatMap(message => message.type === "assistant" + ? message.content.filter(part => part.type === "tool" && part.name === "read" && part.state?.status === "completed").map(part => ({ message: message.id, part })) : []) + const idle = async () => { await client.session.wait({ sessionID }, { signal: AbortSignal.timeout(60_000) }); await delay(300) } + async function turn(prompt, settled, label) { + await client.session.prompt({ sessionID, text: prompt }) + await until(label, async () => settled(await messages())) + await idle() + } + // Every page, as the panel reads them; each answer is checked against the published contract. + async function gallery() { + const entries = [] + let cursor + do { + const page = await rpc("images", { sessionID, prompts: true, ...(cursor ? { cursor } : {}) }) + assert.equal(page.status, "page", JSON.stringify(page)) + imagesResultSchema.parse(page) + entries.push(...page.entries) + cursor = page.cursor + } while (cursor) + return entries + } + const pixels = async target => { + const answer = await rpc("imageRead", { sessionID, target }) + assert.equal(answer.status, "asset", JSON.stringify(answer).slice(0, 300)) + return Buffer.from(answer.uri.slice(answer.uri.indexOf(",") + 1), "base64") + } + const design = path.join(project, ".codenomad", "design", "icon") + const file = name => path.join(design, name) + const gen = (prompt, name, extra = {}) => ({ tool: "image_generate", args: { prompt, path: `.codenomad/design/icon/${name}`, ...extra } }) + + // AC-16, series 1: two generations, both read back in one answer once both are saved. + await turn(`RUN ${JSON.stringify([gen("Flat blue app icon", "icon-minimal-a.png"), gen("Bold orange app icon", "icon-bold-a.png")])}`, + list => notices(list).length >= 2 && readParts(list).length >= 2, "series 1 generated and read") + // Series 2: a refinement of icon-minimal-a, which names it as its parent. + await turn(`RUN ${JSON.stringify([gen("Same icon, warmer", "icon-minimal-a2.png", { images: [".codenomad/design/icon/icon-minimal-a.png"] })])}`, + list => notices(list).length >= 3 && readParts(list).length >= 3, "refinement generated and read") + + const files = (await readdir(design)).sort() + assert.deepEqual(files, ["icon-bold-a.png", "icon-minimal-a.png", "icon-minimal-a2.png"], "3 files under .codenomad/design//") + assert.equal(state.imageCalls.length, 3) + assert.deepEqual(state.imageCalls.map(entry => entry.edit), [false, false, true], "the refinement sends its parent to the image model") + const transcript = await messages() + const reads = readParts(transcript) + assert.equal(reads.length, 3, "one read part per image") + for (const { part } of reads) assert.equal(part.state.content.filter(item => item.type === "file" && /^image\//.test(item.mime ?? "")).length, 1, JSON.stringify(part.state.content).slice(0, 300)) + assert.equal(new Set(reads.slice(0, 2).map(entry => entry.message)).size, 1, "the first two reads share one answer") + let entries = await gallery() + const images = entries.filter(entry => entry.kind === "image") + const generations = entries.filter(entry => entry.kind === "generation") + assert.equal(images.length, 3, JSON.stringify(entries)) + assert.equal(generations.length, 3) + const byLabel = Object.fromEntries(images.map(entry => [entry.label, entry])) + assert.deepEqual(Object.keys(byLabel).sort(), ["icon-bold-a", "icon-minimal-a", "icon-minimal-a2"]) + assert.equal(new Set(images.map(entry => entry.series)).size, 2, "two series") + assert.equal(byLabel["icon-minimal-a"].series, byLabel["icon-bold-a"].series) + assert.notEqual(byLabel["icon-minimal-a2"].series, byLabel["icon-minimal-a"].series) + for (const entry of images) { + assert.equal(entry.origin, "proven", JSON.stringify(entry)) + assert(generations.some(generation => generation.key === entry.generationKey && generation.tool === "completed" && generation.outcome === "completed"), JSON.stringify(entry)) + assert.equal(typeof entry.prompt, "string") + assert.equal(entry.model, "fiximg/image", "the named fake image model generated it") + const bytes = await pixels(entry.target) + assert.deepEqual(bytes, await readFile(file(`${entry.label}.png`)), `${entry.label}: the gallery pixels are the file's`) + assert(state.imageCalls.some(item => item.sha === sha(bytes)), `${entry.label}: the pixels come from the image model`) + } + assert.deepEqual([byLabel["icon-minimal-a"].prompt, byLabel["icon-bold-a"].prompt, byLabel["icon-minimal-a2"].prompt], + ["Flat blue app icon", "Bold orange app icon", "Same icon, warmer"]) + assert.deepEqual(byLabel["icon-minimal-a2"].parentKeys, [byLabel["icon-minimal-a"].generationKey], "lineage: the refinement's parent") + assert.equal(byLabel["icon-minimal-a2"].otherRefs ?? 0, 0) + assert.equal(byLabel["icon-minimal-a"].parentKeys?.length ?? 0, 0) + const raw = JSON.stringify(entries) + for (const leak of [project, project.replaceAll("\\", "/"), "ses_", "call_", "Saved image"]) assert(!raw.includes(leak), `the gallery never carries ${leak}`) + results.ac16 = { files, series: 2, images: images.map(entry => ({ label: entry.label, origin: entry.origin, parents: entry.parentKeys?.length ?? 0 })) } + console.log("PASS AC-16: 3 files under .codenomad/design/icon/, one read part per image, 2 series, proven, lineage and pixels") + + // AC-52: a completed generation the agent does not read back. + state.readBack = false + const answered = (list, words) => list.some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text.includes(words))) + await turn(`RUN ${JSON.stringify([gen("Monochrome app icon", "icon-mono-a.png")])}`, list => notices(list).length >= 4, "unread generation") + await until("the notice answered without a read", async () => { + const list = await messages() + return answered(list.slice(list.indexOf(notices(list)[3])), "Noted.") + }) + await idle() + entries = await gallery() + const unread = entries.find(entry => entry.kind === "generation" && entry.prompt === "Monochrome app icon") + assert(unread && unread.tool === "completed" && unread.outcome === "completed", JSON.stringify(unread)) + assert(!entries.some(entry => entry.kind === "image" && entry.generationKey === unread.key), "no read-back image is attached") + assert.equal(entries.filter(entry => entry.kind === "image").length, 3) + const reference = await rpc("imageReference", { sessionID, target: unread.target }) + assert.deepEqual(reference, { status: "path", path: ".codenomad/design/icon/icon-mono-a.png" }) + const generatedAt = (await messages()).findLastIndex(message => message.type === "assistant" && message.content.some(part => part.type === "tool" && part.name === "image_generate")) + + if (companionUI) await companionUI.testDesignCompanionUI({ cli, client, url, password, root, location, sessionID, designZip, until, idle }) + else { + // The Design extension's English request, as `composer.prefill` substitutes its reference. + await turn(`Read the generated image \`${reference.path}\` and show it here with your critique.`, + list => readParts(list).length >= 4 && answered(list, "Here it is"), "late read-back") + } + + const after = await messages() + const lateRead = readParts(after).at(-1) + const readAt = after.findIndex(message => message.id === lateRead.message) + const between = after.slice(generatedAt + 1, readAt).filter(message => message.type === "assistant").length + assert(between > 32, `the read comes ${between} assistant messages after its generation`) + entries = await gallery() + const late = entries.filter(entry => entry.kind === "image" && entry.label === "icon-mono-a") + assert.equal(late.length, 1, JSON.stringify(entries.filter(entry => entry.kind === "image"))) + assert.equal(late[0].origin, "unverifiable", JSON.stringify(late[0])) + for (const key of ["generationKey", "parentKeys", "prompt", "model"]) assert.equal(late[0][key], undefined, `an unverifiable image carries no ${key}`) + assert.deepEqual(await pixels(late[0].target), await readFile(file("icon-mono-a.png")), "the late read shows the file's pixels") + assert.deepEqual(await rpc("imageReference", { sessionID, target: late[0].target }), { status: "refused", reason: "unverifiable" }) + // The earlier series stay proven on older pages. + assert.deepEqual(entries.filter(entry => entry.kind === "image" && entry.label !== "icon-mono-a").map(entry => entry.origin), ["proven", "proven", "proven"]) + results.ac52 = { assistantMessagesBetween: between, origin: late[0].origin, ui: Boolean(designZip) } + console.log(`PASS AC-52${designZip ? " (native and browser)" : " (native)"}: no read-back, reference, request sent, read ${between} assistant messages later, pixels, unverifiable origin`) + assert.equal(state.imageCalls.length, 4, "every image request reached the fake image provider") + console.log(`PASS ${version}: ${JSON.stringify({ ...results, imageCalls: state.imageCalls.length, chatRequests: state.chat })}`) + passed = true +} finally { + await lifecycle.stop() + try { await stopFixtureChild(daemon, daemonStopped) } finally { + provider.closeAllConnections() + await new Promise(resolve => provider.close(resolve)) + if (passed && !process.env.CODENOMAD_KEEP_FIXTURE) await rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }).catch(() => {}) + else { await writeFile(path.join(root, "daemon.log"), output).catch(() => {}); console.log(`Isolated fixture retained: ${root}`) } + } +} diff --git a/scripts/test-design-companion-ui.mjs b/scripts/test-design-companion-ui.mjs new file mode 100644 index 00000000..31137c4a --- /dev/null +++ b/scripts/test-design-companion-ui.mjs @@ -0,0 +1,206 @@ +// Browser half of scripts/test-design-companion-native.mjs: the S8 path (AC-52) through the real +// Design extension, right panel, API 4 routes and channel, composer and transcript, against the +// isolated runtime; and the transcript's relative Markdown image of a read image (AC-54). +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { tsImport } from "tsx/esm/api" +import replyFrom from "@fastify/reply-from" +import pino from "pino" +import { loadPruningUIDependencies } from "./test-session-pruning-ui.mjs" +import { assertFixtureEnvironment, fixtureEnvironment } from "./native-product-fixture.mjs" + +const load = file => tsImport(`../packages/server/src/${file}`, import.meta.url) +// Vite loads before the native preamble (see test-session-pruning-ui.mjs). +export const loadDesignCompanionUIDependencies = () => loadPruningUIDependencies() + +export async function testDesignCompanionUI({ cli, client, url, password, root, location, sessionID, designZip, until, idle }) { + const { uiRoot, chromium, createServer, solid, Fastify } = await loadPruningUIDependencies() + const { registerInstanceProxyRoutes } = await load("server/http-server.ts") + const { OpenCodeCliService } = await load("workspaces/opencode-cli-service.ts") + const { OpenCodeSharedService } = await load("workspaces/opencode-service.ts") + const { PanelExtensionStore } = await load("panel-extensions/store.ts") + const { readPanelExtensionArchive } = await load("panel-extensions/archive.ts") + const { registerPanelExtensionRoutes } = await load("server/routes/panel-extensions.ts") + const { registerPanelExtensionApi4Routes } = await load("server/routes/panel-extension-api4.ts") + const { WorktreeDeletionFence } = await load("workspaces/worktree-session-evacuation.ts") + const { PluginControls } = await load("opencode/plugin-controls.ts") + const { ImageSettings } = await load("opencode/image-settings.ts") + + const instanceId = "design-ui", directory = location.directory + // The production shared-service connection; its lifecycle answers from the isolated daemon + // and never runs a service command. + const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` + const lifecycle = new OpenCodeCliService({ label: "Isolated Design companion", timeoutMs: 5000, command: args => ({ command: cli, args, options: {} }) }, + { execFile: async (_file, args) => ({ stdout: args.join(" ") === "service get password" ? password : url, stderr: "" }) }) + const sharedService = new OpenCodeSharedService() + await sharedService.client({ kind: "lifecycle", identity: "isolated-design-companion", lifecycle }) + const connection = await sharedService.acquire() + const endpoint = connection.endpoint, runtimeFetch = connection.fetch + const owns = candidate => path.resolve(candidate) === path.resolve(directory) + const workspaceManager = { + get: id => id === instanceId ? { id, path: directory } : undefined, + getSharedServiceEndpoint: async () => endpoint, getSharedServiceConnection: async () => connection, + getInstanceAuthorizationHeader: () => authorization, + getServiceDirectory: () => directory, getServiceLocation: () => location, getWorktrees: async () => ({ worktrees: [] }), + getSharedServiceClient: async () => connection.client, getSharedServiceFetch: async () => runtimeFetch, desktopPluginsReady: async () => {}, + // The snapshot sent to the isolated session: the fixture's own environment, never this process's. + getSessionEnvironment: async () => { + const snapshot = await (await load("workspaces/session-environment.ts")).sessionEnvironment({}, + { environment: await fixtureEnvironment(path.join(root, "session-environment")) }) + assertFixtureEnvironment(snapshot) + return snapshot + }, + getWorktreeIdentityForPath: async (_id, candidate) => owns(candidate) ? "isolated-fixture" : undefined, + getServiceDirectoryForPath: async (_id, candidate) => owns(candidate) ? directory : undefined, + ownsDirectory: async (_id, candidate) => owns(candidate), ownsPath: async (_id, candidate) => owns(candidate), + ownsLocation: async (id, candidate) => id === instanceId && owns(candidate.directory) && candidate.workspaceID === location.workspaceID, + getServicePathStyle: () => process.platform === "win32" ? "win32" : "posix", + getServiceWslDistro: () => undefined, getHostPathForServicePath: async (_id, value) => value, + } + const logger = pino({ level: process.env.CODENOMAD_FIXTURE_LOG_LEVEL || "silent" }) + const store = new PanelExtensionStore(path.join(root, "panel-extensions")) + const installed = await readPanelExtensionArchive(await readFile(designZip)) + assert.equal(installed.manifest.id, "neuralnomads.design") + await store.install(installed) + await store.activate(installed.manifest.id, installed.digest, true) + const worktreeDeletionFence = new WorktreeDeletionFence() + const imageSettings = new ImageSettings(new PluginControls({ workspaceManager, worktreeDeletionFence, logger }), workspaceManager) + const broker = Fastify() + await broker.register(replyFrom) + registerInstanceProxyRoutes(broker, { workspaceManager, worktreeDeletionFence: { enter: () => () => {} }, logger }) + registerPanelExtensionRoutes(broker, { store, workspaceManager }) + registerPanelExtensionApi4Routes(broker, { store, workspaceManager, worktreeDeletionFence, imageSettings }) + // Like production's multiplexed stream, the server owns the native subscriber. + broker.get("/fixture-events", async (request, reply) => { + const abort = new AbortController() + reply.hijack() + reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }) + reply.raw.on("close", () => abort.abort()) + try { + for await (const event of client.event.subscribe({ signal: abort.signal })) reply.raw.write(`data: ${JSON.stringify(event)}\n\n`) + } catch (error) { if (!abort.signal.aborted) reply.raw.destroy(error) } + finally { reply.raw.end() } + }) + await broker.listen({ host: "127.0.0.1", port: 0 }) + const brokerUrl = `http://127.0.0.1:${broker.server.address().port}` + // An owned optimized-dependency cache, removed only once the server drained (dev-docs/BROWSER_TESTING.md). + const { createFixtureCache } = await tsImport("../packages/ui/tests/browser/fixture-cache.ts", import.meta.url) + const { createFixtureShutdown } = await tsImport("../packages/ui/tests/browser/fixture-shutdown.ts", import.meta.url) + const cache = await createFixtureCache(), shutdown = createFixtureShutdown(cache) + const server = await createServer({ + configFile: false, root: uiRoot, logLevel: "error", cacheDir: cache.cacheDir, plugins: [shutdown.plugin, solid(), { + name: "design-companion-fixture", + configureServer(vite) { + vite.middlewares.use("/fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await vite.transformIndexHtml("/fixture", '
')) + }) + }, + }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: { + [`/workspaces/${instanceId}/instance`]: { target: brokerUrl }, "/api/panel-extensions": { target: brokerUrl }, "/fixture-events": { target: brokerUrl }, + } }, + }) + shutdown.own(server) + let browser, page + const pageErrors = [] + try { + await server.listen() + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) + page = await browser.newPage({ viewport: { width: 1400, height: 950 }, locale: "en-US" }) + page.on("pageerror", error => { pageErrors.push(error.message); console.error("UI error:", error.stack ?? error.message) }) + page.on("response", response => { if (response.status() >= 400) console.error("UI response", response.status(), response.request().method(), response.url()) }) + page.on("console", message => { if (message.type() === "error") console.error("UI console:", message.text().slice(0, 500)) }) + // The host's own event transport stays steady, as in the browser suite; native events + // reach the page through the fixture stream only. + await page.addInitScript(() => { + const Native = window.EventSource + window.EventSource = class extends Native { + constructor(url, init) { + if (String(url).includes("/fixture-events")) super(url, init) + else return { readyState: 1, addEventListener() {}, removeEventListener() {}, close() {} } + } + } + }) + // Every other host route answers empty, as in the browser suite. + await page.route(url => url.pathname.startsWith("/api/") && !url.pathname.startsWith("/api/panel-extensions"), + route => route.fulfill({ json: { entries: [], files: [], records: [], messages: [], data: [] } })) + const before = (await client.message.list({ sessionID, limit: 200, order: "asc" })).data.length + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/fixture?session=${sessionID}&directory=${encodeURIComponent(directory)}`) + await page.waitForFunction(() => Boolean(window.fixture?.eventsReady()), undefined, { timeout: 60_000 }) + await page.getByRole("tab", { name: "Design", exact: true }).click() + const frame = page.frameLocator("iframe[title='Design']") + + // S8: the completed generation without read-back, and its request prefilled, never sent. + const unread = frame.locator('li.gen[data-state="no-read"]') + await unread.getByText("No read-back image attached", { exact: true }).waitFor({ timeout: 30_000 }) + assert.equal(await frame.locator('.tile[data-origin="unverifiable"]').count(), 0) + assert.equal(await frame.locator(".tile").count(), 3, "the three proven images are in the gallery") + await unread.getByRole("button", { name: "Ask the agent to show it", exact: true }).click() + const expected = "Read the generated image `.codenomad/design/icon/icon-mono-a.png` and show it here with your critique." + await page.waitForFunction(text => window.fixture.snapshot().draft === text, expected, { timeout: 20_000 }) + await page.waitForFunction(() => window.fixture.snapshot().focused) + assert.equal((await client.message.list({ sessionID, limit: 200, order: "asc" })).data.length, before, "prefilling sends nothing") + await page.screenshot({ path: path.join(root, "ui-prefilled.png") }) + + // The user sends it from the real composer. + await page.locator("button.send-button").click() + await until("the scripted model's late read-back", async () => (await client.message.list({ sessionID, limit: 200, order: "asc" })).data + .some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text.includes("Here it is"))), 120_000) + await idle() + const sent = (await client.message.list({ sessionID, limit: 200, order: "asc" })).data.filter(message => message.type === "user") + assert.equal(sent.at(-1)?.text ?? sent.at(-1)?.content?.find?.(part => part.type === "text")?.text, expected, "the prefilled request is what was sent") + + // The late read appears in the panel with its pixels and an unverifiable origin. + const fileUri = `data:image/png;base64,${(await readFile(path.join(directory, ".codenomad", "design", "icon", "icon-mono-a.png"))).toString("base64")}` + const late = frame.locator('.tile[data-origin="unverifiable"]') + await late.filter({ hasText: "icon-mono-a" }).waitFor({ timeout: 30_000 }) + assert.match(await late.textContent(), /origin not verifiable/) + const preview = late.locator(".preview img") + await preview.waitFor({ timeout: 20_000 }) + // Decoded in the image's own document (the frame's `blob:` URLs belong to its opaque origin). + const samePixels = image => image.evaluate(async (element, file) => { + const draw = async source => { + const decoded = new Image(); decoded.src = source; await decoded.decode() + const canvas = document.createElement("canvas"); canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight + const context = canvas.getContext("2d"); context.drawImage(decoded, 0, 0) + return { size: [canvas.width, canvas.height], data: Array.from(context.getImageData(0, 0, canvas.width, canvas.height).data) } + } + const [shown, expected] = [await draw(element.src), await draw(file)] + return JSON.stringify(shown.size) === JSON.stringify(expected.size) && shown.data.every((value, index) => value === expected.data[index]) + }, fileUri) + assert.equal(await samePixels(preview), true, "the panel shows the file's pixels") + await late.click() + const detail = frame.locator("#selection") + await detail.getByText("origin not verifiable", { exact: true }).waitFor() + assert.equal(await detail.locator("dd.prompt").count(), 0, "an unverifiable image shows no prompt") + assert.doesNotMatch(await detail.textContent(), /Monochrome app icon/) + await page.screenshot({ path: path.join(root, "ui-late-read.png") }) + console.log("PASS AC-52 browser: no read-back shown, request prefilled and sent from the composer, late read with pixels and unverifiable origin in the panel") + + // AC-54: the answer's relative Markdown image shows the read image in the real transcript. + const answer = page.locator(".markdown-local-image[data-local-image='.codenomad/design/icon/icon-mono-a.png']") + await page.waitForFunction(() => { + const image = document.querySelector("img.markdown-local-image[data-local-image='.codenomad/design/icon/icon-mono-a.png']") + return Boolean(image?.complete && image.naturalWidth === 16) + }, undefined, { timeout: 30_000 }) + assert.equal(await answer.count(), 1) + assert.equal(await samePixels(answer), true, "the transcript shows the read file's pixels") + assert.equal(await page.locator("span.markdown-local-image").count(), 0, "no unresolved local-image label") + await page.screenshot({ path: path.join(root, "ui-transcript.png") }) + console.log("PASS AC-54: relative Markdown ![](.codenomad/design/...) of a read image renders it in the real transcript") + assert.deepEqual(pageErrors, [], "no browser errors") + } catch (error) { + if (page) { + await page.screenshot({ path: path.join(root, "ui-failure.png"), fullPage: true }).catch(() => {}) + console.error("UI sessions:", JSON.stringify(await page.evaluate(() => window.fixture?.sessions?.()).catch(String)).slice(0, 2000)) + } + throw error + } finally { + await browser?.close() + await server.close() + await broker.close() + await sharedService.shutdown() + } +} diff --git a/scripts/test-panel-extension-electron.mjs b/scripts/test-panel-extension-electron.mjs new file mode 100644 index 00000000..2e1caacf --- /dev/null +++ b/scripts/test-panel-extension-electron.mjs @@ -0,0 +1,105 @@ +// Hosts the frame probe in real Electron with the real preload and the webPreferences +// of createWindow (read from main.ts). No CodeNomad backend, profile or daemon is involved. +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { createRequire } from "node:module" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { runProbe } from "./panel-extension-native-probe.mjs" + +const root = fileURLToPath(new URL("../", import.meta.url)) +const electronApp = path.join(root, "packages/electron-app") +const require = createRequire(path.join(electronApp, "package.json")) +const preload = path.join(electronApp, "electron/preload/index.cjs") +const windowId = "panel-extension-probe" + +function topLevelEntries(body) { + const entries = [] + let current = "", depth = 0, quote = null + for (const char of body) { + if (quote) { if (char === quote) quote = null } + else if (char === '"' || char === "'" || char === "`") quote = char + else if ("([{".includes(char)) depth++ + else if (")]}".includes(char)) depth-- + if (char === "," && depth === 0 && !quote) { entries.push(current.trim()); current = "" } else current += char + } + return current.trim() ? [...entries, current.trim()] : entries +} + +// Evaluates createWindow's webPreferences entry by entry; any entry this test cannot +// reproduce exactly fails instead of being dropped. +function createWindowPreferences(source) { + const start = source.indexOf("const createWindow = (") + assert.notEqual(start, -1, "createWindow is no longer found in main.ts") + const open = source.indexOf("{", source.indexOf("webPreferences: {", start)) + assert.ok(open > start, "createWindow no longer declares webPreferences") + let depth = 0, close = open + for (; close < source.length; close++) { + if (source[close] === "{") depth++ + else if (source[close] === "}" && --depth === 0) break + } + const preferences = {} + for (const entry of topLevelEntries(source.slice(open + 1, close))) { + if (entry === "...(saved ? { zoomFactor: saved.zoomFactor } : {})") continue // persisted zoom only + const [, key, value] = /^(\w+):\s*([\s\S]+)$/.exec(entry) ?? [] + if (value === "true" || value === "false") preferences[key] = value === "true" + else if (value === "!isMac") preferences[key] = process.platform !== "darwin" + else if (key === "preload" && value === "getPreloadPath()") preferences.preload = preload + else if (key === "additionalArguments" && /^\[[\s\S]*\]$/.test(value)) { + preferences.additionalArguments = topLevelEntries(value.slice(1, -1)).map(item => { + if (/^"[^"]*"$/.test(item)) return JSON.parse(item) + assert.match(item, /^`[^`$]*\$\{windowId\}[^`$]*`$/, `Unrecognized createWindow additional argument: ${item}`) + return item.slice(1, -1).replace("${windowId}", windowId) + }) + } else assert.fail(`Unrecognized createWindow webPreferences entry: ${entry}`) + } + assert.equal(preferences.preload, preload, "createWindow must load the real preload") + return preferences +} + +const webPreferences = createWindowPreferences(await readFile(path.join(electronApp, "electron/main/main.ts"), "utf8")) +const channels = [...new Set([...(await readFile(preload, "utf8")).matchAll(/ipcRenderer\.invoke\("([^"]+)"/g)].map(match => match[1]))] +assert.ok(channels.includes("cli:getStatus"), "The preload no longer exposes the positive control") +const directory = await mkdtemp(path.join(os.tmpdir(), "codenomad-panel-electron-")) +try { + const entry = path.join(directory, "fixture.cjs") + // Every preload invoke channel reports whether the main frame or a subframe sent it. + await writeFile(entry, `const { app, BrowserWindow, ipcMain } = require("electron") +const config = JSON.parse(require("node:fs").readFileSync(process.argv[process.argv.indexOf("--probe-config") + 1], "utf8")) +process.on("uncaughtException", error => { console.error(error); app.exit(1) }) +for (const channel of config.channels) ipcMain.handle(channel, event => { + const main = event.sender.mainFrame, frame = event.senderFrame + const fromMain = Boolean(frame) && frame.processId === main.processId && frame.routingId === main.routingId + console.log(fromMain && channel === "cli:getStatus" ? "NATIVE:parent" : "NATIVE:" + (fromMain ? "parent-other:" : "child:") + channel) + return null +}) +void app.whenReady().then(() => { + const window = new BrowserWindow({ show: false, webPreferences: config.webPreferences }) + void window.loadURL(config.url) + setTimeout(() => app.exit(0), config.seconds * 1000) +}) +`) + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + delete env.NODE_OPTIONS + // Some Linux test hosts disable unprivileged user namespaces; opt-in only. + const sandboxArgs = process.platform === "linux" && process.argv.includes("--no-sandbox") ? ["--no-sandbox"] : [] + const summary = await runProbe({ host: "Electron", apiVersion: 4, launch: async (url, mode) => { + const config = path.join(directory, `${mode}.json`) + await writeFile(config, JSON.stringify({ url, seconds: 8, channels, webPreferences })) + const child = spawn(require("electron"), [entry, "--probe-config", config, `--user-data-dir=${path.join(directory, `profile-${mode}`)}`, ...sandboxArgs], + { env, stdio: ["ignore", "pipe", "pipe"] }) + let output = "" + child.stdout.on("data", chunk => { output += chunk }) + child.stderr.on("data", chunk => { output += chunk }) + const timeout = setTimeout(() => child.kill(), 60_000) + const status = await new Promise((resolve, reject) => { child.on("error", reject); child.on("exit", resolve) }).finally(() => clearTimeout(timeout)) + assert.equal(status, 0, output) + return output + } }) + console.log(JSON.stringify({ webPreferences: { ...webPreferences, preload: path.relative(root, preload) }, ...summary }, null, 2)) +} finally { + await rm(directory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }) +} diff --git a/scripts/test-panel-extension-native.mjs b/scripts/test-panel-extension-native.mjs index ff3e8831..c93f3890 100644 --- a/scripts/test-panel-extension-native.mjs +++ b/scripts/test-panel-extension-native.mjs @@ -1,14 +1,14 @@ // No CodeNomad backend/profile, shared daemon or desktop instance is involved. +// Hosts the frame probe in the isolated Tauri fixture: WebView2 on Windows, +// WKWebView on macOS, WebKitGTK on Linux. import assert from "node:assert/strict" -import { createServer } from "node:http" import { spawn, spawnSync } from "node:child_process" import { mkdtemp, rm } from "node:fs/promises" import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" -import { tsImport } from "tsx/esm/api" +import { runProbe } from "./panel-extension-native-probe.mjs" -assert.equal(process.platform, "win32", "Run the WebView2 regression on Windows") const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const workspace = path.join(root, "packages/tauri-app") const target = path.resolve(process.env.CARGO_TARGET_DIR || path.join(workspace, "target")) @@ -16,72 +16,28 @@ const env = { ...process.env, CARGO_TARGET_DIR: target } delete env.NODE_OPTIONS delete env.CODENOMAD_NATIVE_PARENT const build = spawnSync("cargo", ["build", "-p", "codenomad-panel-extension-fixture"], { - cwd: workspace, env, encoding: "utf8", timeout: 300_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true, + cwd: workspace, env, encoding: "utf8", timeout: 1_800_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true, }) assert.equal(build.error, undefined, String(build.error)) assert.equal(build.status, 0, build.stdout + build.stderr) -const { panelExtensionDocument } = await tsImport(new URL("../packages/ui/src/components/panel-extensions/frame-document.ts", import.meta.url).href, import.meta.url) -const apiVersion = process.argv.includes("--api3") ? 3 : process.argv.includes("--api2") ? 2 : 1 -const document = panelExtensionDocument(`

External panel

`, "fixture-handshake", apiVersion) -const reports = [] -const server = createServer((request, response) => { - if (request.url === "/report") { - let body = "" - request.on("data", chunk => { body += chunk }) - request.on("end", () => { reports.push(JSON.parse(body)); response.end("ok") }) - return - } - response.setHeader("Content-Type", "text/html") - response.end(``) -}) -const profile = await mkdtemp(path.join(process.env.TEMP || os.tmpdir(), "opencode-panel-native-")) -try { - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) - const child = spawn(path.join(target, "debug/codenomad-panel-extension-fixture.exe"), [ - `http://127.0.0.1:${server.address().port}`, profile, - ], { cwd: workspace, env, stdio: ["ignore", "pipe", "pipe"] }) - let output = "" - child.stdout.on("data", chunk => { output += chunk }) - child.stderr.on("data", chunk => { output += chunk }) - const status = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", resolve) }) - assert.equal(status, 0, output) - assert.match(output, /NATIVE:parent/, "Positive native control must succeed") - assert.doesNotMatch(output, /NATIVE:(?:child|raw-child|message-child)/, "Author code must not reach native commands") - assert.equal(reports.length, 1, JSON.stringify(reports)) - assert.equal(reports[0].parent, "blocked") - assert.equal(reports[0].network, "blocked") - assert.equal(reports[0].context, "native-session") - if (apiVersion === 2) assert.equal(reports[0].asset, "Native asset") - if (apiVersion === 3) assert.equal(reports[0].workItem, "Native work item") - console.log(JSON.stringify({ nativeParent: "allowed", nativeChild: "blocked", ...reports[0] }, null, 2)) -} finally { - await new Promise(resolve => server.close(resolve)) - // WebView2 releases its child-process lock shortly after the host exits. - await rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }) -} +} }) +console.log(JSON.stringify(summary, null, 2))