test(design): qualify the Design companion end to end and the panel sandbox on native hosts (#1208)

Slice 6 (qa-native) of the Design image companion (SCR v1.3.1; AC-16,
AC-41, AC-52, AC-54). Test code only; no product source changes.

- `scripts/test-design-companion-native.mjs`: isolated OpenCode daemon,
scripted chat model, fake image model; AC-16 and native AC-52 (re-read
35 assistant messages later). Added to the PR build on
Windows/Ubuntu/macOS.
- `--ui <Design ZIP>` → `scripts/test-design-companion-ui.mjs` +
`fixtures/design-companion.tsx`: real UI S8 path with the installed
extension and real-transcript Markdown rendering (AC-52, AC-54).
- `scripts/panel-extension-native-probe.mjs`,
`test-panel-extension-native.mjs --api4` (also off Windows), new
`test-panel-extension-electron.mjs`: sandbox invariants with negative
controls (AC-41).
- `fixture-exemptions.ts`: registers the Images conversation and API 4
browser tests required by `fixture-wiring`.

Runs: Windows (OpenCode 2.2.1 native + UI path; WebView2 and Electron
runners), Ubuntu VM (WebKitGTK and Electron runners). **Not run:** macOS
WKWebView — the VM stops at its login screen.
This commit is contained in:
Pascal André 2026-10-30 12:07:46 +01:00 • committed by GitHub
parent 4124b60b21
commit f39642bc4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1156 additions and 111 deletions

View file

@ -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

View file

@ -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

View file

@ -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 <Design ZIP>` 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

View file

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

View file

@ -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"] }
}

View file

@ -35,6 +35,7 @@ export const exemptions: Record<string, string> = {
"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<string, string> = {
"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",

View file

@ -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<void>(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<Record<string, PromptInputApi | null>>({})
const active = () => activeSessionId().get(instanceId) ?? null
const promptApi = () => { const id = active(); return id ? apis()[id] ?? null : null }
function Fixture() {
const { t } = useI18n()
return <div style={{ display: "flex", height: "100dvh", width: "100vw" }}>
<div style={{ flex: "1", "min-width": "0", display: "flex" }}>
<Show when={active()} keyed>{id =>
<SessionView sessionId={id} activeSessions={sessions().get(instanceId)!} instanceId={instanceId} instanceFolder={directory}
escapeInDebounce={false} isActive={true} onAgentChange={agentId => updateSessionAgent(instanceId, id, agentId)} onModelChange={async () => {}}
registerSessionPromptApi={(id, api) => setApis(current => ({ ...current, [id]: api }))} />
}</Show>
</div>
<div class="session-shell-panels" style={{ width: "460px", position: "relative" }}>
<RightPanel instanceId={instanceId} instance={{ id: instanceId, folder: directory, status: "ready", client: null, port: 0, pid: 0, proxyPath } as any}
isActive={() => true} t={t} activeSessionId={active} activeSession={() => null} isPhoneLayout={() => false}
rightDrawerWidth={() => 460} rightDrawerWidthInitialized={() => true} onCloseRightDrawer={() => {}} promptInputApi={promptApi} setContentEl={() => {}} />
</div>
</div>
}
;(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(() => <ConfigProvider><I18nProvider><ThemeProvider><Fixture /></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)

View file

@ -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 = []

View file

@ -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 `<h1>Native probe</h1><script>
const results = { origin: self.origin, parentDocument: 'unknown', native: {}, globals: {}, leaks: {}, api: {}, surface: null, context: null, changed: null, errors: [] };
try { void parent.document.title; results.parentDocument = 'reachable'; } catch { results.parentDocument = 'blocked'; }
for (const [name, read] of Object.entries({
__TAURI_INTERNALS__: () => window.__TAURI_INTERNALS__, __TAURI__: () => window.__TAURI__, electronAPI: () => window.electronAPI,
require: () => window.require, process: () => window.process, ipc: () => window.ipc, ipcRenderer: () => window.ipcRenderer,
chromeWebview: () => window.chrome && window.chrome.webview, webkitHandlers: () => window.webkit && window.webkit.messageHandlers,
})) { try { results.globals[name] = typeof read(); } catch { results.globals[name] = 'threw'; } }
const attempt = (name, run) => {
try { const value = run(); results.native[name] = 'called';
if (value && typeof value.then === 'function') value.then(() => { results.native[name] = 'resolved'; }, () => { results.native[name] = 'rejected'; });
} catch { results.native[name] = 'threw'; }
};
const ipcMessage = role => JSON.stringify({ cmd: 'probe', callback: 1, error: 2, payload: { role } });
attempt('tauri.invoke', () => __TAURI_INTERNALS__.invoke('probe', { role: 'child' }));
attempt('tauri.ipc', () => __TAURI_INTERNALS__.ipc({ cmd: 'probe', callback: 1, error: 2, payload: { role: 'raw-child' } }));
attempt('tauri.global', () => __TAURI__.core.invoke('probe', { role: 'global-child' }));
attempt('tauri.parent', () => parent.__TAURI_INTERNALS__.invoke('probe', { role: 'parent-reach-child' }));
attempt('tauri.top', () => top.__TAURI_INTERNALS__.invoke('probe', { role: 'top-reach-child' }));
attempt('tauri.protocol', () => fetch('http://ipc.localhost/probe', { method: 'POST', body: '{"role":"protocol-child"}' }));
attempt('tauri.ipcScheme', () => fetch('ipc://localhost/probe', { method: 'POST', body: '{"role":"scheme-child"}' }));
attempt('webview2.postMessage', () => chrome.webview.postMessage(ipcMessage('webview-child')));
attempt('webkit.postMessage', () => webkit.messageHandlers.ipc.postMessage(ipcMessage('webkit-child')));
attempt('wry.ipc', () => window.ipc.postMessage(ipcMessage('wry-child')));
attempt('electron.self', () => electronAPI.restartCli());
attempt('electron.parent', () => parent.electronAPI.restartCli());
attempt('electron.top', () => top.electronAPI.restartCli());
attempt('electron.require', () => require('electron').ipcRenderer.invoke('cli:restart'));
attempt('electron.process', () => process.electronBinding('ipc'));
attempt('message', () => parent.postMessage({ cmd: 'probe', role: 'message-child' }, '*'));
const leakBase = ${JSON.stringify(leakBase)};
const add = (tag, attributes, where = document.body) => { const node = document.createElement(tag);
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value); where.append(node); return node; };
const style = css => { const node = document.createElement('style'); node.textContent = css; document.head.append(node); };
const leak = (kind, run) => { try { run(leakBase + kind); results.leaks[kind] = 'attempted'; } catch { results.leaks[kind] = 'threw'; } };
leak('fetch', url => fetch(url).catch(() => {}));
leak('fetch-no-cors', url => fetch(url, { mode: 'no-cors' }).catch(() => {}));
leak('fetch-relative', () => fetch('/api/forbidden').catch(() => {}));
leak('xhr', url => { const request = new XMLHttpRequest(); request.open('GET', url); request.send(); });
leak('img', url => add('img', { src: url }));
leak('img-srcset', url => add('img', { srcset: url + ' 1x' }));
leak('script', url => add('script', { src: url }));
leak('stylesheet', url => add('link', { rel: 'stylesheet', href: url }, document.head));
leak('prefetch', url => add('link', { rel: 'prefetch', href: url }, document.head));
leak('preload', url => add('link', { rel: 'preload', as: 'image', href: url }, document.head));
leak('css-background', url => add('div', { style: 'width:8px;height:8px;background:url("' + url + '")' }));
leak('css-import', url => style('@import url("' + url + '");'));
leak('css-font', url => { style('@font-face{font-family:probe;src:url("' + url + '")}'); add('span', { style: 'font-family:probe' }).textContent = 'x'; });
leak('iframe', url => add('iframe', { src: url }));
leak('object', url => add('object', { data: url }));
leak('embed', url => add('embed', { src: url }));
leak('form', url => { add('iframe', { name: 'leak-target' }); add('form', { action: url, method: 'POST', target: 'leak-target' }).submit(); });
leak('beacon', url => navigator.sendBeacon(url, 'probe'));
leak('websocket', url => new WebSocket(url.replace(/^http:/, 'ws:')));
leak('eventsource', url => new EventSource(url));
leak('audio', url => add('audio', { src: url, preload: 'auto' }));
leak('video', url => add('video', { src: url, preload: 'auto' }));
leak('video-poster', url => add('video', { poster: url }));
leak('svg-image', url => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image'); image.setAttribute('href', url); svg.append(image); document.body.append(svg); });
leak('worker-blob', url => new Worker(URL.createObjectURL(new Blob(['fetch(' + JSON.stringify(url) + ').catch(() => {})'], { type: 'text/javascript' }))));
leak('worker-data', url => new Worker('data:text/javascript,' + encodeURIComponent('fetch(' + JSON.stringify(url) + ').catch(() => {})')));
leak('popup', url => window.open(url));
const fired = Date.now();
if (${apiVersion} === 2) codenomad.assets.onChanged(() => { results.changed = 'called'; });
if (${apiVersion} === 4 && codenomad.images) codenomad.images.onChanged(detail => { results.changed = detail; });
const methods = ${JSON.stringify(methods)}, args = ${JSON.stringify(ARGS)};
let started = false;
codenomad.onContext(async context => {
if (started) return; started = true;
results.context = context.sessionId;
results.surface = Object.fromEntries(Object.keys(codenomad).map(key => [key, codenomad[key] && typeof codenomad[key] === 'object' ? Object.keys(codenomad[key]) : typeof codenomad[key]]));
for (const [object, names] of Object.entries(methods)) for (const method of names) {
const key = object + '.' + method;
try { results.api[key] = await codenomad[object][method](...(args[key] || [])); } catch (error) { results.errors.push(key + ': ' + error.message); }
}
setTimeout(() => parent.postMessage({ type: 'results', results }, '*'), Math.max(0, ${GRACE_MS} - (Date.now() - fired)));
});
</script>`
}
// Host page: positive native control, one-use port handshake, one answer per request.
function hostPage({ mode, srcdoc, sandbox, apiVersion }) {
const attribute = value => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")
return `<!doctype html><meta charset="utf-8"><title>Native probe host</title><script>
const answers = [], unexpected = [];
let positive = 'none';
window.addEventListener('DOMContentLoaded', () => {
if (window.__TAURI_INTERNALS__) { positive = 'tauri'; __TAURI_INTERNALS__.invoke('probe', { role: 'parent' }); }
else if (window.electronAPI) { positive = 'electron'; window.electronAPI.getCliStatus(); }
});
window.addEventListener('message', event => {
const frame = document.querySelector('iframe');
if (event.source !== frame.contentWindow || event.data?.type !== 'results') return;
fetch('/report/${mode}', { method: 'POST', body: JSON.stringify({ child: event.data.results, answers, unexpected, positive, hostOrigin: location.origin }) });
});
function initialize(frame) {
const channel = new MessageChannel();
channel.port1.onmessage = event => {
const data = event.data, request = /^(assets|workItems|api4):request$/.exec(data?.type);
if (request) {
const capability = request[1] === 'api4' ? data.capability : request[1];
answers.push({ type: request[1], capability, method: data.method, input: data.input });
channel.port1.postMessage({ type: request[1] + ':result', id: data.id, result: { probe: capability + '.' + data.method } });
} else if (data?.type === 'ready' && data.handshake === ${JSON.stringify(HANDSHAKE)}) {
channel.port1.postMessage({ type: 'context', context: { apiVersion: ${apiVersion}, sessionId: 'native-session', locale: 'en', appearance: 'light' } });
if (${apiVersion} === 2) channel.port1.postMessage({ type: 'assets:changed' });
if (${apiVersion} === 4) channel.port1.postMessage({ type: 'images:changed', gallery: true, status: false });
} else unexpected.push(data?.type ?? typeof data);
};
frame.contentWindow.postMessage({ type: 'codenomad:init' }, '*', [channel.port2]);
}
</script><iframe sandbox="${attribute(sandbox)}" onload="initialize(this)" srcdoc="${attribute(srcdoc)}"></iframe>`
}
/** Starts the loopback server serving `/host/<mode>`; 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 = `<meta http-equiv="Content-Security-Policy" content="${policy.PANEL_EXTENSION_CSP}">`
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()
}
}

View file

@ -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]()

View file

@ -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/<subject>/`, 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 <absolute opencode executable> [--ui <design ZIP>]
// `--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 <json>" 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/<subject>/")
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}`) }
}
}

View file

@ -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", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/design-companion.tsx"></script></body></html>'))
})
},
}], 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()
}
}

View file

@ -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 })
}

View file

@ -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(`<h1>External panel</h1><p id="session"></p><script>
const results = { parent: 'unknown', network: 'unknown', context: null, nativeGlobal: typeof window.__TAURI_INTERNALS__ };
try { parent.document; results.parent = 'allowed'; } catch { results.parent = 'blocked'; }
codenomad.onContext(context => { results.context = context.sessionId; document.querySelector('#session').textContent = context.sessionId; });
if (codenomad.assets) codenomad.onContext(async () => { results.asset = (await codenomad.assets.list()).entries[0].name; });
if (codenomad.workItems) codenomad.onContext(async () => { results.workItem = (await codenomad.workItems.list({ kind: 'issue' })).items[0].title; });
fetch('/api/forbidden').then(() => results.network = 'allowed').catch(() => results.network = 'blocked');
// On Windows Tauri globals may be injected. Their presence is not permission.
if (window.__TAURI_INTERNALS__) {
__TAURI_INTERNALS__.invoke('probe', { role: 'child' }).catch(() => {});
__TAURI_INTERNALS__.ipc({ cmd: 'probe', callback: 1, error: 2, payload: { role: 'raw-child' } });
const apiVersion = process.argv.includes("--api4") ? 4 : process.argv.includes("--api3") ? 3 : process.argv.includes("--api2") ? 2 : 1
const executable = path.join(target, "debug", `codenomad-panel-extension-fixture${process.platform === "win32" ? ".exe" : ""}`)
const engine = { win32: "WebView2", darwin: "WKWebView", linux: "WebKitGTK" }[process.platform] ?? process.platform
const summary = await runProbe({ host: `Tauri/${engine}`, apiVersion, launch: async url => {
const profile = await mkdtemp(path.join(os.tmpdir(), "opencode-panel-native-"))
try {
// One host per mode, alive long enough for the frame report and the leak grace period.
const child = spawn(executable, [url, profile, "10"], { cwd: workspace, 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("close", resolve) }).finally(() => clearTimeout(timeout))
assert.equal(status, 0, output)
return output
} finally {
// WebView2 releases its child-process lock shortly after the host exits.
await rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 })
}
parent.postMessage({ cmd: 'probe', role: 'message-child' }, '*');
setTimeout(() => parent.postMessage({ type: 'results', results }, '*'), 1500);
</script>`, "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(`<script>
window.addEventListener('message', e => { if (e.data?.type === 'results') fetch('/report', { method: 'POST', body: JSON.stringify(e.data.results) }); });
window.addEventListener('DOMContentLoaded', () => __TAURI_INTERNALS__.invoke('probe', { role: 'parent' }));
function initialize(frame) {
const channel = new MessageChannel();
channel.port1.onmessage = e => {
if (e.data?.handshake === 'fixture-handshake') channel.port1.postMessage({ type: 'context', context: { apiVersion: ${apiVersion}, sessionId: 'native-session', locale: 'en', appearance: 'light' } });
else if (e.data?.type === 'assets:request') channel.port1.postMessage({ type: 'assets:result', id: e.data.id, result: { entries: [{ name: 'Native asset' }], cursor: null } });
else if (e.data?.type === 'workItems:request') channel.port1.postMessage({ type: 'workItems:result', id: e.data.id, result: { status: 'ok', items: [{ title: 'Native work item' }] } });
};
frame.contentWindow.postMessage({ type: 'codenomad:init' }, '*', [channel.port2]);
}
</script><iframe sandbox="allow-scripts" onload="initialize(this)" srcdoc="${document.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")}"></iframe>`)
})
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))