From 3de649e190984fd6d458b8e9af30d2b76457fbc7 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:48:16 +0200 Subject: [PATCH 1/3] code: connect explicit native editor tasks to local core --- README.md | 23 +++-- docs/NATIVE_CODING_TRIAL.md | 17 ++++ docs/NATIVE_EDITOR.md | 107 +++++++++++++++++++ package.json | 6 +- scripts/editor_session.cjs | 115 +++++++++++++++++++++ scripts/editor_session.py | 137 +++++++++++++++++++++++++ src/editor-runtime.cjs | 99 ++++++++++++++++++ src/editor-task.cjs | 103 +++++++++++++++++++ src/extension.cjs | 84 +++++++++++++-- tests/editor-runtime.test.cjs | 187 ++++++++++++++++++++++++++++++++++ tests/editor-task.test.cjs | 103 +++++++++++++++++++ tests/extension.test.cjs | 96 ++++++++++++++++- 12 files changed, 1060 insertions(+), 17 deletions(-) create mode 100644 docs/NATIVE_EDITOR.md create mode 100644 scripts/editor_session.cjs create mode 100644 scripts/editor_session.py create mode 100644 src/editor-runtime.cjs create mode 100644 src/editor-task.cjs create mode 100644 tests/editor-runtime.test.cjs create mode 100644 tests/editor-task.test.cjs diff --git a/README.md b/README.md index d67814e..36f2dd7 100644 --- a/README.md +++ b/README.md @@ -26,25 +26,32 @@ automatically public training or cache material. ## First executable slice -The extension implements two explicit commands: +The extension implements explicit commands: - **VOLPAROSSA: Ask About Selected Code (Private, Local)** sends only a confirmed question and selection to an existing same-owner `compute private-serve` socket. Responses appear as untrusted plaintext; no changes are applied automatically. - **VOLPAROSSA: Show Compute Capabilities** queries that service without sending code or claiming that a model has successfully executed. +- **VOLPAROSSA: Run Native Coding Task (Private, Local)** explicitly launches a + prepared, source-verified open Codex runtime in an isolated Linux workspace and + connects it to the existing local VOLPAROSSA conversation service. The native + agent can read, change and check that selected project, with one-shot command + approvals and cancellation. See [setup and current proof limits](docs/NATIVE_EDITOR.md). -The current core interface permits **512 UTF-8 bytes for the question and 4096 +The selected-code advice interface permits **512 UTF-8 bytes for the question and 4096 for the selection**, subject to the selected model's smaller token budget. Over-limit inputs fail instead of being silently shortened. Partial model output remains labeled partial. Cancellation is forwarded; uncertain cleanup is not reported as success. There is no public-peer or OpenAI fallback. -These first commands use the core directly. They are **not yet routed through -Codex**. The separate app-server client implements the pinned NDJSON handshake, +The first two commands use the core directly, not through Codex. The new native +coding command uses the app-server, but is **not yet proved in a native editor +with real model-driven editing**. The app-server client implements the pinned NDJSON handshake, thread/turn requests, notifications and interruption, and declines tool approvals by default. An explicit caller can supply a narrowly scoped per-command approval -policy; the normal extension does not enable it. Its focused protocol tests are +policy; only the explicit native coding command enables an interactive one-shot +policy for the selected project. Its focused protocol tests are now complemented by a **real, source-built app-server lifecycle trial**: initialization, an ephemeral VOLPAROSSA-provider thread, exact unsubscribe and clean shutdown pass in disposable namespaces without OpenAI credentials or @@ -97,9 +104,9 @@ npm run check - Prove the new conversation/provider interface with an actual model and the native Codex Responses/tool loop; the bounded Q&A endpoint stays separate. -- Connect the built runtime to the extension and core provider, retaining its - isolated configuration and upstream notices; never use the owner's OpenAI login - or cloud fallback. +- Prove the new explicit runtime/extension/provider connection in a native editor, + retaining its isolated configuration and upstream notices; never use the + owner's OpenAI login or cloud fallback. - Complete native conversation/tool interoperability, reviewable diffs and local approvals, then prove an actual edit-and-test coding task end to end. - Delegate eligible work through the core's cooperative scheduler, with explicit diff --git a/docs/NATIVE_CODING_TRIAL.md b/docs/NATIVE_CODING_TRIAL.md index b74a62d..62ac57a 100644 --- a/docs/NATIVE_CODING_TRIAL.md +++ b/docs/NATIVE_CODING_TRIAL.md @@ -157,6 +157,23 @@ arguments, paths or identifiers. Historical version-1/2 receipts remain readable The bounded continuation and these diagnostics have offline controller/protocol coverage, not a newly successful model-driven read/edit/test proof. +The actual [run 36932657647](https://github.com/VOLPAROSSA/volparossa/actions/runs/36932657647) +on core `c3fb587f6cdcdc9fd1e0a1dd31a9a0bb6001706c` / Code `2f7014b0` +now reaches that continuation: one read is executed, the first native turn ends, +then another real tool proposal is refused by the fixture's exact command policy. +One command is accepted, one declined in category `command`; edit and test remain +false. All three model responses are complete and cleanup-confirmed (function +call, assistant, function call). The rejected command text is not retained, so +its intended action and correctness are unknown. Runtime exits normally and +private/service cleanup and unchanged host-state checks pass. This is a failed +read/edit/test proof, not a successful coding task or a reason to loosen that +fixture's existing success criteria. + +The separate [native editor integration](NATIVE_EDITOR.md) uses the user's actual +chosen workspace and interactive command approvals, rather than the arithmetic +fixture's special command list. Its frontend/launcher implementation and narrow +checks do not supersede this failed model-driven evidence. + Success requires the actual app-server's command-completion events, changed file hash, independent passing tests, at least four cleanup-confirmed real core responses, exact thread unsubscribe and graceful runtime exit. Partial responses, diff --git a/docs/NATIVE_EDITOR.md b/docs/NATIVE_EDITOR.md new file mode 100644 index 0000000..0ead63f --- /dev/null +++ b/docs/NATIVE_EDITOR.md @@ -0,0 +1,107 @@ +# Native coding in the editor + +The explicit **VOLPAROSSA: Run Native Coding Task (Private, Local)** command +connects the editor to the source-built open Codex app-server and the existing +VOLPAROSSA conversation service. The runtime may read and edit the chosen project +and execute approved tools. The extension does not contain a model or a second +peer scheduler, and does not supply a predefined repair or fabricate tool output. + +This is a Linux development integration. Controller and launcher checks are not +proof of reliable model-driven coding or a native VS Code/VSCodium end-to-end +trial. The original selected-code advice commands remain available separately. + +## Explicit setup + +Prepare the [pinned runtime](RUNTIME_BUILD.md) and an existing same-owner private +VOLPAROSSA conversation service using the `qwen3-0.6b-v1` profile. Nothing is +downloaded or installed by this command. In **user settings**, configure: + +```json +{ + "volparossaCode.privateSocket": "/absolute/private-directory/compute.sock", + "volparossaCode.nativeRuntime": { + "version": 1, + "appServer": "/absolute/runtime-bundle/runtime/codex-app-server", + "appServerSha256": "", + "buildReport": "/absolute/runtime-bundle/BUILD_REPORT.json", + "node": "/absolute/prepared-node/bin/node", + "nodeSha256": "", + "upstreamPrompt": "/absolute/pinned-source/codex-rs/models-manager/prompt.md" + } +} +``` + +Use canonical absolute paths and lowercase 64-character SHA-256 values, not the +placeholders above. The launcher verifies the exact upstream revision, source +tree, lockfile, declared patch, retained license/notice, executable hash and full +native prompt. A workspace setting cannot replace these machine-scoped inputs. +Runtime inputs must be owned by the current user or root and must not be writable +by group or others. Use a dedicated prepared bundle (executables `0700` or `0555`, +report and prompt `0400` or `0444`), rather than relaxing checks for a group-writable +source checkout. Keep the original pinned source and its notices unchanged. +The core socket must belong to the current user with mode `0600` in an owned +`0700` directory. Existing runtime/model installation remains the operator's +explicit action. System Python 3 and bubblewrap must already be available. + +Open a trusted local project and invoke the command. In a multi-folder workspace, +choose one folder; the other folders are not implicitly included. Enter the task +and confirm the selected read/write scope. Opening the extension or workspace +alone starts no runtime, model or network participation. + +## Execution and privacy boundaries + +The selected project is mounted as `/workspace` inside a disposable Linux +sandbox. The sandbox has a separate network/PID/mount namespace, no host user +home or inherited credentials, and only the prepared runtimes, system runtime +files, exact private core socket and selected project. It does not change host +DNS, routes or firewall. Broad system directories and overlap with launcher +inputs are refused as projects. The core processes private input locally; no +public cache, training, remote peer or OpenAI fallback is enabled by this slice. + +The native runtime's workspace sandbox and approval policy remain in force. +When it requests command approval, the editor shows the exact command and its +relative working directory. **Run once** grants only that request, not a session, +future rule, network access or wider filesystem permissions. Unsupported tool +approval kinds and privilege/network expansion are refused. Workspace content +and model output do not grant permissions. + +The agent can modify real files in the selected folder. Changes are **not** rolled +back on cancellation or failure; inspect Source Control and run the relevant +project checks. Prefer a dedicated working branch. The frontend does not label +a completed native turn as a verified completed task. Generated output is shown +as plaintext rather than executable HTML or Markdown. + +Cancellation is forwarded to the exact thread and turn, including cancellation +during turn admission. Closing the extension also closes its owned session. +The launcher waits for provider/runtime shutdown; forced stops or uncertain +cleanup remain errors, not successful completion. Runtime stderr, bearer secrets +and raw prompts are not exported as diagnostics. The frontend does not retain +a durable conversation; the final untitled text can be saved only by the user. + +## Verification scope and remaining work + +Focused tests cover actual frontend/controller logic with synthetic protocol +events: explicit launch, user-only settings, scope selection, one-shot approval, +early native notifications, cancellation, EOF, cleanup failure and no automatic +startup. Launcher tests separately exercise process and namespace construction. +They do not stand in for the outstanding native editor/model trial. + +A separate local protocol probe has passed through the production launcher and +the actual source-built app-server: initialize, open an ephemeral VOLPAROSSA +thread, unsubscribe, EOF and confirmed runtime/provider shutdown. Its core socket +was **synthetic and capability-only**: no turn, inference or tool execution was +requested, and VS Code/VSCodium itself was not launched. The temporary selected +project and read-only runtime staging were removed; original runtime bytes/modes +and host network state remained unchanged. Earlier attempts stopped before the +protocol handshake: first on group-writable source inputs, then because the +launcher rejected bubblewrap's own `PWD=/workspace`. Dedicated private staging +and an exact namespace-local PWD check resolved those launch blockers without +relaxing input ownership or importing host environment settings. + +The current prepared model is small; usable general coding quality is still to +be measured. Reviewable native diffs, durable multi-turn sessions, additional +tool types, broader platform support and eligible cooperative delegation remain +separate unfinished work. The core must own delegation and its privacy decision; +this local command must not silently publish a private project to peers. + +Protocol reference: [official Codex app-server documentation](https://learn.chatgpt.com/docs/app-server). diff --git a/package.json b/package.json index 9e066ca..7c76828 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "contributes": { "commands": [ {"command": "volparossaCode.reviewSelection", "title": "VOLPAROSSA: Ask About Selected Code (Private, Local)"}, + {"command": "volparossaCode.codingTask", "title": "VOLPAROSSA: Run Native Coding Task (Private, Local)"}, {"command": "volparossaCode.capabilities", "title": "VOLPAROSSA: Show Compute Capabilities"} ], "configuration": { @@ -25,10 +26,13 @@ "volparossaCode.privateSocket": { "type": "string", "default": "", "scope": "machine", "description": "Absolute same-owner Unix socket of an explicitly started VOLPAROSSA private-serve service. No service is started or downloaded by the extension." + }, + "volparossaCode.nativeRuntime": { + "type": "object", "default": {}, "scope": "machine", + "description": "Explicit prepared native coding runtime inputs; see docs/NATIVE_EDITOR.md. User settings only. No runtime or model is downloaded, and opening a workspace starts nothing." } } } }, "scripts": {"test": "node --test tests/*.test.cjs", "check": "node --check src/extension.cjs && node --check src/app-server.cjs && node --check src/private-compute.cjs"} } - diff --git a/scripts/editor_session.cjs b/scripts/editor_session.cjs new file mode 100644 index 0000000..8a49c06 --- /dev/null +++ b/scripts/editor_session.cjs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Native NDJSON transport owner. No prompts, task controller or tool approval here. +'use strict'; +const fs = require('node:fs'); +const {spawn} = require('node:child_process'); +const {PrivateConversation} = require('../src/private-conversation.cjs'); +const {startResponsesProvider} = require('../src/responses-provider.cjs'); +const {MODEL, modelCatalog, runtimeSettings} = require('../src/native-coding-fixture.cjs'); + +async function preflight() { + const client = new PrivateConversation('/opt/core/compute.sock'); + try { + const caps = await client.connect(); + if (caps.model_profile !== MODEL || !caps.native_tool_template || !caps.local_only || caps.quarantined) { + throw Error('native_editor_capabilities'); + } + } finally { client.close(); } +} + +const defaults = { + preflight, + catalog() { + const instructions = fs.readFileSync('/opt/upstream-prompt.md', 'utf8'); + fs.writeFileSync('/opt/catalog.json', JSON.stringify(modelCatalog(instructions)), {flag: 'wx', mode: 0o400}); + }, + provider: () => startResponsesProvider({socketPath: '/opt/core/compute.sock', model: MODEL}), + spawn: (binary, args, options) => spawn(binary, args, options), +}; + +// Dependency seam is only for synthetic stream/process tests. CLI has no overrides. +async function runSession({input, output, ready, events = process}, hooks = defaults) { + let provider, child, exited, stopped = false, bad = false, exit = null, closing = null; + let wake; + const stopRequested = new Promise(resolve => { wake = resolve; }); + const stop = () => { stopped = true; wake(); }; + const failed = () => { bad = true; stop(); }; + for (const name of ['end', 'close']) input.once(name, stop); + input.once('error', failed); output.once('error', failed); output.once('close', stop); + for (const name of ['SIGTERM', 'SIGINT', 'SIGHUP']) events.once(name, failed); + // Notice EOF even before the app-server is spawned; do not retain early bytes. + input.pause(); + async function close() { + closing ??= (async () => { + input.unpipe(child?.stdin); input.pause(); + child?.stdin.end(); + if (provider) { + try { + await provider.close(); + // Current adapter does not expose successful cancel receipts separately. + // Never call an interrupted/unconfirmed request verified cleanup. + const seen = provider.observations; + if (seen.submitted !== seen.cleanup_confirmed) bad = true; + } catch { bad = true; } + } + if (child) { + let timer, hard; + try { + exit = await Promise.race([exited, new Promise(resolve => { + timer = setTimeout(() => resolve(null), 5000); + })]); + if (!exit) { + bad = true; child.kill('SIGTERM'); + hard = setTimeout(() => child.kill('SIGKILL'), 5000); + exit = await exited; + } + if (exit.code !== 0 || exit.signal) bad = true; + } finally { clearTimeout(timer); clearTimeout(hard); } + } + })(); + return closing; + } + try { + await hooks.preflight(); + if (stopped || input.destroyed || input.readableEnded) throw Error('editor_input_closed'); + hooks.catalog(); + provider = await hooks.provider(); + if (stopped || input.destroyed || input.readableEnded) throw Error('editor_input_closed'); + child = hooks.spawn('/opt/codex-app-server', ['--listen', 'stdio://', '--strict-config', + ...runtimeSettings(provider.baseUrl).flatMap(value => ['-c', value])], { + cwd: '/workspace', stdio: ['pipe', 'pipe', 'pipe'], + env: {PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', VOLPAROSSA_PROVIDER_TOKEN: provider.bearerToken}, + }); + exited = new Promise(resolve => { + child.once('error', () => { failed(); resolve({code: null, signal: 'spawn_failed'}); }); + child.once('close', (code, signal) => { stop(); resolve({code, signal}); }); + }); + child.stdin.on('error', failed); child.stdout.on('error', failed); + let stderrBytes = 0; + child.stderr.on('data', data => { stderrBytes += data.length; if (stderrBytes > 1048576) failed(); }); + child.stderr.on('error', failed); // Discard raw stderr, including paths and secrets. + await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); + if (stopped) throw Error('editor_input_closed'); + child.stdout.pipe(output, {end: false}); + input.pipe(child.stdin); + ready(); + await stopRequested; + } catch { bad = true; } + finally { + await close(); + for (const name of ['end', 'close']) input.removeListener(name, stop); + input.removeListener('error', failed); output.removeListener('error', failed); output.removeListener('close', stop); + for (const name of ['SIGTERM', 'SIGINT', 'SIGHUP']) events.removeListener(name, failed); + } + return bad ? 1 : 0; +} + +async function main() { + if (process.platform !== 'linux' || process.getuid() === 0 || process.cwd() !== '/workspace' || + Object.keys(process.env).some(key => !['PATH', 'LANG'].includes(key))) return 1; + return runSession({input: process.stdin, output: process.stdout, ready() { + fs.writeSync(2, '{"native_editor_ready":true}\n'); + }}); +} +if (require.main === module) main().then(code => { process.exitCode = code; }, () => { process.exitCode = 1; }); +module.exports = {runSession}; diff --git a/scripts/editor_session.py b/scripts/editor_session.py new file mode 100644 index 0000000..f18a358 --- /dev/null +++ b/scripts/editor_session.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Explicit owner-selected editor session. No downloads, models or core startup.""" +import json +import os +from pathlib import Path +import pwd +import socket +import stat +import sys + +from smoke_native_coding import (PROMPT_SHA256, private_socket, require, + verified_build, verified_file) + +ROOT = Path(__file__).resolve().parents[1] +FIELDS = {'version', 'appServer', 'appServerSha256', 'buildReport', 'node', + 'nodeSha256', 'upstreamPrompt', 'socketPath'} +SOURCES = ('private-compute.cjs', 'private-conversation.cjs', + 'responses-provider.cjs', 'native-coding-fixture.cjs') + + +def owned(path): + info = path.lstat() + require(info.st_uid in (0, os.getuid()) and not info.st_mode & 0o6022, + 'input-ownership') + return path + + +def selected_workspace(value, inputs, home, protected=()): + path = Path(value) + require(path.is_absolute() and path.resolve(strict=True) == path, + 'canonical-workspace-required') + info = path.lstat() + broad = {Path(name) for name in ('/', '/home', '/root', '/tmp', '/var', '/var/tmp', + '/usr', '/etc', '/run', '/media', '/mnt', '/opt')} + broad.update((Path(home), *Path(home).parents)) + require(stat.S_ISDIR(info.st_mode) and info.st_uid == os.getuid() + and not info.st_mode & 0o022 and path not in broad and not path.is_mount() + and os.access(path, os.R_OK | os.W_OK | os.X_OK), 'selected-project-required') + # Never expose the launcher/runtime/core authority as writable project data. + require(all(not item.is_relative_to(path) for item in inputs) + and all(not path.is_relative_to(item) for item in protected), 'project-input-overlap') + return path + + +def validate(config, workspace): + require(os.getuid() != 0, 'root-refused') + require(type(config) is dict and set(config) == FIELDS and type(config['version']) is int + and config['version'] == 1, 'configuration-schema') + require(all(type(config[name]) is str and 0 < len(config[name]) <= 4096 + and '\0' not in config[name] for name in FIELDS - {'version'}), 'configuration-fields') + binary = owned(verified_file(config['appServer'], config['appServerSha256'], executable=True)) + report = owned(Path(config['buildReport'])) + verified_build(str(report), binary, config['appServerSha256']) + node = owned(verified_file(config['node'], config['nodeSha256'], executable=True)) + prompt = owned(verified_file(config['upstreamPrompt'], PROMPT_SHA256)) + require(prompt.stat().st_size == 20903, 'native-prompt-size') + ipc = private_socket(config['socketPath']) + home = pwd.getpwuid(os.getuid()).pw_dir + require(Path(home).parent == Path('/home') and Path(home).name not in ('', '.', '..'), 'account-home') + project = selected_workspace(workspace, (binary, report, node, prompt, ipc, ROOT), home, + protected=(ROOT, report.parent)) + return binary, node, ipc, prompt, project, home + + +def command(binary, node, ipc, prompt, project, home): + # New mount/net/PID namespaces, only system runtimes and exact owned inputs. + # The owner's actual home contents and environmental credentials never enter. + result = ['/usr/bin/bwrap', '--die-with-parent', '--new-session', '--unshare-user', + '--uid', str(os.getuid()), '--gid', str(os.getgid()), '--unshare-net', '--unshare-pid', + '--unshare-ipc', '--unshare-uts', '--cap-drop', 'ALL', + '--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', + '--symlink', 'usr/sbin', '/sbin', '--symlink', 'usr/lib', '/lib', + '--symlink', 'usr/lib64', '/lib64', '--dir', '/etc', + '--ro-bind', '/etc/passwd', '/etc/passwd', '--ro-bind', '/etc/group', '/etc/group', + '--ro-bind', '/etc/ld.so.cache', '/etc/ld.so.cache', '--tmpfs', '/tmp', + '--dir', '/run', '--tmpfs', '/opt', '--dir', '/opt/src', '--dir', '/opt/scripts', + '--dir', home, '--perms', '0700', '--dir', '/opt/core', + '--proc', '/proc', '--dev', '/dev', + '--ro-bind', str(binary), '/opt/codex-app-server', '--ro-bind', str(node), '/opt/node', + '--ro-bind', str(prompt), '/opt/upstream-prompt.md', + '--ro-bind', str(ipc), '/opt/core/compute.sock', '--bind', str(project), '/workspace'] + for name in SOURCES: + result += ['--ro-bind', str(ROOT / 'src' / name), '/opt/src/' + name] + for name in ('editor_session.py', 'editor_session.cjs', 'smoke_native_coding.py'): + result += ['--ro-bind', str(ROOT / 'scripts' / name), '/opt/scripts/' + name] + return result + ['--chdir', '/workspace', '--clearenv', '--setenv', 'PATH', '/usr/bin:/bin', + '--setenv', 'LANG', 'C.UTF-8', '--', '/usr/bin/python3', '-B', + '/opt/scripts/editor_session.py', '--inside', os.readlink('/proc/self/ns/net')] + + +def clean_environment(environment): + # bwrap itself sets PWD for --chdir after --clearenv. It is not inherited + # owner configuration; accept only the selected namespace-local directory. + require(set(environment) <= {'PATH', 'LANG', 'LC_CTYPE', 'PWD'} + and environment.get('PWD', '/workspace') == '/workspace', 'clean-environment') + + +def inside(parent): + require(os.geteuid() != 0 and os.readlink('/proc/self/ns/net') != parent, 'isolation') + require({name for _, name in socket.if_nameindex()} <= {'lo'}, 'network-isolation') + caps = next(line.split()[1] for line in Path('/proc/self/status').read_text().splitlines() + if line.startswith('CapEff:')) + require(int(caps, 16) == 0, 'capability-isolation') + clean_environment(os.environ) + home = Path(pwd.getpwuid(os.getuid()).pw_dir) + require(home.is_dir() and not list(home.iterdir()), 'empty-isolated-home') + private_socket('/opt/core/compute.sock') + os.execve('/opt/node', ['/opt/node', '/opt/scripts/editor_session.cjs'], + {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8'}) + + +def no_duplicates(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'duplicate-configuration-field') + result[key] = value + return result + + +def main(): + if len(sys.argv) == 3 and sys.argv[1] == '--inside': + inside(sys.argv[2]) + require(len(sys.argv) == 4 and sys.argv[1] == '--execute', 'explicit-execution-required') + require(len(sys.argv[2]) <= 32768, 'configuration-bound') + values = validate(json.loads(sys.argv[2], object_pairs_hook=no_duplicates), sys.argv[3]) + owned(Path('/usr/bin/bwrap')) + # exec, not a detached wrapper: the editor tracks the actual sandbox owner. + os.execve('/usr/bin/bwrap', command(*values), {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8'}) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, TypeError, KeyError, RuntimeError): + # Paths/configuration and underlying stderr never become editor output. + sys.exit(1) diff --git a/src/editor-runtime.cjs b/src/editor-runtime.cjs new file mode 100644 index 0000000..ec7e092 --- /dev/null +++ b/src/editor-runtime.cjs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-only +'use strict'; +const path = require('node:path'); +const {spawn} = require('node:child_process'); +const FIELDS = ['version', 'appServer', 'appServerSha256', 'buildReport', 'node', + 'nodeSha256', 'upstreamPrompt', 'socketPath']; +const fail = () => Error('native_editor_runtime_unavailable_or_cleanup_unconfirmed'); + +function configuration(config, workspace) { + if (!config || typeof config !== 'object' || Array.isArray(config) || + Object.keys(config).length !== FIELDS.length || !FIELDS.every(key => Object.hasOwn(config, key)) || + config.version !== 1) throw fail(); + for (const key of FIELDS.slice(1)) { + const value = config[key]; + if (typeof value !== 'string' || !value || value.includes('\0') || Buffer.byteLength(value) > 4096 || + (key.endsWith('Sha256') ? !/^[a-f0-9]{64}$/.test(value) : !path.isAbsolute(value))) throw fail(); + } + if (typeof workspace !== 'string' || !path.isAbsolute(workspace) || workspace.includes('\0') || + Buffer.byteLength(workspace) > 4096) throw fail(); + return {...config}; +} + +// Exposed for synthetic process/stream lifecycle tests, not a configurable executable. +async function ownedSession(child, {startupMs = 30000, closeMs = 45000, killMs = 5000} = {}) { + let terminal = null, forced = false, closing = null; + const exited = new Promise(resolve => { + const done = (code, signal) => { terminal ??= {code, signal}; resolve(terminal); }; + child.once('error', () => done(null, 'spawn_failed')); + child.once('close', done); + }); + child.stdin.on('error', () => {}); // AppServer also receives the stream failure. + child.stdout.on('error', () => {}); + async function close() { + closing ??= (async () => { + child.stdin.end(); + let timer, hard; + try { + const result = await Promise.race([exited, new Promise(resolve => { + timer = setTimeout(() => resolve(null), closeMs); + })]); + if (!result) { + forced = true; child.kill('SIGTERM'); + hard = setTimeout(() => child.kill('SIGKILL'), killMs); + await exited; + } + } finally { clearTimeout(timer); clearTimeout(hard); child.stderr?.destroy(); } + if (forced || terminal?.code !== 0 || terminal?.signal) throw fail(); + })(); + return closing; + } + try { + await new Promise((resolve, reject) => { + let received = Buffer.alloc(0), done = false; + // bwrap only promises stdio inheritance. Native stderr is discarded by + // the inner owner; accept just one exact content-free readiness line. + const status = child.stderr; + const timer = setTimeout(() => finish(false), startupMs); + const finish = okay => { + if (done) return; done = true; clearTimeout(timer); + status?.removeListener('data', data); status?.removeListener('end', end); + status?.removeListener('error', bad); child.removeListener('close', bad); child.removeListener('error', bad); + // Continue draining without retaining or emitting any raw stderr. + if (okay) { status.on('error', () => {}); status.resume(); } + okay ? resolve() : reject(fail()); + }; + const data = chunk => { + received = Buffer.concat([received, chunk]); + if (received.length > 64) finish(false); + else if (received.includes(10)) finish(received.toString() === '{"native_editor_ready":true}\n'); + }; + const end = () => finish(false); + const bad = () => finish(false); + if (!status || terminal) { finish(false); return; } + status.on('data', data); status.once('end', end); status.once('error', bad); + child.once('close', bad); child.once('error', bad); + }); + if (terminal) throw fail(); + return {readable: child.stdout, writable: child.stdin, close}; + } catch { + try { await close(); } catch {} + throw fail(); + } +} + +class NativeRuntime { + static async start(config, {workspace} = {}) { + const checked = configuration(config, workspace); + if (process.platform !== 'linux') throw fail(); + // Fixed interpreter and launcher; no shell, inherited secrets or user-selected command. + const child = spawn('/usr/bin/python3', ['-B', path.resolve(__dirname, '../scripts/editor_session.py'), + '--execute', JSON.stringify(checked), workspace], { + cwd: '/', env: {PATH: '/usr/bin:/bin', LANG: 'C.UTF-8'}, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return ownedSession(child); + } + start(config, options) { return NativeRuntime.start(config, options); } +} +module.exports = {NativeRuntime, configuration, ownedSession}; diff --git a/src/editor-task.cjs b/src/editor-task.cjs new file mode 100644 index 0000000..69bf7de --- /dev/null +++ b/src/editor-task.cjs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-only +'use strict'; +const path = require('node:path'); + +const WORKSPACE = '/workspace'; +const MODEL = 'qwen3-0.6b-v1'; +const id = value => typeof value === 'string' && value.length > 0 && value.length <= 256; +const bounded = (value, size) => typeof value === 'string' && !value.includes('\0') && + Buffer.byteLength(value) <= size; + +// The editor owns intent and one-shot approvals; the native runtime owns tools, +// and VOLPAROSSA owns inference. There is no fixture command list or fake result. +class EditorTask { + constructor(client, approve, {onStatus = () => {}} = {}) { + this.client = client; this.approval = approve; this.onStatus = onStatus; + this.thread = null; this.turn = null; this.active = false; this.stopped = false; + this.text = ''; this.commands = 0; this.terminal = null; this.finish = () => {}; + this.notify = value => this.notification(value); + this.closed = () => { this.stopped = true; this.finish(); }; + client.on('notification', this.notify); client.on('closed', this.closed); + } + async approve(params) { + const eligible = () => this.active && !this.stopped && !this.terminal && this.turn && + params?.threadId === this.thread && params.turnId === this.turn; + if (!eligible() || params.kind !== 'command' || !id(params.itemId) || + !bounded(params.command, 8192) || !params.command.trim() || + !bounded(params.cwd, 4096) || path.posix.normalize(params.cwd) !== params.cwd || + !(params.cwd === WORKSPACE || params.cwd.startsWith(WORKSPACE + '/')) || + params.networkApprovalContext || params.additionalPermissions || params.proposedNetworkPolicyAmendments) return false; + // No session approval, escalation, network authorization or persisted policy. + const accepted = await this.approval({command: params.command, + directory: '.' + params.cwd.slice(WORKSPACE.length)}); + return eligible() && accepted === true; + } + notification({method, params}) { + if (!this.active || this.stopped || this.terminal || params?.threadId !== this.thread) return; + if (method === 'turn/started') { + if (!id(params.turn?.id) || this.turn && this.turn !== params.turn.id) { + void this.stop(); return; + } + this.turn = params.turn.id; + } + if (method === 'item/agentMessage/delta' && params.turnId === this.turn) { + if (!bounded(params.delta, 65536) || Buffer.byteLength(this.text) + Buffer.byteLength(params.delta) > 65536) { + void this.stop(); return; + } + this.text += params.delta; + } + if (method === 'item/completed' && params.turnId === this.turn && params.item?.type === 'commandExecution') { + this.commands++; + this.onStatus({commands: this.commands, status: params.item.status === 'completed' ? 'completed' : 'failed'}); + } + if (method === 'turn/completed' && this.turn && params.turn?.id === this.turn) { + this.terminal = params.turn; this.finish(); + } + } + async stop() { + if (this.stopped) return; + this.stopped = true; this.finish(); + if (this.active && this.thread && this.turn) { + try { await this.client.interrupt(this.thread, this.turn); } catch {} + } + } + async run(prompt, {signal, timeoutMs = 2400000} = {}) { + if (this.thread || !bounded(prompt, 65536) || !prompt.trim() || + !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2400000) throw Error('editor_task_scope'); + const abort = () => { void this.stop(); }; + const deadline = setTimeout(abort, timeoutMs); + signal?.addEventListener('abort', abort, {once: true}); + try { + if (signal?.aborted || this.stopped) throw Error('editor_task_cancelled'); + await this.client.initialize(); + if (this.stopped) throw Error('editor_task_cancelled'); + const started = await this.client.startThread({model: MODEL, cwd: WORKSPACE}); + if (!id(started?.thread?.id) || started.model !== MODEL || started.cwd !== WORKSPACE || + started.thread.modelProvider !== 'volparossa' || started.thread.ephemeral !== true) throw Error('editor_thread_scope'); + this.thread = started.thread.id; + if (this.stopped) throw Error('editor_task_cancelled'); + const done = new Promise(resolve => { this.finish = resolve; }); + this.active = true; + const admitted = await this.client.startTurn(this.thread, prompt); + if (!id(admitted?.turn?.id) || this.turn && this.turn !== admitted.turn.id) throw Error('editor_turn_scope'); + this.turn = admitted.turn.id; + if (this.stopped) { + // A cancellation before start's response still cancels the admitted turn. + try { await this.client.interrupt(this.thread, this.turn); } catch {} + } else await done; + this.active = false; + if (this.stopped) throw Error('editor_task_cancelled'); + if (this.terminal?.status !== 'completed' || this.terminal.error) throw Error('editor_turn_incomplete'); + const result = await this.client.request('thread/unsubscribe', {threadId: this.thread}); + if (result?.status !== 'unsubscribed') throw Error('editor_unsubscribe_unconfirmed'); + // Native turn completion is not proof the user's task or tests succeeded. + return {text: this.text, commands: this.commands, nativeTurnCompleted: true, taskVerified: false}; + } finally { + clearTimeout(deadline); signal?.removeEventListener('abort', abort); + if (this.active) await this.stop(); + this.active = false; + this.client.off('notification', this.notify); this.client.off('closed', this.closed); + } + } +} +module.exports = {EditorTask, WORKSPACE, MODEL}; diff --git a/src/extension.cjs b/src/extension.cjs index a97983b..f04138e 100644 --- a/src/extension.cjs +++ b/src/extension.cjs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-only 'use strict'; const {PrivateCompute} = require('./private-compute.cjs'); +const {AppServer} = require('./app-server.cjs'); +const {EditorTask, WORKSPACE, MODEL} = require('./editor-task.cjs'); const byteLength = text => Buffer.byteLength(text, 'utf8'); function selectionInput(editor) { @@ -14,11 +16,13 @@ function selectionInput(editor) { return text; } -function register(vscode, context, Client = PrivateCompute) { +function register(vscode, context, Client = PrivateCompute, native = {}) { const active = new Set(); + let nativeActive; + let disposed = false; let busy = false; const trusted = () => { - if (!vscode.workspace.isTrusted || vscode.env.remoteName) { + if (disposed || !vscode.workspace.isTrusted || vscode.env.remoteName) { throw Error('This development integration requires a trusted local workspace.'); } }; @@ -49,7 +53,8 @@ function register(vscode, context, Client = PrivateCompute) { // Transport/server errors are not echoed: they may include submitted input or paths. const local = error?.message; const safe = ['Select a small code excerpt', 'The current private compute', - 'This development integration', 'Set the absolute'].some(prefix => local?.startsWith(prefix)); + 'This development integration', 'Set the absolute', 'Configure the native runtime', + 'Open a local workspace'].some(prefix => local?.startsWith(prefix)); await vscode.window.showErrorMessage(safe ? local : 'VOLPAROSSA could not complete this operation. No cloud or public-peer fallback was attempted.'); } finally { busy = false; } }; @@ -58,7 +63,7 @@ function register(vscode, context, Client = PrivateCompute) { try { await show(`VOLPAROSSA private compute\n\nScope: private, local only\nProfile: ${capabilities.model_profile}\n` + `Selected-code limit: ${capabilities.max_context_bytes} UTF-8 bytes\n` + - 'Public peer delegation: not enabled\nCodex coding-agent integration: in development, not yet connected\n' + + 'Public peer delegation: not enabled\nNative coding is a separate explicit command requiring a prepared runtime and conversation service.\n' + 'Capability negotiation does not prove model quality or execution.\n'); } finally { close(client); } }))); @@ -89,10 +94,77 @@ function register(vscode, context, Client = PrivateCompute) { 'Local private inference; no Codex tool execution or distributed coding claim.\n\n' + result.output.text); } finally { close(client); } }))); - context.subscriptions.push({dispose() { for (const client of active) client.close(); active.clear(); }}); + context.subscriptions.push(vscode.commands.registerCommand('volparossaCode.codingTask', run(async () => { + const folders = (vscode.workspace.workspaceFolders ?? []).filter(folder => folder.uri.scheme === 'file'); + if (!folders.length) throw Error('Open a local workspace folder before starting a coding task.'); + const folder = folders.length === 1 ? folders[0] : (await vscode.window.showQuickPick( + folders.map(item => ({label: item.name, description: item.uri.fsPath, folder: item})), + {title: 'Choose the only workspace folder this coding task may access'}))?.folder; + if (!folder) return; + const config = vscode.workspace.getConfiguration('volparossaCode'); + const runtimeConfig = config.inspect('nativeRuntime')?.globalValue; + const socket = config.inspect('privateSocket')?.globalValue; + if (!runtimeConfig || typeof runtimeConfig !== 'object' || Array.isArray(runtimeConfig) || typeof socket !== 'string') { + throw Error('Configure the native runtime and private socket in your user settings first.'); + } + const prompt = await vscode.window.showInputBox({title: 'VOLPAROSSA native coding task', + prompt: 'Describe the task. The native agent may read and modify the selected workspace using local VOLPAROSSA inference.', + ignoreFocusOut: true, validateInput: text => !text.trim() || text.includes('\0') || byteLength(text) > 65536 + ? 'Enter a task of 1–65536 UTF-8 bytes.' : undefined}); + if (!prompt?.trim() || prompt.includes('\0') || byteLength(prompt) > 65536) return; + const confirmed = await vscode.window.showInformationMessage( + `Allow a native coding task in ${folder.uri.fsPath}?\n\n` + + 'The selected folder is writable. Its contents and tool results may be processed by your local VOLPAROSSA core. ' + + 'No public peers or Internet access are enabled. Requested command approvals are one-shot; changes are not automatically rolled back.', + {modal: true}, 'Start local coding'); + if (confirmed !== 'Start local coding') return; + trusted(); + const Runtime = native.Runtime ?? require('./editor-runtime.cjs').NativeRuntime; + const Server = native.Server ?? AppServer, Task = native.Task ?? EditorTask; + let runtime, server, task, result; + try { + runtime = await Runtime.start({...runtimeConfig, socketPath: socket}, {workspace: folder.uri.fsPath}); + trusted(); + server = new Server(runtime.readable, runtime.writable, {timeoutMs: 30000, + allowedModels: [MODEL], writableRoot: WORKSPACE, + commandApproval: params => task ? task.approve(params) : false}); + result = await vscode.window.withProgress({location: vscode.ProgressLocation.Notification, + title: 'VOLPAROSSA native coding — local, workspace-scoped', cancellable: true}, async (progress, cancellation) => { + trusted(); + const controller = new AbortController(); + task = new Task(server, async proposal => { + trusted(); + const decision = await vscode.window.showWarningMessage( + `Run this command once in ${proposal.directory}?\n\n${proposal.command}\n\n` + + 'This permits only this request, not future commands or wider access.', {modal: true}, 'Run once'); + trusted(); + return decision === 'Run once'; + }, {onStatus: event => progress.report({message: `${event.commands} native command(s) observed; last ${event.status}.`})}); + nativeActive = {runtime, server, task}; + const listener = cancellation.onCancellationRequested(() => controller.abort()); + if (cancellation.isCancellationRequested) controller.abort(); + try { return await task.run(prompt, {signal: controller.signal}); } + finally { listener.dispose(); } + }); + } finally { + server?.close(); + try { if (runtime) await runtime.close(); } + finally { nativeActive = undefined; } + } + await show('VOLPAROSSA — native coding turn finished\n' + + 'Task correctness and tests are not independently verified by this frontend. Review your changes and tool results.\n' + + `Native commands observed: ${result.commands}\nLocal private inference; no public-peer execution.\n\n${result.text}`); + }))); + context.subscriptions.push({dispose() { + disposed = true; + for (const client of active) client.close(); active.clear(); + if (nativeActive) { + void nativeActive.task.stop(); nativeActive.server.close(); + void nativeActive.runtime.close().catch(() => {}); + } + }}); } exports.activate = context => register(require('vscode'), context); exports.register = register; exports.selectionInput = selectionInput; - diff --git a/tests/editor-runtime.test.cjs b/tests/editor-runtime.test.cjs new file mode 100644 index 0000000..725a0c6 --- /dev/null +++ b/tests/editor-runtime.test.cjs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Synthetic transport/lifecycle checks. No native runtime, model or real project executes. +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const {spawn, spawnSync} = require('node:child_process'); +const {PassThrough} = require('node:stream'); +const {EventEmitter, once} = require('node:events'); +const path = require('node:path'); +const {configuration, ownedSession, NativeRuntime} = require('../src/editor-runtime.cjs'); +const {runSession} = require('../scripts/editor_session.cjs'); +const root = path.resolve(__dirname, '..'); + +function python(source) { + const result = spawnSync('/usr/bin/python3', ['-B', '-c', + "import sys; sys.path.insert(0,'scripts')\nimport editor_session as s\n" + source], + {cwd: root, encoding: 'utf8', timeout: 10000}); + assert.equal(result.status, 0, result.stderr); +} + +test('editor configuration has only explicit pinned runtime/node/prompt/core inputs', async () => { + const config = {version: 1, appServer: '/fixture/runtime/codex-app-server', appServerSha256: 'a'.repeat(64), + buildReport: '/fixture/BUILD_REPORT.json', node: '/fixture/node', nodeSha256: 'b'.repeat(64), + upstreamPrompt: '/fixture/prompt.md', socketPath: '/fixture/private/core.sock'}; + assert.deepEqual(configuration(config, '/fixture/project'), config); + for (const changed of [{...config, command: 'injected'}, {...config, nodeSha256: 'bad'}, + {...config, socketPath: 'relative'}, {...config, version: 2}, {...config, appServer: '/x\0bad'}]) { + assert.throws(() => configuration(changed, '/fixture/project'), /native_editor_runtime/); + } + await assert.rejects(NativeRuntime.start(config, {workspace: 'relative'}), /native_editor_runtime/); +}); + +test('sparse sandbox binds only the selected project RW with isolated network and no environment override', () => { + python(String.raw` +from pathlib import Path +c=s.command(Path('/f/runtime'),Path('/f/node'),Path('/f/core/socket'),Path('/f/prompt'), + Path('/f/project'),'/home/fixture') +triples=[c[i:i+3] for i in range(len(c)-2)] +assert [t for t in triples if t[0]=='--bind']==[['--bind','/f/project','/workspace']] +assert ['--ro-bind','/f/core/socket','/opt/core/compute.sock'] in triples +assert ['--ro-bind','/etc/passwd','/etc/passwd'] in triples +assert not any(t[0]=='--ro-bind' and t[1] in ('/','/home','/home/fixture','/f') for t in triples) +for flag in ('--unshare-user','--unshare-net','--unshare-pid','--unshare-ipc','--unshare-uts','--clearenv'): + assert flag in c +assert '--preserve-fds' not in c +assert c[c.index('--chdir')+1]=='/workspace' +assert [t for t in triples if t[0]=='--setenv']==[ + ['--setenv','PATH','/usr/bin:/bin'],['--setenv','LANG','C.UTF-8']] +assert all('fixture.py' not in arg and 'smoke_native_coding.cjs' not in arg for arg in c) +`); +}); + +test('owner selection rejects broad roots, symlinks, writable-by-others projects and authority overlap', () => { + python(String.raw` +from pathlib import Path +import tempfile,os +with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary); project=root/'project'; project.mkdir(mode=0o700) + assert s.selected_workspace(str(project),[root/'runtime'],str(root/'home'))==project + alias=root/'alias'; alias.symlink_to(project) + for candidate,inputs,home in [('/',[],str(root/'home')),('/tmp',[],str(root/'home')), + (str(alias),[],str(root/'home')),(str(project),[project/'runtime'],str(root/'home')), + (str(project),[],str(project))]: + try:s.selected_workspace(candidate,inputs,home) + except ValueError:pass + else:raise AssertionError('unsafe selection accepted') + try:s.selected_workspace(str(project),[],str(root/'home'),protected=(root,)) + except ValueError:pass + else:raise AssertionError('launcher/runtime descendant accepted') + project.chmod(0o777) + try:s.selected_workspace(str(project),[],str(root/'home')) + except ValueError:pass + else:raise AssertionError('shared writable project accepted') + try:s.no_duplicates([('version',1),('version',1)]) + except ValueError:pass + else:raise AssertionError('duplicate config accepted') +`); +}); + +test('inside accepts bubblewrap-generated workspace PWD, never inherited home/config or another cwd', () => { + python(String.raw` +s.clean_environment({'PATH':'/usr/bin:/bin','LANG':'C.UTF-8','PWD':'/workspace'}) +s.clean_environment({'PATH':'/usr/bin:/bin','LANG':'C.UTF-8'}) +for change in ({'PWD':'/host/project'},{'HOME':'/home/owner'},{'CODEX_HOME':'/private'}, + {'OPENAI_API_KEY':'synthetic-secret'}): + try:s.clean_environment({'PATH':'/usr/bin:/bin','LANG':'C.UTF-8','PWD':'/workspace'}|change) + except ValueError:pass + else:raise AssertionError('host environment accepted') +`); +}); + +test('runtime file binding rejects modified hashes or writable executable, socket requires same-owner private directory', () => { + python(String.raw` +from pathlib import Path +import tempfile,hashlib,socket +with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary); binary=root/'runtime'; binary.write_bytes(b'synthetic-not-an-executable') + binary.chmod(0o700); sha=hashlib.sha256(binary.read_bytes()).hexdigest() + assert s.owned(s.verified_file(str(binary),sha,executable=True))==binary + for expected,mode in [('0'*64,0o700),(sha,0o777)]: + binary.chmod(mode) + try:s.owned(s.verified_file(str(binary),expected,executable=True)) + except ValueError:pass + else:raise AssertionError('runtime binding lost') + private=root/'private'; private.mkdir(mode=0o700); ipc=private/'compute.sock' + with socket.socket(socket.AF_UNIX) as server: + server.bind(str(ipc)); ipc.chmod(0o600) + assert s.private_socket(str(ipc))==ipc + private.chmod(0o755) + try:s.private_socket(str(ipc)) + except ValueError:pass + else:raise AssertionError('nonprivate socket accepted') +`); +}); + +function syntheticInner({unconfirmed = false, providerThrows = false} = {}) { + const input = new PassThrough(), output = new PassThrough(), events = new EventEmitter(); + let bytes = Buffer.alloc(0), closes = 0, children = 0, ready; + output.on('data', chunk => { bytes = Buffer.concat([bytes, chunk]); }); + const started = new Promise(resolve => { ready = resolve; }); + const hooks = {preflight: async () => {}, catalog() {}, + provider: async () => ({baseUrl: 'http://127.0.0.1:1234/v1', bearerToken: 'synthetic-secret', + observations: {submitted: unconfirmed ? 1 : 0, cleanup_confirmed: 0}, + async close() { closes++; if (providerThrows) throw Error('private-sentinel'); }}), + spawn(binary, args, options) { + children++; + assert.equal(binary, '/opt/codex-app-server'); assert.equal(options.cwd, '/workspace'); + assert.deepEqual(Object.keys(options.env).sort(), ['LANG', 'PATH', 'VOLPAROSSA_PROVIDER_TOKEN']); + assert(args.includes('model_provider="volparossa"')); + assert(!args.some(value => /fixture.py|TASK|HOME/.test(value))); + // A small echo process, not native Codex and not an inference substitute. + return spawn(process.execPath, ['-e', + 'process.stderr.write("private-sentinel"); process.stdin.pipe(process.stdout);'], + {stdio: ['pipe', 'pipe', 'pipe'], env: options.env}); + }}; + const done = runSession({input, output, events, ready}, hooks); + return {input, output, events, started, done, get bytes() { return bytes; }, + get closes() { return closes; }, get children() { return children; }}; +} + +test('inner owner transparently forwards NDJSON, discards raw stderr and joins provider on EOF', async () => { + const fixture = syntheticInner(); await fixture.started; + const request = Buffer.from('{"id":1,"method":"initialize","params":{"private":"local"}}\n'); + fixture.input.end(request); + assert.equal(await fixture.done, 0); assert.deepEqual(fixture.bytes, request); + assert.equal(fixture.closes, 1); assert.equal(fixture.children, 1); + assert.equal(fixture.events.listenerCount('SIGTERM'), 0); +}); + +test('inner owner never calls missing cleanup or provider failure successful', async () => { + for (const options of [{unconfirmed: true}, {providerThrows: true}]) { + const fixture = syntheticInner(options); await fixture.started; fixture.input.end(); + assert.equal(await fixture.done, 1); assert.equal(fixture.closes, 1); + } +}); + +test('inner owner handles signal by closing the provider and child, without protocol diagnostics', async () => { + const fixture = syntheticInner(); await fixture.started; fixture.events.emit('SIGTERM'); + assert.equal(await fixture.done, 1); assert.equal(fixture.closes, 1); + assert.equal(fixture.bytes.length, 0); +}); + +function syntheticOuter(program) { + return spawn(process.execPath, ['-e', program], {stdio: ['pipe', 'pipe', 'pipe'], + env: {PATH: '/usr/bin:/bin', LANG: 'C.UTF-8'}}); +} +test('outer readiness is separate from NDJSON and close is idempotent with a joined process', async () => { + const child = syntheticOuter('process.stderr.write(\'{"native_editor_ready":true}\\n\'); process.stdin.pipe(process.stdout);'); + const session = await ownedSession(child, {closeMs: 1000}); + const chunks = []; session.readable.on('data', chunk => chunks.push(chunk)); + const seen = once(session.readable, 'data'); + session.writable.write('{"synthetic":true}\n'); await seen; + await Promise.all([session.close(), session.close()]); + assert.equal(Buffer.concat(chunks).toString(), '{"synthetic":true}\n'); + assert.equal(child.exitCode, 0); +}); + +test('outer failures are generic and forced stop is never a cleanup success', async () => { + const failed = syntheticOuter('process.stderr.write("private-path-and-token\\n"); process.exitCode=1;'); + await assert.rejects(ownedSession(failed, {startupMs: 1000, closeMs: 1000}), + error => error.message === 'native_editor_runtime_unavailable_or_cleanup_unconfirmed'); + const stuck = syntheticOuter('process.stderr.write(\'{"native_editor_ready":true}\\n\'); setInterval(()=>{},1000);'); + const session = await ownedSession(stuck, {closeMs: 30, killMs: 30}); + await assert.rejects(session.close(), /cleanup_unconfirmed/); + await assert.rejects(session.close(), /cleanup_unconfirmed/); + assert(stuck.signalCode); +}); diff --git a/tests/editor-task.test.cjs b/tests/editor-task.test.cjs new file mode 100644 index 0000000..560596f --- /dev/null +++ b/tests/editor-task.test.cjs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-only +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const {EventEmitter} = require('node:events'); +const {EditorTask, WORKSPACE, MODEL} = require('../src/editor-task.cjs'); + +function setup(action) { + const client = new EventEmitter(), calls = [], approvals = []; + client.initialize = async () => { calls.push('initialize'); }; + client.startThread = async args => { + calls.push(['thread', args]); + return {model: MODEL, cwd: WORKSPACE, thread: {id: 'thread', modelProvider: 'volparossa', ephemeral: true}}; + }; + const event = (method, params) => client.emit('notification', {method, params: {threadId: 'thread', ...params}}); + client.interrupt = async (...args) => { calls.push(['interrupt', ...args]); }; + client.request = async (method, params) => { calls.push([method, params]); return {status: 'unsubscribed'}; }; + const task = new EditorTask(client, async proposal => { approvals.push(proposal); return true; }); + client.startTurn = async (thread, prompt) => { + calls.push(['turn', thread, prompt]); + event('turn/started', {turn: {id: 'turn'}}); + await action({client, task, event}); + return {turn: {id: 'turn'}}; + }; + return {client, task, calls, approvals}; +} +const proposal = () => ({kind: 'command', threadId: 'thread', turnId: 'turn', itemId: 'item', + command: '/bin/bash -c "node --test"', cwd: WORKSPACE}); +const finish = event => event('turn/completed', {turn: {id: 'turn', status: 'completed', error: null}}); + +test('real editor controller accepts an arbitrary scoped command, keeps native lineage, and does not claim task correctness', async () => { + const f = setup(async ({task, event}) => { + assert.equal(await task.approve(proposal()), true); + event('item/agentMessage/delta', {turnId: 'turn', delta: 'Inspect the actual changes.'}); + event('item/completed', {turnId: 'turn', item: {type: 'commandExecution', status: 'completed'}}); + finish(event); // Notifications may precede the turn/start reply. + }); + const result = await f.task.run('Implement the selected task.'); + assert.deepEqual(result, {text: 'Inspect the actual changes.', commands: 1, nativeTurnCompleted: true, taskVerified: false}); + assert.deepEqual(f.approvals, [{command: proposal().command, directory: '.'}]); + assert.deepEqual(f.calls.at(-1), ['thread/unsubscribe', {threadId: 'thread'}]); + assert.equal(f.client.listenerCount('notification'), 0); + assert.equal(await f.task.approve(proposal()), false); +}); + +test('wrong lineage, workspace escapes, network/escalation, and non-command approvals never reach the user', async () => { + const f = setup(async ({task, event}) => { + for (const change of [{threadId: 'other'}, {turnId: 'old'}, {itemId: ''}, {cwd: '/workspace-other'}, + {cwd: '/workspace/../tmp'}, {cwd: '/workspace/sub/../../tmp'}, {kind: 'writeStdin'}, + {additionalPermissions: {}}, {networkApprovalContext: {}}, {proposedNetworkPolicyAmendments: []}, + {command: 'x'.repeat(8193)}, {command: '\0'}, {command: ''}]) { + assert.equal(await task.approve({...proposal(), ...change}), false); + } + // The proposed rule is not adopted; the underlying client sends accept once. + assert.equal(await task.approve({...proposal(), cwd: '/workspace/src', proposedExecpolicyAmendment: ['node']}), true); + finish(event); + }); + await f.task.run('A task'); + assert.equal(f.approvals.length, 1); assert.equal(f.approvals[0].directory, './src'); +}); + +test('an approval returned after cancellation or native completion is refused', async () => { + const f = setup(async ({task, event}) => { + let accept; + task.approval = () => new Promise(resolve => { accept = resolve; }); + const pending = task.approve(proposal()); + finish(event); accept(true); + assert.equal(await pending, false); + }); + await f.task.run('A task'); +}); + +test('cancel before start reply still interrupts the exact admitted native turn', async () => { + const abort = new AbortController(); + const f = setup(async () => { abort.abort(); }); + await assert.rejects(f.task.run('A task', {signal: abort.signal}), /cancelled/); + assert(f.calls.some(call => Array.isArray(call) && call[0] === 'interrupt' && call[1] === 'thread' && call[2] === 'turn')); + assert(!f.calls.some(call => Array.isArray(call) && call[0] === 'thread/unsubscribe')); +}); + +test('native failure, EOF, oversize output and deadline are not successful tasks', async () => { + const actions = [async ({event}) => event('turn/completed', {turn: {id: 'turn', status: 'failed', error: {message: 'private'}}}), + async ({client}) => client.emit('closed'), + async ({event}) => event('item/agentMessage/delta', {turnId: 'turn', delta: 'x'.repeat(65537)}), + async () => {}]; + for (const action of actions) { + const f = setup(action); + await assert.rejects(f.task.run('A task', {timeoutMs: 10}), /editor_(turn_incomplete|task_cancelled)/); + assert.equal(f.client.listenerCount('notification'), 0); + } +}); + +test('cancellation before launch and provider/thread substitution fail without a model request', async () => { + const abort = new AbortController(); abort.abort(); + const f = setup(async () => {}); + await assert.rejects(f.task.run('A task', {signal: abort.signal}), /cancelled/); + assert.deepEqual(f.calls, []); + const g = setup(async () => {}); + g.client.startThread = async () => ({model: MODEL, cwd: WORKSPACE, + thread: {id: 'thread', modelProvider: 'other', ephemeral: true}}); + await assert.rejects(g.task.run('A task'), /thread_scope/); + assert(!g.calls.some(call => Array.isArray(call) && call[0] === 'turn')); +}); diff --git a/tests/extension.test.cjs b/tests/extension.test.cjs index c105e51..d04e72c 100644 --- a/tests/extension.test.cjs +++ b/tests/extension.test.cjs @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const {readFileSync} = require('node:fs'); const {register, selectionInput} = require('../src/extension.cjs'); -function fixture({trusted = true, confirm = true, partial = false} = {}) { +function fixture({trusted = true, confirm = true, partial = false, native} = {}) { const commands = new Map(), calls = [], documents = [], errors = []; const context = {subscriptions: []}; const editor = {selection: {isEmpty: false}, document: {uri: {scheme: 'file'}, @@ -29,7 +29,7 @@ function fixture({trusted = true, confirm = true, partial = false} = {}) { async withProgress(_options, action) { return action({}, {isCancellationRequested: false, onCancellationRequested() { return {dispose() {}}; }}); }} }; - register(api, context, Client); + register(api, context, Client, native); return {commands, calls, documents, errors, api, context}; } test('activation has no compute side effects and configuration cannot be redirected by the workspace', async () => { @@ -63,5 +63,97 @@ test('manifest declares trust and machine scope without telemetry, accounts or a const value = JSON.parse(readFileSync(new URL('../package.json', `file://${__filename}`))); assert.equal(value.capabilities.untrustedWorkspaces.supported, false); assert.equal(value.contributes.configuration.properties['volparossaCode.privateSocket'].scope, 'machine'); + assert.equal(value.contributes.configuration.properties['volparossaCode.nativeRuntime'].scope, 'machine'); assert.equal(value.dependencies, undefined); assert.equal(value.activationEvents, undefined); }); + +function codingFixture() { + const events = []; + const native = { + Runtime: {async start(config, options) { + events.push(['launch', config, options]); + return {readable: {}, writable: {}, async close() { events.push(['cleanup']); }}; + }}, + Server: class { + constructor(_read, _write, options) { events.push(['server', options]); } + close() { events.push(['server-close']); } + }, + Task: class { + constructor(_server, approval, options) { this.approval = approval; this.options = options; } + async run(prompt) { + events.push(['task', prompt]); + assert.equal(await this.approval({command: 'node --test', directory: '.'}), true); + this.options.onStatus({commands: 1, status: 'completed'}); + return {text: 'Generated reply', commands: 1, nativeTurnCompleted: true, taskVerified: false}; + } + async stop() { events.push(['stop']); } + } + }; + const f = fixture({native}); + f.api.workspace.workspaceFolders = [{name: 'project', uri: {scheme: 'file', fsPath: '/projects/selected'}}]; + f.api.workspace.getConfiguration = () => ({inspect: key => ({ + globalValue: key === 'privateSocket' ? '/run/owner/conversation.sock' : {version: 1, appServer: '/explicit/runtime'}, + workspaceValue: key === 'privateSocket' ? '/tmp/untrusted.sock' : {appServer: '/project/untrusted-runtime'} + })}); + f.api.window.showInformationMessage = async (_text, _options, action) => action; + f.api.window.showWarningMessage = async (text, options, action) => { + events.push(['approval', text, options]); return action; + }; + f.api.window.withProgress = async (_options, action) => action({report(value) { events.push(['progress', value]); }}, { + isCancellationRequested: false, onCancellationRequested() { return {dispose() {}}; } + }); + return {...f, events, native}; +} + +test('explicit coding command launches only user-selected inputs, asks one-shot approval and waits for cleanup', async () => { + const f = codingFixture(); assert.deepEqual(f.events, []); + await f.commands.get('volparossaCode.codingTask')(); + assert.deepEqual(f.events[0], ['launch', {version: 1, appServer: '/explicit/runtime', socketPath: '/run/owner/conversation.sock'}, + {workspace: '/projects/selected'}]); + const options = f.events.find(e => e[0] === 'server')[1]; + assert.equal(options.writableRoot, '/workspace'); assert.deepEqual(options.allowedModels, ['qwen3-0.6b-v1']); + assert(f.events.find(e => e[0] === 'approval')[1].includes('node --test')); + assert.deepEqual(f.events.slice(-2), [['server-close'], ['cleanup']]); + assert.equal(f.documents[0].language, 'plaintext'); + assert.match(f.documents[0].content, /not independently verified/); + assert.match(f.documents[0].content, /Generated reply/); assert.deepEqual(f.errors, []); +}); + +test('cancelled consent, remote, untrusted and missing folders never launch a native runtime', async () => { + for (const configure of [f => { f.api.window.showInformationMessage = async () => undefined; }, + f => { f.api.env.remoteName = 'ssh-remote'; }, f => { f.api.workspace.isTrusted = false; }, + f => { f.api.workspace.workspaceFolders = []; }]) { + const f = codingFixture(); configure(f); + await f.commands.get('volparossaCode.codingTask')(); assert.deepEqual(f.events, []); + } +}); + +test('runtime cleanup failure never displays a successful coding result or raw private error', async () => { + const f = codingFixture(); + f.native.Runtime.start = async () => ({readable: {}, writable: {}, async close() { throw Error('private-token-and-path'); }}); + await f.commands.get('volparossaCode.codingTask')(); + assert.deepEqual(f.documents, []); assert.equal(f.errors.length, 1); + assert(!f.errors[0].includes('private-token-and-path')); +}); + +test('deactivation during native startup closes the new runtime without beginning a task', async () => { + const f = codingFixture(); let joined = false; + f.native.Runtime.start = async () => { + f.context.subscriptions.at(-1).dispose(); + return {readable: {}, writable: {}, async close() { joined = true; }}; + }; + await f.commands.get('volparossaCode.codingTask')(); + assert.equal(joined, true); assert.deepEqual(f.events, []); assert.deepEqual(f.documents, []); +}); + +test('deactivation while progress callback is queued cannot begin native inference', async () => { + const f = codingFixture(); + f.api.window.withProgress = async (_options, action) => { + f.context.subscriptions.at(-1).dispose(); + return action({}, {}); + }; + await f.commands.get('volparossaCode.codingTask')(); + assert(!f.events.some(e => e[0] === 'task')); + assert.deepEqual(f.events.slice(-2), [['server-close'], ['cleanup']]); + assert.deepEqual(f.documents, []); +}); From 41a4320f0a819819adcfa9969ed4780506d6c747 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:08:54 +0200 Subject: [PATCH 2/3] test: add isolated native editor UI trial --- docs/NATIVE_EDITOR.md | 89 +++++++++ scripts/editor_ui_fixture.py | 82 +++++++++ scripts/smoke_editor_ui.cjs | 344 +++++++++++++++++++++++++++++++++++ tests/editor-ui.test.cjs | 97 ++++++++++ 4 files changed, 612 insertions(+) create mode 100644 scripts/editor_ui_fixture.py create mode 100644 scripts/smoke_editor_ui.cjs create mode 100644 tests/editor-ui.test.cjs diff --git a/docs/NATIVE_EDITOR.md b/docs/NATIVE_EDITOR.md index 0ead63f..e98d54f 100644 --- a/docs/NATIVE_EDITOR.md +++ b/docs/NATIVE_EDITOR.md @@ -98,6 +98,95 @@ launcher rejected bubblewrap's own `PWD=/workspace`. Dedicated private staging and an exact namespace-local PWD check resolved those launch blockers without relaxing input ownership or importing host environment settings. +A separate **actual VSCodium UI** probe also passed: F1/Command Palette, the +capabilities view, the native-task input box, the consent dialog and clicking +Cancel before runtime startup. There were zero automatic requests and exactly +one capability request to a synthetic capability-only service. The editor exited +normally; its private profile was removed and host network state was unchanged. +This is UI admission evidence, **not** native inference or coding evidence: +**Run once**, model-driven edits and the final task-result view remain unproven. + +## Disposable guest UI trial + +`scripts/smoke_editor_ui.cjs` drives the real editor UI; it does **not** start an +editor, model, core service or VM. Execution requires `--execute --yes`, Linux +hostname `volparossa-alpha`, user `vpci` and KVM virtualization. Do not run the +model trial on the development host or bypass these guards. + +The supervising guest launcher must first provide: + +- The source-verified native runtime, complete upstream prompt and notices, + hash-verified Node 24 and VSCodium; reuse these assets, without downloading at + launch. VSCodium **1.135.06055**, commit + `1a46a584725d5dd330e0bcd7f5510f24990efcf2`, has actually opened a headless + workbench with `--ozone-platform=headless`; this version needs no Xvfb. +- A **real** owner-private `qwen3-0.6b-v1` conversation service with verified model + and Python-runtime provenance: two threads, 600 seconds per request, a 5 GiB + memory cgroup, no swap and a 2,700-second service window. Preserve the worker's + existing RSS/admission limits; an out-of-memory or admission failure is not + permission to weaken them. +- An isolated network/PID/mount/IPC environment, empty account home, no host + `DISPLAY`, Wayland, D-Bus or other host IPC mounts, and no inherited credentials. + Use an ordinary unprivileged user; **never add `--no-sandbox`**. Keep the core + socket and its parent at `0600`/`0700` and loopback CDP inside this environment. + +The examples below assume that environment exposes this extension at `/extension`, +Node at `/opt/node`, and new owner-only directories under `/trial`. The project +must already be empty and mode `0700`, with a name such as `editor-ui-project-01`. +The reports directory must also be `0700`; output files must not already exist. + +```sh +/opt/node /extension/scripts/smoke_editor_ui.cjs \ + --prepare-project --execute --yes \ + --project /trial/editor-ui-project-01 --output /trial/reports/prepare.json +``` + +Merge the explicit runtime/socket settings from the setup example into the +isolated profile's `User/settings.json`, alongside: + +```json +{ + "window.dialogStyle": "custom", + "workbench.startupEditor": "none", + "telemetry.telemetryLevel": "off", + "update.mode": "none", + "extensions.autoCheckUpdates": false, + "extensions.autoUpdate": false, + "security.workspace.trust.enabled": false +} +``` + +The last setting is **only for this disposable, explicitly selected fixture**, +not a recommended user default. Custom dialogs and English UI are required for +the real DOM selectors. Start the prepared editor inside the same isolation: + +```sh +/usr/share/codium/codium --new-window --ozone-platform=headless --disable-gpu \ + --disable-updates --disable-telemetry --disable-crash-reporter --locale=en \ + --user-data-dir=/trial/profile --extensions-dir=/trial/extensions \ + --extensionDevelopmentPath=/extension --skip-welcome --skip-release-notes \ + --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 \ + /trial/editor-ui-project-01 +``` + +Once its workbench is ready, run from a separate supervised process: + +```sh +/opt/node /extension/scripts/smoke_editor_ui.cjs --execute --yes \ + --cdp http://127.0.0.1:9222 --project /trial/editor-ui-project-01 \ + --output /trial/reports/ui.json --timeout-seconds 2400 +``` + +The driver requires the unchanged prepared fixture, enters a real task, clicks +consent and approves only the bounded fixture commands. The model supplies the +edit expression. Success requires actual recorded read/edit/test actions, +unchanged helper code, a changed source file, an independent passing test and the +UI result displayed after runtime cleanup. The receipt contains closed statuses, +counts and hashes, not prompts, commands or private paths. **The full real-model +UI trial has not yet passed.** The parent supervisor still owns editor/core/VM +shutdown, private-profile/project removal and unchanged-host verification; +`ui.json` does not claim that broader cleanup. + The current prepared model is small; usable general coding quality is still to be measured. Reviewable native diffs, durable multi-turn sessions, additional tool types, broader platform support and eligible cooperative delegation remain diff --git a/scripts/editor_ui_fixture.py b/scripts/editor_ui_fixture.py new file mode 100644 index 0000000..88fb4c3 --- /dev/null +++ b/scripts/editor_ui_fixture.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Real bounded arithmetic fixture for the disposable native-editor UI trial.""" +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import sys + +import native_coding_fixture as fixture + +NAMES = ('arithmetic.py', 'editor_fixture.py', 'native_coding_fixture.py') +JOURNAL = '.editor-ui-actions.jsonl' + + +def project(value): + path = Path(value) + info = path.lstat() + if (os.getuid() == 0 or not path.is_absolute() or path.resolve(strict=True) != path + or not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() + or stat.S_IMODE(info.st_mode) != 0o700 + or not re.fullmatch(r'editor-ui-project-[a-zA-Z0-9_-]{1,32}', path.name)): + raise ValueError('fixture_project_scope') + return path + + +def prepare(path): + if list(path.iterdir()): + raise ValueError('fixture_project_not_empty') + source = Path(__file__).resolve().parent + for name, content, mode in ( + ('arithmetic.py', fixture.ORIGINAL.encode(), 0o600), + ('editor_fixture.py', Path(__file__).read_bytes(), 0o444), + ('native_coding_fixture.py', (source / 'native_coding_fixture.py').read_bytes(), 0o444)): + fd = os.open(path / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + with os.fdopen(fd, 'wb') as output: + output.write(content) + return 0 + + +def journal(action, passed): + path = Path('/workspace') / JOURNAL + if path.exists(): + info = path.lstat() + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size > 2048): + raise ValueError('fixture_journal_scope') + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as output: + output.write(json.dumps({'action': action, 'passed': passed}, separators=(',', ':')) + '\n') + + +def main(): + if len(sys.argv) == 3 and sys.argv[1] in ('--prepare-project', '--verify-project'): + selected = project(sys.argv[2]) + if sys.argv[1] == '--prepare-project': + return prepare(selected) + fixture.PROJECT = selected + fixture.SOURCE = selected / 'arithmetic.py' + os.chdir(selected) + sys.argv = [sys.argv[0], 'test'] + return fixture.main() # Independent verification does not enter the native-action journal. + if Path.cwd() != Path('/workspace') or len(sys.argv) not in (2, 3): + raise ValueError('fixture_execution_scope') + action = sys.argv[1] + if action not in ('read', 'edit', 'test'): + raise ValueError('fixture_action') + fixture.PROJECT = Path('/workspace') + fixture.SOURCE = fixture.PROJECT / 'arithmetic.py' + status = fixture.main() + journal(action, status == 0) + return status + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (OSError, ValueError, SyntaxError, IndexError, ZeroDivisionError): + print('editor_ui_fixture_failed', file=sys.stderr) + sys.exit(1) diff --git a/scripts/smoke_editor_ui.cjs b/scripts/smoke_editor_ui.cjs new file mode 100644 index 0000000..acb7dca --- /dev/null +++ b/scripts/smoke_editor_ui.cjs @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Disposable guest only. Real CDP keyboard/mouse input, never a VS Code API shim. +'use strict'; +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const {spawnSync} = require('node:child_process'); +const {createHash} = require('node:crypto'); +const {commandKind} = require('../src/native-coding-fixture.cjs'); +const ROOT = path.resolve(__dirname, '..'); +const ORIGINAL = 'def add(a, b):\n return a - b\n'; +const ACTIONS = '.editor-ui-actions.jsonl'; +const FAILURE = new Set(['guest_required', 'arguments', 'project_scope', 'output_scope', + 'editor_unavailable', 'cdp_failed', 'ui_unrecognized', 'ui_bound', 'deadline', + 'command_refused', 'editor_failed', 'fixture_failed', 'cleanup_unconfirmed']); +function demand(value, reason) { if (!value) throw Error(reason); } +const sha = value => createHash('sha256').update(value).digest('hex'); + +function guestAllowed({platform, hostname, username, uid, virtualization}) { + return platform === 'linux' && hostname === 'volparossa-alpha' && username === 'vpci' && + Number.isInteger(uid) && uid > 0 && virtualization === 'kvm'; +} +function requireGuest() { + const account = os.userInfo(); + demand(process.platform === 'linux' && os.hostname() === 'volparossa-alpha' && + account.username === 'vpci' && account.uid > 0, 'guest_required'); + const checked = spawnSync('/usr/bin/systemd-detect-virt', ['--vm'], + {encoding: 'utf8', timeout: 5000, env: {PATH: '/usr/bin:/bin', LANG: 'C.UTF-8'}}); + demand(checked.status === 0 && guestAllowed({platform: process.platform, hostname: os.hostname(), + username: account.username, uid: account.uid, virtualization: checked.stdout.trim()}), 'guest_required'); +} + +function options(args) { + const result = {}; + for (let index = 0; index < args.length; index++) { + const key = args[index]; + demand(['--execute', '--yes', '--prepare-project', '--cdp', '--project', '--output', '--timeout-seconds'].includes(key) && + !Object.hasOwn(result, key), 'arguments'); + result[key] = ['--execute', '--yes', '--prepare-project'].includes(key) ? true : args[++index]; + } + demand(result['--execute'] === true && result['--yes'] === true, 'arguments'); + if (result['--prepare-project']) demand(result['--cdp'] === undefined && result['--timeout-seconds'] === undefined, 'arguments'); + else demand(typeof result['--cdp'] === 'string' && /^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(result['--cdp']) && + Number(new URL(result['--cdp']).port) <= 65535, 'arguments'); + const seconds = Number(result['--timeout-seconds'] ?? 2400); + demand(Number.isInteger(seconds) && seconds >= 30 && seconds <= 2400, 'arguments'); + for (const key of ['--project', '--output']) demand(typeof result[key] === 'string' && + path.isAbsolute(result[key]) && path.normalize(result[key]) === result[key] && + !result[key].includes('\0') && result[key].length <= 4096, 'arguments'); + return {prepare: result['--prepare-project'] === true, cdp: result['--cdp'], + project: result['--project'], output: result['--output'], seconds}; +} + +async function privateDirectory(value) { + const info = await fs.lstat(value); + demand(await fs.realpath(value) === value && info.isDirectory() && info.uid === process.getuid() && + (info.mode & 0o7777) === 0o700, 'project_scope'); +} + +async function fixtureFile(project, name, max = 65536) { + const file = path.join(project, name), info = await fs.lstat(file); + demand(info.isFile() && !info.isSymbolicLink() && info.nlink === 1 && info.uid === process.getuid() && + info.size <= max && !(info.mode & 0o022), 'fixture_failed'); + return fs.readFile(file); +} + +function journal(bytes) { + demand(bytes.length <= 2048, 'fixture_failed'); + const lines = bytes.toString('utf8').split('\n'); + demand(lines.pop() === '' && lines.length <= 16, 'fixture_failed'); + return lines.map(line => { + const value = JSON.parse(line); + demand(value && Object.keys(value).sort().join(',') === 'action,passed' && + ['read', 'edit', 'test'].includes(value.action) && typeof value.passed === 'boolean', 'fixture_failed'); + return value; + }); +} +async function actions(project) { + try { return journal(await fixtureFile(project, ACTIONS, 2048)); } + catch (error) { if (error.code === 'ENOENT') return []; throw error; } +} +function approval(message, rows, count) { + const prefix = 'Run this command once in .?\n\n'; + const suffix = '\n\nThis permits only this request, not future commands or wider access.'; + if (typeof message !== 'string' || message.length > 16384 || !message.startsWith(prefix) || + !message.endsWith(suffix) || count >= 8) return null; + const command = message.slice(prefix.length, -suffix.length); + if (!command.includes('/workspace/editor_fixture.py') || command.includes('/opt/fixture.py')) return null; + const kind = commandKind(command.replaceAll('/workspace/editor_fixture.py', '/opt/fixture.py')); + if (!kind || kind !== 'read' && !rows.some(row => row.action === 'read' && row.passed) || + kind === 'test' && !rows.some(row => row.action === 'edit' && row.passed)) return null; + return kind; +} +function completedActions(rows) { + let read = false, edit = false, test = false; + for (const row of rows) { + if (!row.passed) { if (row.action === 'test') test = false; continue; } + if (row.action === 'read') read = true; + if (row.action === 'edit') { if (!read) return false; edit = true; test = false; } + if (row.action === 'test') { if (!edit) return false; test = true; } + } + return read && edit && test; +} + +const TASK = `Fix add(a,b) in arithmetic.py, then verify it. Read the current source first; do not guess. +Use native exec_command, workdir /workspace, shell /bin/bash, login false, tty false, max_output_tokens 1024. +Only these command forms are authorized, one at a time: +1. python3 -B /workspace/editor_fixture.py read +2. python3 -B /workspace/editor_fixture.py edit 'EXPRESSION' (replace EXPRESSION with your arithmetic expression in a and b) +3. python3 -B /workspace/editor_fixture.py test +The helper writes your proposed expression, not a predetermined repair. It accepts only arithmetic. +Read, inspect the returned source, make the minimal edit, and run the actual tests. Finish only after tests pass. +Do not run other commands, request escalation, modify helpers or tests, use network, or invent tool results.`; + +// CDP reads only the actual rendered DOM; all user input uses keyboard/mouse events. +// Selectors checked against installed VSCodium1.135.06055/1a46a584 source: +// out/vs/workbench/workbench.desktop.main.js (quick input and custom dialog widgets). +const SNAPSHOT = `(() => { + const visible = e => e && e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height > 0 && getComputedStyle(e).visibility !== 'hidden'; + const text = e => (e?.textContent || '').replaceAll('\\u00a0',' '); + const point = e => { const r=e.getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; }; + const dialogs = [...document.querySelectorAll('.monaco-dialog-box')].filter(visible).map(e=>({ + message:[text(e.querySelector('.dialog-message-text')),text(e.querySelector('.dialog-message-detail'))].filter(Boolean).join('\\n\\n'), + buttons:[...e.querySelectorAll('.dialog-buttons .monaco-button')].filter(visible).map(b=>({label:text(b).trim(),...point(b)})) })); + const q=[...document.querySelectorAll('.quick-input-widget')].find(visible); + const input=q?.querySelector('.quick-input-box input'); + const result=[...document.querySelectorAll('.monaco-editor .view-lines .view-line')].filter(visible).map(text).join('\\n'); + const errors=[...document.querySelectorAll('.notification-list-item-message')].filter(visible).map(text).some(t=>t.includes('VOLPAROSSA could not complete this operation.')); + const title=q?text(q.querySelector('.quick-input-title')):''; + return {dialogs,quick:visible(input)?{title,...point(input)}:null, + resultShown:result.includes('VOLPAROSSA — native coding turn finished')&&result.includes('Task correctness and tests are not independently verified'), + resultCommands:result.match(/Native commands observed: ([0-9]+)/)?.[1]??null,errors}; +})()`; + +class CDP { + constructor(socket) { + this.socket = socket; this.pending = new Map(); this.next = 0; + this.closed = new Promise(resolve => { this.resolveClosed = resolve; }); + socket.addEventListener('message', event => { + try { + demand(typeof event.data === 'string' && Buffer.byteLength(event.data) <= 262144, 'ui_bound'); + const message = JSON.parse(event.data), item = this.pending.get(message.id); + if (!item) return; + this.pending.delete(message.id); clearTimeout(item.timer); + if (message.error) item.reject(Error('cdp_failed')); else item.resolve(message.result); + } catch { void this.close().catch(() => {}); } + }); + socket.addEventListener('error', () => { void this.close().catch(() => {}); }); + socket.addEventListener('close', () => { this.rejectPending(); this.resolveClosed(); }); + } + call(method, params = {}) { + demand(this.socket.readyState === WebSocket.OPEN && this.pending.size < 8, 'cdp_failed'); + return new Promise((resolve, reject) => { + const id = ++this.next, timer = setTimeout(() => { this.pending.delete(id); reject(Error('cdp_failed')); }, 10000); + this.pending.set(id, {resolve, reject, timer}); this.socket.send(JSON.stringify({id, method, params})); + }); + } + async snapshot() { + const value = await this.call('Runtime.evaluate', {expression: SNAPSHOT, returnByValue: true}); + demand(!value.exceptionDetails && value.result?.value && JSON.stringify(value.result.value).length <= 32768, 'ui_bound'); + return value.result.value; + } + async key(key, code, virtual, modifiers = 0) { + const event = {key, code, windowsVirtualKeyCode: virtual, nativeVirtualKeyCode: virtual, modifiers}; + await this.call('Input.dispatchKeyEvent', {type: 'keyDown', ...event}); + await this.call('Input.dispatchKeyEvent', {type: 'keyUp', ...event}); + } + async click(point) { + demand(point && Number.isFinite(point.x) && Number.isFinite(point.y) && point.x >= 0 && point.y >= 0, 'ui_unrecognized'); + for (const type of ['mousePressed', 'mouseReleased']) await this.call('Input.dispatchMouseEvent', + {type, x: point.x, y: point.y, button: 'left', clickCount: 1}); + } + rejectPending() { + for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(Error('cdp_failed')); } + this.pending.clear(); + } + async close() { + this.rejectPending(); this.socket.close(); + let timer; + try { + await Promise.race([this.closed, new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('cleanup_unconfirmed')), 5000); + })]); + } finally { clearTimeout(timer); } + } +} + +async function connect(origin) { + const response = await fetch(origin + '/json/list', {redirect: 'error', signal: AbortSignal.timeout(5000)}); + demand(response.ok && Number(response.headers.get('content-length') ?? 0) <= 65536, 'editor_unavailable'); + const chunks = []; let size = 0; + for await (const chunk of response.body) { size += chunk.length; demand(size <= 65536, 'ui_bound'); chunks.push(chunk); } + const bytes = Buffer.concat(chunks); + const pages = JSON.parse(bytes).filter(item => item.type === 'page' && typeof item.url === 'string' && + item.url.startsWith('vscode-file://') && new URL(item.url).pathname.endsWith('/vs/code/electron-browser/workbench/workbench.html')); + demand(pages.length === 1, 'editor_unavailable'); + const target = new URL(pages[0].webSocketDebuggerUrl), base = new URL(origin); + demand(target.protocol === 'ws:' && target.hostname === '127.0.0.1' && target.port === base.port && + !target.username && !target.password, 'editor_unavailable'); + const socket = new WebSocket(target); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { socket.close(); reject(Error('cdp_failed')); }, 5000); + socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }, {once: true}); + socket.addEventListener('error', () => { clearTimeout(timer); reject(Error('cdp_failed')); }, {once: true}); + }); + const cdp = new CDP(socket); + try { await cdp.call('Page.bringToFront'); return cdp; } + catch (error) { await cdp.close(); throw error; } +} +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function until(cdp, predicate, deadline) { + while (Date.now() < deadline) { + const view = await cdp.snapshot(); demand(!view.errors, 'editor_failed'); + if (predicate(view)) return view; + await pause(200); + } + throw Error('deadline'); +} + +async function drive(cdp, project, seconds, report) { + const deadline = Date.now() + seconds * 1000; + await cdp.key('F1', 'F1', 112); + let view = await until(cdp, v => v.quick, Math.min(deadline, Date.now() + 15000)); + await cdp.click(view.quick); await cdp.key('a', 'KeyA', 65, 2); + await cdp.call('Input.insertText', {text: '>VOLPAROSSA: Run Native Coding Task (Private, Local)'}); + await pause(400); await cdp.key('Enter', 'Enter', 13); + report.phase = 'task-input'; + view = await until(cdp, v => v.quick?.title === 'VOLPAROSSA native coding task', Math.min(deadline, Date.now() + 30000)); + await cdp.click(view.quick); await cdp.call('Input.insertText', {text: TASK.replaceAll('\n', ' ')}); + await cdp.key('Enter', 'Enter', 13); + report.phase = 'consent'; + view = await until(cdp, v => v.dialogs.length > 0, Math.min(deadline, Date.now() + 15000)); + demand(view.dialogs.length === 1, 'ui_unrecognized'); + const consent = view.dialogs[0]; + demand(consent.message.startsWith(`Allow a native coding task in ${project}?\n\n`) && + consent.message.endsWith('changes are not automatically rolled back.'), 'ui_unrecognized'); + await cdp.click(consent.buttons.find(button => button.label === 'Start local coding')); + report.start_clicked = true; report.phase = 'native-turn'; + let previous = consent.message; // The just-clicked consent may still be animating away. + while (Date.now() < deadline) { + view = await cdp.snapshot(); demand(!view.errors, 'editor_failed'); + if (view.resultShown) { + report.ui_result_shown = true; + demand(/^[0-9]{1,2}$/.test(view.resultCommands), 'ui_unrecognized'); + report.native_commands_observed = Number(view.resultCommands); + return; + } + if (view.dialogs.length) { + demand(view.dialogs.length === 1, 'ui_unrecognized'); + const dialog = view.dialogs[0]; + // Wait for the already-clicked dialog to disappear; never double-approve it. + if (dialog.message !== previous) { + const kind = approval(dialog.message, await actions(project), report.approved_commands); + if (!kind) { + report.declined_commands++; + const cancel = dialog.buttons.find(button => button.label === 'Cancel'); + if (cancel) await cdp.click(cancel); else await cdp.key('Escape', 'Escape', 27); + throw Error('command_refused'); + } + await cdp.click(dialog.buttons.find(button => button.label === 'Run once')); + report.approved_commands++; previous = dialog.message; + } + } else previous = null; + await pause(200); + } + throw Error('deadline'); +} + +async function run(config) { + requireGuest(); // Before files, connections or any input into an editor. + await privateDirectory(config.project); + demand(/^editor-ui-project-[A-Za-z0-9_-]{1,32}$/.test(path.basename(config.project)), 'project_scope'); + await privateDirectory(path.dirname(config.output)); + demand(!config.output.startsWith(config.project + '/') && + !(await fs.lstat(config.output).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; })), 'output_scope'); + if (config.prepare) { + demand((await fs.readdir(config.project)).length === 0, 'project_scope'); + const prepared = spawnSync('/usr/bin/python3', ['-B', path.join(__dirname, 'editor_ui_fixture.py'), + '--prepare-project', config.project], {stdio: 'ignore', timeout: 10000, + env: {PATH: '/usr/bin:/bin', LANG: 'C.UTF-8'}}); + const report = {version: 1, kind: 'native-editor-ui-project-prepared', passed: prepared.status === 0, + phase: 'prepare', failure: prepared.status === 0 ? null : 'fixture_failed', + before_sha256: sha(ORIGINAL), model_executed: false, editor_contacted: false}; + await fs.writeFile(config.output, JSON.stringify(report) + '\n', {flag: 'wx', mode: 0o600}); + return report; + } + const report = {version: 1, kind: 'native-editor-ui-smoke', passed: false, phase: 'prepare', + failure: null, start_clicked: false, approved_commands: 0, declined_commands: 0, + native_commands_observed: 0, read: false, edit: false, test: false, independent_test_passed: false, + ui_result_shown: false, runtime_cleanup_confirmed_by_ui: false, cdp_closed: false, + before_sha256: sha(ORIGINAL), after_sha256: null, + synthetic_model: false, private_peer_execution_claimed: false, general_coding_quality_claimed: false, + guest_cleanup_owned_by_parent: true}; + let cdp; + try { + demand(JSON.stringify((await fs.readdir(config.project)).sort()) === JSON.stringify( + ['arithmetic.py', 'editor_fixture.py', 'native_coding_fixture.py'].sort()), 'fixture_failed'); + demand(sha(await fixtureFile(config.project, 'arithmetic.py', 256)) === sha(ORIGINAL), 'fixture_failed'); + for (const [name, source] of [['editor_fixture.py', 'editor_ui_fixture.py'], ['native_coding_fixture.py', 'native_coding_fixture.py']]) { + demand(sha(await fixtureFile(config.project, name)) === sha(await fs.readFile(path.join(__dirname, source))), 'fixture_failed'); + } + report.phase = 'editor-connect'; cdp = await connect(config.cdp); + await drive(cdp, config.project, config.seconds, report); + report.phase = 'independent-check'; + const rows = await actions(config.project); + report.read = rows.some(row => row.action === 'read' && row.passed); + report.edit = rows.some(row => row.action === 'edit' && row.passed); + report.test = completedActions(rows); + demand(report.test && rows.length === report.approved_commands && + report.native_commands_observed === rows.length, 'fixture_failed'); + demand(JSON.stringify((await fs.readdir(config.project)).sort()) === JSON.stringify( + [ACTIONS, 'arithmetic.py', 'editor_fixture.py', 'native_coding_fixture.py'].sort()), 'fixture_failed'); + for (const [name, source] of [['editor_fixture.py', 'editor_ui_fixture.py'], ['native_coding_fixture.py', 'native_coding_fixture.py']]) { + demand(sha(await fixtureFile(config.project, name)) === sha(await fs.readFile(path.join(__dirname, source))), 'fixture_failed'); + } + report.after_sha256 = sha(await fixtureFile(config.project, 'arithmetic.py', 256)); + demand(report.after_sha256 !== report.before_sha256, 'fixture_failed'); + const checked = spawnSync('/usr/bin/python3', ['-B', path.join(__dirname, 'editor_ui_fixture.py'), + '--verify-project', config.project], {encoding: 'utf8', timeout: 10000, maxBuffer: 4096, + env: {PATH: '/usr/bin:/bin', LANG: 'C.UTF-8'}}); + demand(checked.status === 0 && JSON.stringify(JSON.parse(checked.stdout)) === + JSON.stringify({action: 'test', passed: true, tests: 3}), 'fixture_failed'); + report.independent_test_passed = true; + // The actual extension displays this result only after awaiting runtime.close(). + report.runtime_cleanup_confirmed_by_ui = true; + report.passed = true; report.phase = 'complete'; + } catch (error) { report.failure = FAILURE.has(error.message) ? error.message : 'fixture_failed'; } + finally { + try { if (cdp) await cdp.close(); report.cdp_closed = true; } + catch { report.passed = false; report.failure = 'cleanup_unconfirmed'; } + await fs.writeFile(config.output, JSON.stringify(report) + '\n', {flag: 'wx', mode: 0o600}); + } + return report; +} + +if (require.main === module) (async () => { + const report = await run(options(process.argv.slice(2))); + console.log(JSON.stringify({kind: report.kind, passed: report.passed, phase: report.phase, failure: report.failure})); + if (!report.passed) process.exitCode = 1; +})().catch(() => { console.error('native_editor_ui_trial_unavailable'); process.exitCode = 1; }); + +// Component probes may inspect real rendered UI without starting a model task. +// There is deliberately no CLI switch bypassing the disposable-guest guard. +module.exports = {options, guestAllowed, approval, completedActions, journal, TASK, SNAPSHOT, CDP, connect}; diff --git a/tests/editor-ui.test.cjs b/tests/editor-ui.test.cjs new file mode 100644 index 0000000..b7a37e3 --- /dev/null +++ b/tests/editor-ui.test.cjs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Driver policy + real tiny fixture tests. No GUI, native runtime or model executes. +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const {spawnSync} = require('node:child_process'); +const path = require('node:path'); +const {options, guestAllowed, approval, completedActions, journal, TASK, SNAPSHOT} = require('../scripts/smoke_editor_ui.cjs'); +const root = path.resolve(__dirname, '..'); +const message = command => `Run this command once in .?\n\n${command}\n\nThis permits only this request, not future commands or wider access.`; +const read = {action: 'read', passed: true}, edit = {action: 'edit', passed: true}, passed = {action: 'test', passed: true}; + +test('UI driver requires explicit execution, loopback CDP and bounded duration; prep has no editor connection', () => { + const args = ['--execute', '--yes', '--cdp', 'http://127.0.0.1:9222', + '--project', '/private/editor-ui-project-trial', '--output', '/private/report.json']; + assert.equal(options(args).seconds, 2400); + assert.equal(options(['--prepare-project', '--execute', '--yes', '--project', '/private/editor-ui-project-trial', + '--output', '/private/prep.json']).prepare, true); + for (const bad of [args.slice(1), args.filter(value => value !== '--yes'), [...args, '--yes'], + [...args, '--timeout-seconds', '2401'], args.map(value => value === 'http://127.0.0.1:9222' ? 'http://localhost:9222' : value), + [...args, '--prepare-project'], [...args, '--host-override']]) assert.throws(() => options(bad)); +}); + +test('actual model driver cannot be enabled on the dev host by supplying a path or test flag', () => { + const guest = {platform: 'linux', hostname: 'volparossa-alpha', username: 'vpci', uid: 1000, virtualization: 'kvm'}; + assert(guestAllowed(guest)); + for (const change of [{hostname: 'desktop'}, {username: 'owner'}, {uid: 0}, {virtualization: 'none'}, + {virtualization: 'docker'}, {platform: 'darwin'}]) assert.equal(guestAllowed({...guest, ...change}), false); +}); + +test('visible approval permits only exact fixture commands and actual successful prior actions', () => { + const readCommand = 'python3 -B /workspace/editor_fixture.py read'; + assert.equal(approval(message(readCommand), [], 0), 'read'); + assert.equal(approval(message(`/bin/bash -c '${readCommand}'`), [], 0), 'read'); + assert.equal(approval(message("python3 -B /workspace/editor_fixture.py edit 'a * b'"), [read], 1), 'edit'); + assert.equal(approval(message('python3 -B /workspace/editor_fixture.py test'), [read, edit], 2), 'test'); + for (const command of ['rm -rf /workspace', readCommand + '; id', readCommand + '\ncat /etc/passwd', + 'python3 -B /opt/fixture.py read', 'python3 -B /workspace/editor_fixture.py test', + "python3 -B /workspace/editor_fixture.py edit 'a + b'", "python3 -B /workspace/editor_fixture.py edit '$(id)'"]) { + assert.equal(approval(message(command), [], 0), null); + } + assert.equal(approval(message(readCommand).replace('in .?', 'in ../?'), [], 0), null); + assert.equal(approval(message(readCommand), [], 8), null); +}); + +test('native completion requires observed read, actual edit and later passing test, not approval counts', () => { + assert(completedActions([read, edit, passed])); + assert(completedActions([read, edit, {action: 'test', passed: false}, edit, passed])); + for (const rows of [[], [read], [read, edit], [edit, passed], [read, passed], + [read, edit, passed, edit], [read, edit, {action: 'test', passed: false}]]) assert.equal(completedActions(rows), false); + assert.deepEqual(journal(Buffer.from([read, edit, passed].map(row => JSON.stringify(row)).join('\n') + '\n')), + [read, edit, passed]); + assert.throws(() => journal(Buffer.from('{"action":"read","passed":true,"payload":"private"}\n'))); + assert.throws(() => journal(Buffer.from(JSON.stringify(read)))); +}); + +test('GUI task gives no repair expression or canned result; DOM observation has no VS Code API injection', () => { + assert(TASK.includes("edit 'EXPRESSION'")); assert(!TASK.includes("edit 'a + b'")); + assert(!/acquireVsCodeApi|vscode\.|executeCommand|\.value\s*=/.test(SNAPSHOT)); + for (const selector of ['.quick-input-widget', '.dialog-message-text', '.dialog-buttons .monaco-button', + '.monaco-editor .view-lines .view-line']) assert(SNAPSHOT.includes(selector)); +}); + +test('fixture really prepares a new private project and independently rejects wrong arithmetic before accepting a supplied repair', () => { + const result = spawnSync('/usr/bin/python3', ['-B', '-c', String.raw` +import sys,tempfile,os,json,contextlib,io +from pathlib import Path +sys.path.insert(0,'scripts') +import editor_ui_fixture as ui +import native_coding_fixture as actual +with tempfile.TemporaryDirectory(prefix='editor-ui-project-') as temporary: + p=Path(temporary); p.chmod(0o700) + assert ui.project(str(p))==p + assert ui.prepare(p)==0 + assert set(x.name for x in p.iterdir())==set(ui.NAMES) + assert (p/'arithmetic.py').read_text()==actual.ORIGINAL + for name in ('editor_fixture.py','native_coding_fixture.py'): + assert (p/name).stat().st_mode & 0o777 == 0o444 + try:ui.prepare(p) + except ValueError:pass + else:raise AssertionError('overwritten fixture') + previous=Path.cwd();actual.PROJECT=p;actual.SOURCE=p/'arithmetic.py';os.chdir(p) + def action(*args): + sys.argv=['fixture',*args] + with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):return actual.main() + try: + assert action('test')==1 + assert action('read')==0 + assert action('edit','a * b')==0 + assert action('test')==1 + assert action('edit','a + b')==0 + assert action('test')==0 + assert not (p/ui.JOURNAL).exists() # independent tests are not native-action evidence + finally:os.chdir(previous) +`], {cwd: root, encoding: 'utf8', timeout: 10000}); + assert.equal(result.status, 0, result.stderr); +}); From 4263b8ba4b28e6617d2a57de33db1efcda01a828 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:01:53 +0200 Subject: [PATCH 3/3] test: wait for actual editor readiness before command palette --- scripts/smoke_editor_ui.cjs | 49 ++++++++++++++++++++++++++++++++----- tests/editor-ui.test.cjs | 23 ++++++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/scripts/smoke_editor_ui.cjs b/scripts/smoke_editor_ui.cjs index acb7dca..505ead1 100644 --- a/scripts/smoke_editor_ui.cjs +++ b/scripts/smoke_editor_ui.cjs @@ -12,7 +12,8 @@ const ORIGINAL = 'def add(a, b):\n return a - b\n'; const ACTIONS = '.editor-ui-actions.jsonl'; const FAILURE = new Set(['guest_required', 'arguments', 'project_scope', 'output_scope', 'editor_unavailable', 'cdp_failed', 'ui_unrecognized', 'ui_bound', 'deadline', - 'command_refused', 'editor_failed', 'fixture_failed', 'cleanup_unconfirmed']); + 'command_refused', 'editor_failed', 'fixture_failed', 'cleanup_unconfirmed', + 'startup_not_ready', 'palette_unavailable', 'startup_dialog']); function demand(value, reason) { if (!value) throw Error(reason); } const sha = value => createHash('sha256').update(value).digest('hex'); @@ -127,7 +128,9 @@ const SNAPSHOT = `(() => { const result=[...document.querySelectorAll('.monaco-editor .view-lines .view-line')].filter(visible).map(text).join('\\n'); const errors=[...document.querySelectorAll('.notification-list-item-message')].filter(visible).map(text).some(t=>t.includes('VOLPAROSSA could not complete this operation.')); const title=q?text(q.querySelector('.quick-input-title')):''; - return {dialogs,quick:visible(input)?{title,...point(input)}:null, + return {dialogs,quick:visible(input)?{title,palette:input.value.startsWith('>'),...point(input)}:null, + documentReady:document.readyState==='complete', + workbenchReady:!!visible(document.querySelector('.monaco-workbench'))&&!!visible(document.querySelector('.part.editor')), resultShown:result.includes('VOLPAROSSA — native coding turn finished')&&result.includes('Task correctness and tests are not independently verified'), resultCommands:result.match(/Native commands observed: ([0-9]+)/)?.[1]??null,errors}; })()`; @@ -208,6 +211,40 @@ async function connect(origin) { catch (error) { await cdp.close(); throw error; } } const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +function startupObservation() { + return {document_ready:false,workbench_ready:false,dialog_seen:false,palette_attempts:0,palette_seen:false}; +} +function startupAction(view, attempts, retryDue) { + demand(view && typeof view.documentReady === 'boolean' && typeof view.workbenchReady === 'boolean' && + Array.isArray(view.dialogs) && Number.isInteger(attempts) && attempts >= 0 && attempts <= 8, 'ui_unrecognized'); + demand(!view.errors, 'editor_failed'); + if (view.dialogs.length) throw Error('startup_dialog'); // Never dismiss or approve an unknown dialog. + if (view.quick) { + demand(attempts > 0 && view.quick.palette === true, 'ui_unrecognized'); + return 'ready'; + } + if (!view.documentReady || !view.workbenchReady || !retryDue || attempts === 8) return 'wait'; + return 'open'; +} +async function openPalette(cdp, deadline, observed) { + let retryAt = 0; + while (Date.now() < deadline) { + const view = await cdp.snapshot(); + observed.document_ready ||= view.documentReady === true; + observed.workbench_ready ||= view.workbenchReady === true; + observed.dialog_seen ||= Array.isArray(view.dialogs) && view.dialogs.length > 0; + const action = startupAction(view, observed.palette_attempts, Date.now() >= retryAt); + if (action === 'ready') { observed.palette_seen = true; return view; } + if (action === 'open') { + // A CDP page can exist before keybindings. Retry only an unobserved palette, + // never consent/tool actions, and never extend the original 15-second window. + await cdp.call('Page.bringToFront'); await cdp.key('F1', 'F1', 112); + observed.palette_attempts++; retryAt = Date.now() + 750; + } + await pause(200); // DOM polling, not an unconditional startup sleep. + } + throw Error(observed.document_ready && observed.workbench_ready ? 'palette_unavailable' : 'startup_not_ready'); +} async function until(cdp, predicate, deadline) { while (Date.now() < deadline) { const view = await cdp.snapshot(); demand(!view.errors, 'editor_failed'); @@ -219,8 +256,7 @@ async function until(cdp, predicate, deadline) { async function drive(cdp, project, seconds, report) { const deadline = Date.now() + seconds * 1000; - await cdp.key('F1', 'F1', 112); - let view = await until(cdp, v => v.quick, Math.min(deadline, Date.now() + 15000)); + let view = await openPalette(cdp, Math.min(deadline, Date.now() + 15000), report.startup); await cdp.click(view.quick); await cdp.key('a', 'KeyA', 65, 2); await cdp.call('Input.insertText', {text: '>VOLPAROSSA: Run Native Coding Task (Private, Local)'}); await pause(400); await cdp.key('Enter', 'Enter', 13); @@ -284,7 +320,7 @@ async function run(config) { await fs.writeFile(config.output, JSON.stringify(report) + '\n', {flag: 'wx', mode: 0o600}); return report; } - const report = {version: 1, kind: 'native-editor-ui-smoke', passed: false, phase: 'prepare', + const report = {version: 2, kind: 'native-editor-ui-smoke', passed: false, phase: 'prepare', startup: startupObservation(), failure: null, start_clicked: false, approved_commands: 0, declined_commands: 0, native_commands_observed: 0, read: false, edit: false, test: false, independent_test_passed: false, ui_result_shown: false, runtime_cleanup_confirmed_by_ui: false, cdp_closed: false, @@ -341,4 +377,5 @@ if (require.main === module) (async () => { // Component probes may inspect real rendered UI without starting a model task. // There is deliberately no CLI switch bypassing the disposable-guest guard. -module.exports = {options, guestAllowed, approval, completedActions, journal, TASK, SNAPSHOT, CDP, connect}; +module.exports = {options, guestAllowed, approval, completedActions, journal, TASK, SNAPSHOT, CDP, connect, + startupObservation, startupAction, openPalette}; diff --git a/tests/editor-ui.test.cjs b/tests/editor-ui.test.cjs index b7a37e3..c49ee6b 100644 --- a/tests/editor-ui.test.cjs +++ b/tests/editor-ui.test.cjs @@ -5,7 +5,8 @@ const {test} = require('node:test'); const assert = require('node:assert/strict'); const {spawnSync} = require('node:child_process'); const path = require('node:path'); -const {options, guestAllowed, approval, completedActions, journal, TASK, SNAPSHOT} = require('../scripts/smoke_editor_ui.cjs'); +const {options, guestAllowed, approval, completedActions, journal, TASK, SNAPSHOT, + startupAction, startupObservation} = require('../scripts/smoke_editor_ui.cjs'); const root = path.resolve(__dirname, '..'); const message = command => `Run this command once in .?\n\n${command}\n\nThis permits only this request, not future commands or wider access.`; const read = {action: 'read', passed: true}, edit = {action: 'edit', passed: true}, passed = {action: 'test', passed: true}; @@ -61,6 +62,26 @@ test('GUI task gives no repair expression or canned result; DOM observation has '.monaco-editor .view-lines .view-line']) assert(SNAPSHOT.includes(selector)); }); +test('startup observes actual workbench DOM before F1 and can retry a lost startup key without approving dialogs', () => { + const loading = {documentReady:false,workbenchReady:false,dialogs:[],quick:null,errors:false}; + const ready = {...loading,documentReady:true,workbenchReady:true}; + assert.deepEqual(startupObservation(), {document_ready:false,workbench_ready:false, + dialog_seen:false,palette_attempts:0,palette_seen:false}); + assert.equal(startupAction(loading, 0, true), 'wait'); + assert.equal(startupAction({...loading, documentReady:true}, 0, true), 'wait'); + assert.equal(startupAction({...loading, workbenchReady:true}, 0, true), 'wait'); + assert.equal(startupAction(ready, 0, true), 'open'); + assert.equal(startupAction(ready, 1, false), 'wait'); + assert.equal(startupAction(ready, 1, true), 'open'); // First F1 was lost, not treated as permission. + assert.equal(startupAction({...ready,quick:{palette:true}}, 2, true), 'ready'); + assert.equal(startupAction(ready, 8, true), 'wait'); // No unbounded input or larger deadline. + for (const view of [{...ready,dialogs:[{message:'unknown startup dialog'}]}, + {...ready,quick:{palette:false}}, {...ready,errors:true}]) assert.throws(() => startupAction(view, 1, true)); + assert.throws(() => startupAction({...ready,quick:{palette:true}}, 0, true)); + assert.throws(() => startupAction(ready, 9, true)); + for (const selector of ["document.readyState==='complete'", '.monaco-workbench', '.part.editor']) assert(SNAPSHOT.includes(selector)); +}); + test('fixture really prepares a new private project and independently rejects wrong arithmetic before accepting a supplied repair', () => { const result = spawnSync('/usr/bin/python3', ['-B', '-c', String.raw` import sys,tempfile,os,json,contextlib,io