From 831e4eefb9f1f5c1b3dcb1b3e74d465488deb8c5 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 18:20:42 +0200 Subject: [PATCH 001/155] fix: harden plugin lifecycle and add packaged runtime gates --- .github/workflows/addon-packaged.yml | 181 ++++++++++++++ .github/workflows/ci.yml | 5 + .github/workflows/release.yml | 11 + docs/addons/IMPLEMENTATION.md | 49 ++-- docs/addons/evidence/host-timing-linux.json | 42 ++++ scripts/addons/benchmark-host.mjs | 53 ++++ scripts/addons/host-limits.mjs | 74 ++++++ scripts/addons/packaged-smoke.mjs | 126 ++++++++++ scripts/addons/sdk-native.mjs | 2 +- src-tauri/src/addons/cleanup.rs | 17 +- src-tauri/src/addons/credentials.rs | 262 ++++++++++++++++++-- src-tauri/src/addons/lifecycle.rs | 90 +++++++ src-tauri/src/addons/manager.rs | 42 +++- src-tauri/src/addons/protocol.rs | 85 ++++++- src-tauri/src/commands/addons.rs | 8 + src/components/addons/addon-view.test.tsx | 91 +++++++ src/components/addons/addon-view.tsx | 10 +- src/components/settings/addons-settings.tsx | 33 +-- src/lib/addons/platform.test.ts | 121 +++++++++ src/lib/addons/platform.ts | 73 ++++-- src/lib/addons/types.ts | 1 + src/stores/addons-store.ts | 22 ++ 22 files changed, 1308 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/addon-packaged.yml create mode 100644 docs/addons/evidence/host-timing-linux.json create mode 100644 scripts/addons/benchmark-host.mjs create mode 100644 scripts/addons/host-limits.mjs create mode 100644 scripts/addons/packaged-smoke.mjs create mode 100644 src/components/addons/addon-view.test.tsx create mode 100644 src/lib/addons/platform.test.ts diff --git a/.github/workflows/addon-packaged.yml b/.github/workflows/addon-packaged.yml new file mode 100644 index 00000000..08f5a8a7 --- /dev/null +++ b/.github/workflows/addon-packaged.yml @@ -0,0 +1,181 @@ +name: Packaged add-on runtime +on: + workflow_dispatch: + pull_request: + paths: ['scripts/addons/packaged-smoke.mjs', 'scripts/addons/host-limits.mjs', 'scripts/addons/sdk-native.mjs', 'src-tauri/addon-host/**', 'src-tauri/tauri.conf.json', 'scripts/build-addon-host.sh', '.github/workflows/addon-packaged.yml'] +permissions: + contents: read +concurrency: + group: addon-packaged-${{ github.ref }} + cancel-in-progress: true +jobs: + bundle: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Linux system dependencies + if: matrix.os == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + build-essential \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libfuse2 \ + file + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install standalone add-on host Windows GNU target + if: runner.os == 'Windows' + run: rustup target add x86_64-pc-windows-gnu + + - name: Install add-on host C compiler + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: mingw-w64-x86_64-gcc + path-type: inherit + + - name: Select add-on host compiler + if: runner.os == 'Windows' + shell: pwsh + run: '"C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append' + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + key: addon-packaged-${{ matrix.os }} + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install frontend dependencies + run: npm ci + + - name: Build and verify independent add-on packages and release host + shell: bash + run: | + bash scripts/addons/build-examples.sh + bash scripts/build-addon-host.sh --profile release + if [ "$RUNNER_OS" = Windows ]; then + addon_host=src-tauri/binaries/codemux-addon-host-windows-x64.exe + else + addon_host=src-tauri/binaries/codemux-addon-host-linux-x64 + fi + node scripts/addons/sdk-native.mjs "$addon_host" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.2.21" + + - name: Stage claude-agent sidecar binary + shell: bash + run: | + bash scripts/build-claude-sidecar.sh --strict + TARGET="${CARGO_BUILD_TARGET:-$(rustc -vV | grep host | cut -d' ' -f2)}" + case "$TARGET" in + *windows*) DEST="src-tauri/binaries/codemux-claude-sidecar-$TARGET.exe" ;; + *) DEST="src-tauri/binaries/codemux-claude-sidecar-$TARGET" ;; + esac + if [ ! -s "$DEST" ]; then + echo "::error::build-claude-sidecar.sh did not produce a non-empty binary at $DEST" + echo "::error::Target: $TARGET" + ls -la src-tauri/binaries/ || true + exit 1 + fi + + - name: Stage agent-browser sidecar binary + shell: bash + run: | + bash scripts/copy-agent-browser.sh + TARGET="${CARGO_BUILD_TARGET:-$(rustc -vV | grep host | cut -d' ' -f2)}" + case "$TARGET" in + *windows*) DEST="src-tauri/binaries/agent-browser-$TARGET.exe" ;; + *) DEST="src-tauri/binaries/agent-browser-$TARGET" ;; + esac + if [ ! -s "$DEST" ]; then + echo "::error::copy-agent-browser.sh did not produce a non-empty binary at $DEST" + echo "::error::Target: $TARGET" + ls -la src-tauri/binaries/ || true + ls -la node_modules/agent-browser/bin/ || true + exit 1 + fi + + - name: Build codemux-remote binary (release profile) + shell: bash + run: | + bash scripts/build-codemux-remote.sh --profile release + + TARGET="${CARGO_BUILD_TARGET:-$(rustc -vV | grep host | cut -d' ' -f2)}" + case "$TARGET" in + *windows*) DEST="src-tauri/binaries/codemux-remote-$TARGET.exe" ;; + *) DEST="src-tauri/binaries/codemux-remote-$TARGET" ;; + esac + STAMP="src-tauri/binaries/.codemux-remote-$TARGET.profile" + + if [ ! -s "$DEST" ]; then + echo "::error::codemux-remote build did not produce a non-empty binary at $DEST" + exit 1 + fi + + if [ ! -f "$STAMP" ]; then + echo "::error::missing provenance stamp $STAMP — build-codemux-remote.sh did not complete" + exit 1 + fi + cat "$STAMP" + if ! grep -qx 'profile=release' "$STAMP"; then + echo "::error::codemux-remote was staged from a non-release profile — refusing to publish" + exit 1 + fi + ls -la src-tauri/binaries/codemux-remote-* + + - name: Configure git identity + shell: bash + run: | + git config --global user.email "release@codemux.dev" + git config --global user.name "Codemux Release" + + + - name: Pre-cache stable linuxdeploy + plugin-appimage + if: matrix.os == 'ubuntu-22.04' + run: | + mkdir -p ~/.cache/tauri + curl -fsSL -o ~/.cache/tauri/linuxdeploy-plugin-appimage.AppImage \ + https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-appimage-x86_64.AppImage + chmod +x ~/.cache/tauri/linuxdeploy-plugin-appimage.AppImage + ls -la ~/.cache/tauri/ + + - name: Build installers without publishing + shell: bash + env: + NO_STRIP: 'true' + run: | + if [ "$RUNNER_OS" = Windows ]; then formats=nsis; else formats=deb,appimage; fi + npm run tauri -- build --verbose --bundles "$formats" --config '{"bundle":{"createUpdaterArtifacts":false}}' + - name: Verify bundled host and runtime deadlines + run: node scripts/addons/packaged-smoke.mjs src-tauri/target/release/bundle + - uses: actions/upload-artifact@v4 + if: always() + with: + name: addon-packaged-evidence-${{ matrix.os }} + path: addon-packaged-evidence.json + if-no-files-found: warn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3820b7..bd6e1558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ on: pull_request: branches: - main + - 'codex/addons-*' # Cancel in-flight runs for the same branch when a new commit is pushed. concurrency: @@ -444,3 +445,7 @@ jobs: if [ "$RUNNER_OS" = Windows ]; then suffix=windows-x64.exe; fi export CODEMUX_TEST_ADDON_HOST="$PWD/src-tauri/binaries/codemux-addon-host-$suffix" cargo test -j 2 --locked --manifest-path src-tauri/Cargo.toml --lib addons::manager::tests::native_ -- --ignored --test-threads=2 + + - name: Windows native credential backend + if: runner.os == 'Windows' + run: cargo test -j 2 --locked --manifest-path src-tauri/Cargo.toml --lib addons::credentials::tests::native_os_backend_roundtrip_and_deletion -- --ignored --test-threads=2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcfb7a45..201c0d72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -311,6 +311,17 @@ jobs: chmod +x ~/.cache/tauri/linuxdeploy-plugin-appimage.AppImage ls -la ~/.cache/tauri/ + - name: Build installer payloads for the add-on runtime gate + shell: bash + env: + NO_STRIP: 'true' + run: | + if [ "$RUNNER_OS" = Windows ]; then formats=nsis; else formats=deb,appimage; fi + npm run tauri -- build --verbose --bundles "$formats" --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Verify packaged add-on host before publication + run: node scripts/addons/packaged-smoke.mjs src-tauri/target/release/bundle + - name: Build and release (Linux) if: matrix.os == 'ubuntu-22.04' uses: tauri-apps/tauri-action@action-v0.6.2 diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index db83dced..195fc76f 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -13,12 +13,12 @@ standalone host tests, browser mocks, or the research probe is not release appro | PR | Scope | State | | --- | --- | --- | | 1 | Contracts, independent QuickJS host, public SDK and CLI | Draft [#390](https://github.com/Zeus-Deus/codemux/pull/390); Linux and Windows GNU CI passed | -| 2 | Native manager, scoped broker, private persistence, installer foundations | Implemented; focused native verification ongoing | -| 3 | Trusted UI, palette, panel deck, controlled composer | Implemented; focused frontend checks pass | -| 4 | Settings, lifecycle/recovery, review, developer watch | Implemented; focused lifecycle verification ongoing | -| 5 | Independent examples and native resource packaging | Packages built and installed in native tests; packaged release gates pending | -| 6 | Reviewed catalog schema, validator, immutable artifact publication | Implemented with an empty revision-1 seed; no example releases published | -| 7 | Website pinned catalog, search/detail/handoff, author documentation | Implemented in separate website worktree; build and affected tests pass | +| 2 | Native manager, scoped broker, private persistence, installer foundations | Draft [#392](https://github.com/Zeus-Deus/codemux/pull/392) | +| 3 | Trusted UI, palette, panel deck, controlled composer | Draft [#393](https://github.com/Zeus-Deus/codemux/pull/393) | +| 4 | Settings, lifecycle/recovery, review, developer watch | Draft [#394](https://github.com/Zeus-Deus/codemux/pull/394) | +| 5 | Independent examples and native resource packaging | Draft [#395](https://github.com/Zeus-Deus/codemux/pull/395); packaged gates pending | +| 6 | Reviewed catalog schema, validator, immutable artifact publication | Draft [#396](https://github.com/Zeus-Deus/codemux/pull/396); empty seed, no published example releases | +| 7 | Website pinned catalog, search/detail/handoff, author documentation | Draft [website #7](https://github.com/Zeus-Deus/codemux-sitev2/pull/7); build and affected tests pass | | 8 | Release hardening and complete acceptance evidence | In progress; see unresolved gates below | Later PRs stack on their specified prerequisites. The native catalog reader and @@ -36,17 +36,27 @@ publication and Settings remain separate deliverables. parent EOF, stale/oversized IPC and hostile synchronous/asynchronous workloads. - SDK native integration passes: Preact view, batched Remote DOM updates, callback, scoped request, property removal and unmount. -- Desktop manager: 20 focused native tests pass at the rollback checkpoint. - Additional native watcher and cleanup changes are awaiting their final rerun. -- Three explicitly enabled real-child tests passed before that checkpoint: +- Desktop manager: 24 focused native tests passed after credential origin binding, + locked-service fixtures, retired-credential cleanup, and real development watch + review/cancellation. A subsequent real SQLite page-limit test passed, preserving + the original tuple/state after SQLITE_FULL. Supervision changes await final rerun. +- Three explicitly enabled real-child tests passed again after credential changes: broker scope/disposal, Project Brief installer/Git/view/composer request, and Issue Companion installer/view/recorded HTTP states/explicit draft action. These tests supply the frontend effect result; they are not stock-app GUI E2E. -- Frontend TypeScript passes. 86 affected tests passed; the subsequent renderer - keyboard test passes (5 renderer tests). Composer tests include preservation of - user input. No theme or footer customization subsystem was repurposed. +- Frontend TypeScript passes. 87 affected tests passed, plus 10 new tests for + delayed effects during disable/remove/pause/workspace/thread/failure, stale + inventory responses, update/rollback remounting and explicit retry. Composer + tests include preservation of user input. No theme or footer customization subsystem was repurposed. - Both examples build/check/pack with packed SDK/CLI tarballs. Issue Companion also builds/checks/packs outside the app checkout using those packages only. +- Actual Linux Secret Service round-trip and deletion passed using a synthetic + token in a random installation namespace. A public GitHub /zen request through + the production HTTP broker passed with pinned DNS/TLS and no credential/data. +- Standalone release-host timing: 20 samples each of five hostile workloads; + maximum 1010.4 ms, activation-loop p95 1007.2 ms on Ryzen 5 7600 / Linux. + See [machine-readable evidence](evidence/host-timing-linux.json). These are + standalone activation measurements, not GUI or packaged-platform evidence. - Website: 5 catalog tests and production build pass. The existing waitlist module needs a Resend key at build time; verification supplied a synthetic non-secret placeholder, without calling its email route. @@ -78,6 +88,16 @@ publication and Settings remain separate deliverables. durable journal. Failed activation preserves the original rollback snapshot. - OS keyring operations keep their serialization lock inside the blocking task; cancelling IPC cannot let an uninstall race ahead of an unfinished OS write. + Keys bind credential ID to exact origin, so an update cannot redirect a saved + bearer token. Uninstall queues all indexed historical keys, including credentials + removed from later manifests; locked-service cleanup remains retryable. +- A full 2 s watchdog left no time for reaping. The response/queue watchdog now + fires at 1.5 s, reserving cleanup within the 2 s ceiling. A cancelled instance + remains registered if the OS delays termination; no new generation can overlap. + The final host handle owns a cancellation lease, including failed initialization. +- Frontend stop actions fence effects synchronously until inventory refresh + completes. Older inventory responses cannot restore stale enabled state. + Native generation changes remount views; failed releases still need explicit Retry. ## Unresolved release gates @@ -88,8 +108,9 @@ publication and Settings remain separate deliverables. - Complete failure-injection matrix: disk-full boundaries, every journal/crash point, unexpected child exit/ignored shutdown, concurrent update/uninstall, and delayed workspace/thread/composer races through the actual desktop. -- OS credential backend integration, locked/missing service, durable deletion - retry; complete public HTTPS/TLS/DNS-rebinding/decoded-body/timeout fixtures. +- Windows OS credential backend execution (added to desktop CI); complete native + HTTPS/DNS-rebinding/decoded-body/timeout fixtures. Linux OS round-trip and + recorded locked-service/deletion-retry tests are passing. - Complete keyboard/screen-reader, light/dark, small-window, chat-GUI-off and core pane restoration evidence; measured runtime fault and UI budgets with hardware/workload details. diff --git a/docs/addons/evidence/host-timing-linux.json b/docs/addons/evidence/host-timing-linux.json new file mode 100644 index 00000000..639592ef --- /dev/null +++ b/docs/addons/evidence/host-timing-linux.json @@ -0,0 +1,42 @@ +{ + "profile": "release", + "packaged": false, + "os": "linux 7.2.5-3-omarchy", + "cpu": "AMD Ryzen 5 7600 6-Core Processor", + "logicalCpus": 12, + "sha256": "7f8079496c2a1e669d16c7f257a2ad31689f03203c47e2fe4bcd9094165eff87", + "samplesPerWorkload": 20, + "deadlineMs": 2000, + "workloads": [ + { + "workload": "while(true){}", + "minMs": 1002.5891979999979, + "p95Ms": 1007.2198379999991, + "maxMs": 1010.3881799999999 + }, + { + "workload": "function f(){f()} f()", + "minMs": 1.9228750000002037, + "p95Ms": 2.757315999999264, + "maxMs": 2.8019269999967946 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "minMs": 1.6830640000007406, + "p95Ms": 2.847866999996768, + "maxMs": 3.255298000000039 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "minMs": 1002.2652180000005, + "p95Ms": 1003.2288259999987, + "maxMs": 1003.2643519999983 + }, + { + "workload": "throw Error('synthetic')", + "minMs": 1.6045930000036606, + "p95Ms": 2.2726150000016787, + "maxMs": 2.5721560000019963 + } + ] +} diff --git a/scripts/addons/benchmark-host.mjs b/scripts/addons/benchmark-host.mjs new file mode 100644 index 00000000..cbbc515f --- /dev/null +++ b/scripts/addons/benchmark-host.mjs @@ -0,0 +1,53 @@ +// Reproducible standalone measurements. Passing does not establish packaged GUI performance. +import { fault } from "./host-limits.mjs"; +import { cpus, release, platform, tmpdir } from "node:os"; +import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { resolve, join } from "node:path"; +const host = resolve(process.argv[2]); +const manifest = JSON.parse( + await readFile("src-tauri/addon-protocol/fixtures/hello.json", "utf8"), +); +const directory = await mkdtemp(join(tmpdir(), "cmx-limits-")); +const workloads = [ + "while(true){}", + "function f(){f()} f()", + "new ArrayBuffer(128*1024*1024)", + "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "throw Error('synthetic')", +]; +const evidence = { + packaged: false, + os: platform() + " " + release(), + cpu: cpus()[0].model, + logicalCpus: cpus().length, + sha256: createHash("sha256") + .update(await readFile(host)) + .digest("hex"), + samplesPerWorkload: 20, + deadlineMs: 2000, + workloads: [], +}; +try { + for (const workload of workloads) { + const samples = []; + for (let i = 0; i < 20; i++) + samples.push(await fault(host, workload, manifest, directory)); + samples.sort((a, b) => a - b); + evidence.workloads.push({ + workload, + minMs: samples[0], + p95Ms: samples[18], + maxMs: samples[19], + }); + } + await writeFile( + "addon-host-evidence.json", + JSON.stringify(evidence, null, 2) + "\n", + ); + console.log( + "PASS: 100 native-host fault samples; see addon-host-evidence.json for hardware and timing", + ); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/scripts/addons/host-limits.mjs b/scripts/addons/host-limits.mjs new file mode 100644 index 00000000..f338efd6 --- /dev/null +++ b/scripts/addons/host-limits.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { performance } from "node:perf_hooks"; +// Real host, empty inherited environment, a fixed hostile test workload. +export async function fault(host, source, manifest, directory) { + const started = performance.now(); + const child = spawn(host, [], { + cwd: directory, + env: + process.platform === "win32" + ? { SystemRoot: process.env.SystemRoot } + : {}, + stdio: ["pipe", "pipe", "ignore"], + }); + child.stdin.on("error", () => {}); + let accepted = false, + timer; + const closed = new Promise((resolve) => child.once("close", resolve)); + const stopped = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", () => { + if (!accepted) + reject(new Error("Host exited before accepting the test workload")); + else resolve(performance.now() - started); + }); + timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("Host exceeded the 2 s fault deadline")); + }, 2000); + }); + const lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => { + const frame = JSON.parse(line); + if (frame.result?.accepted) { + accepted = true; + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + generation: "limits-smoke", + id: 2, + method: "activate", + params: {}, + }) + "\n", + ); + } + }); + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + generation: "limits-smoke", + id: 1, + method: "initialize", + params: { + protocolVersion: 1, + manifest, + source: `__codemuxRegister({},()=>()=>{${source}});`, + final: true, + }, + }) + "\n", + ); + try { + const elapsed = await stopped; + assert.ok(elapsed < 2000); + return elapsed; + } finally { + clearTimeout(timer); + lines.close(); + child.stdin.destroy(); + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + await closed; + } +} diff --git a/scripts/addons/packaged-smoke.mjs b/scripts/addons/packaged-smoke.mjs new file mode 100644 index 00000000..cf2a1e56 --- /dev/null +++ b/scripts/addons/packaged-smoke.mjs @@ -0,0 +1,126 @@ +// Inspect actual installer payloads, then execute only their bundled native host. +// This is a packaged-runtime gate, not a substitute for desktop GUI acceptance. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { cpus, platform, release, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fault } from "./host-limits.mjs"; +const bundleRoot = resolve( + process.argv[2] ?? "src-tauri/target/release/bundle", +); +const root = await mkdtemp(join(tmpdir(), "cmx-addon-bundle-")); +const hostName = + process.platform === "win32" + ? "codemux-addon-host-windows-x64.exe" + : "codemux-addon-host-linux-x64"; +const expected = createHash("sha256") + .update(await readFile(`src-tauri/binaries/${hostName}`)) + .digest("hex"); +assert.match( + await readFile(`src-tauri/binaries/.${hostName}.profile`, "utf8"), + /^profile=release$/m, +); +const manifest = JSON.parse( + await readFile("src-tauri/addon-protocol/fixtures/hello.json", "utf8"), +); +const evidence = { + platform: platform(), + os: release(), + cpu: cpus()[0]?.model, + logicalCpus: cpus().length, + hostSha256: expected, + bundles: [], +}; +async function files(path) { + const found = []; + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isDirectory()) found.push(...(await files(child))); + else if (entry.isFile()) found.push(child); + } + return found; +} +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: "inherit", + timeout: 180000, + ...options, + }); + if (result.error) throw result.error; + assert.equal(result.status, 0, `${command} failed`); +} + +try { + const bundles = await files(bundleRoot); + const formats = + process.platform === "win32" ? [".exe"] : [".deb", ".AppImage"]; + for (const format of formats) { + const matches = bundles.filter((file) => file.endsWith(format)); + assert.equal(matches.length, 1, `Expected exactly one ${format} installer`); + const unpack = join(root, format.slice(1)); + const { mkdir } = await import("node:fs/promises"); + await mkdir(unpack); + if (format === ".deb") run("dpkg-deb", ["-x", matches[0], unpack]); + else if (format === ".AppImage") + run(matches[0], ["--appimage-extract"], { cwd: unpack, stdio: "ignore" }); + else run(matches[0], ["/S", `/D=${unpack}`]); + const hosts = (await files(unpack)).filter((file) => + file.endsWith(hostName), + ); + assert.equal( + hosts.length, + 1, + "Installer must contain exactly one platform host", + ); + const host = hosts[0]; + assert.equal( + createHash("sha256") + .update(await readFile(host)) + .digest("hex"), + expected, + "Installer host differs from release build", + ); + run(process.execPath, ["scripts/addons/sdk-native.mjs", host]); + const workloads = [ + "while(true){}", + "function f(){f()} f()", + "new ArrayBuffer(128*1024*1024)", + "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "throw Error('fixture')", + ]; + const faults = []; + for (const workload of workloads) + faults.push({ + workload, + elapsedMs: await fault(host, workload, manifest, root), + }); + evidence.bundles.push({ + format, + hostPath: host.slice(unpack.length + 1), + faults, + }); + if (format === ".exe") { + const uninstall = (await files(unpack)).find((file) => + /uninstall\.exe$/i.test(file), + ); + assert.ok(uninstall, "NSIS must include its uninstaller"); + run(uninstall, ["/S"]); + } + } + await writeFile( + "addon-packaged-evidence.json", + JSON.stringify(evidence, null, 2) + "\n", + ); + console.log( + "PASS: installer payload digests, clean-environment SDK callbacks and packaged hostile-runtime deadlines", + ); +} finally { + await rm(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 500, + }); +} diff --git a/scripts/addons/sdk-native.mjs b/scripts/addons/sdk-native.mjs index 3382e787..0bcd3660 100644 --- a/scripts/addons/sdk-native.mjs +++ b/scripts/addons/sdk-native.mjs @@ -10,7 +10,7 @@ import assert from 'node:assert/strict'; const dir=await mkdtemp(join(tmpdir(),'codemux-sdk-native-')); const entry=resolve('packages/plugin-sdk'); await build({stdin:{contents:"import plugin from './tests/fixture.tsx'; import {register} from './src/runtime.ts'; register(plugin);",resolveDir:entry},bundle:true,format:'iife',platform:'neutral',target:'es2020',jsx:'automatic',jsxImportSource:'preact',outfile:join(dir,'plugin.js')}); -const binary=process.argv[2]??resolve('src-tauri/addon-host/target/debug/codemux-addon-host'+(process.platform==='win32'?'.exe':'')); +const binary=resolve(process.argv[2]??('src-tauri/addon-host/target/debug/codemux-addon-host'+(process.platform==='win32'?'.exe':''))); const child=spawn(binary,[],{cwd:dir,env:process.platform==='win32'?{SystemRoot:process.env.SystemRoot}:{},stdio:['pipe','pipe','pipe']}); let diagnostics='';child.stderr.on('data',data=>diagnostics+=data); const frames=[];let resolveWait;const lines=createInterface({input:child.stdout});lines.on('line',line=>{frames.push(JSON.parse(line));resolveWait?.();}); diff --git a/src-tauri/src/addons/cleanup.rs b/src-tauri/src/addons/cleanup.rs index d435d7de..88a3db84 100644 --- a/src-tauri/src/addons/cleanup.rs +++ b/src-tauri/src/addons/cleanup.rs @@ -24,10 +24,21 @@ pub(super) fn queue( installation: &Installation, keep_data: bool, ) -> Result<()> { - for credential in &installation.manifest.credentials { + // Include credentials from every accepted release, even declarations that + // were removed in later versions. The index contains no secret values. + tx.execute("INSERT OR IGNORE INTO cleanup(installation,credential) SELECT installation,id FROM credential_entries WHERE installation=?1",[&installation.installation_id]).map_err(|_|unavailable())?; + for credential in installation.manifest.credentials.iter().chain( + installation + .previous + .iter() + .flat_map(|p| p.manifest.credentials.iter()), + ) { tx.execute( "INSERT OR IGNORE INTO cleanup(installation,credential) VALUES(?1,?2)", - rusqlite::params![installation.installation_id, credential.id], + rusqlite::params![ + installation.installation_id, + super::credentials::Credentials::key(&credential.id, &credential.origin) + ], ) .map_err(|_| unavailable())?; } @@ -202,7 +213,7 @@ impl Manager { }; for (installation, credential) in credentials { if uuid::Uuid::parse_str(&installation).is_err() - || !codemux_addon_protocol::manifest::local_id(&credential) + || !codemux_addon_protocol::catalog::hex(&credential, 64) { return Err(unavailable()); } diff --git a/src-tauri/src/addons/credentials.rs b/src-tauri/src/addons/credentials.rs index 6c821b09..9f06921d 100644 --- a/src-tauri/src/addons/credentials.rs +++ b/src-tauri/src/addons/credentials.rs @@ -5,12 +5,49 @@ use std::{ sync::Arc, }; use tokio::sync::Mutex; -#[derive(Default, Clone)] +#[derive(Clone)] pub struct Credentials { + backend: Arc, session: Arc>>, serial: Arc>, configured: Arc>>, } +trait CredentialStore: Send + Sync { + fn set(&self, service: &str, id: &str, value: &str) -> Result<()>; + fn get(&self, service: &str, id: &str) -> Result>; + fn delete(&self, service: &str, id: &str) -> Result<()>; +} +struct OsCredentialStore; +impl CredentialStore for OsCredentialStore { + fn set(&self, service: &str, id: &str, value: &str) -> Result<()> { + keyring::Entry::new(service, id) + .and_then(|e| e.set_password(value)) + .map_err(|_| unavailable()) + } + fn get(&self, service: &str, id: &str) -> Result> { + match keyring::Entry::new(service, id).and_then(|e| e.get_password()) { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err(unavailable()), + } + } + fn delete(&self, service: &str, id: &str) -> Result<()> { + match keyring::Entry::new(service, id).and_then(|e| e.delete_credential()) { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err(unavailable()), + } + } +} +impl Default for Credentials { + fn default() -> Self { + Self { + backend: Arc::new(OsCredentialStore), + session: Default::default(), + serial: Default::default(), + configured: Default::default(), + } + } +} fn unavailable() -> ProtocolError { ProtocolError::new( ErrorCode::CredentialRequired, @@ -18,6 +55,15 @@ fn unavailable() -> ProtocolError { ) } impl Credentials { + /// An update cannot reuse a named bearer token at a different origin. + /// Old origin keys remain indexed for rollback and complete uninstall cleanup. + pub fn key(id: &str, origin: &str) -> String { + use sha2::{Digest, Sha256}; + format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&(id, origin)).unwrap()) + ) + } pub fn with_configured(entries: Vec<(String, String)>) -> Self { Self { configured: Arc::new(Mutex::new(entries.into_iter().collect())), @@ -44,13 +90,12 @@ impl Credentials { let id = id.to_owned(); let session = self.session.clone(); let configured = self.configured.clone(); + let backend = self.backend.clone(); tokio::task::spawn_blocking(move || { // The OS operation cannot be cancelled. Keep serialization and the // corresponding memory update inside this task even if IPC closes. let _lock = lock; - keyring::Entry::new(&service, &id) - .and_then(|entry| entry.set_password(&secret)) - .map_err(|_| unavailable())?; + backend.set(&service, &id, &secret)?; session.blocking_lock().remove(&key); configured.blocking_lock().insert(key); Ok(()) @@ -78,18 +123,15 @@ impl Credentials { } let service = format!("{}.addons.{}", crate::APP_DIR_NAME, installation); let id = id.to_owned(); - match tokio::task::spawn_blocking(move || { + let backend = self.backend.clone(); + tokio::task::spawn_blocking(move || { let _lock = lock; - keyring::Entry::new(&service, &id).and_then(|entry| entry.get_password()) + backend.get(&service, &id) }) .await .map_err(|_| unavailable())? - { - Ok(value) => Ok(Some(value)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(_) => Err(unavailable()), - } } + pub async fn delete(&self, installation: &str, id: &str) -> Result<()> { let lock = self.serial.clone().lock_owned().await; self.session @@ -102,22 +144,208 @@ impl Credentials { .remove(&(installation.into(), id.into())); let service = format!("{}.addons.{}", crate::APP_DIR_NAME, installation); let id = id.to_owned(); - match tokio::task::spawn_blocking(move || { + let backend = self.backend.clone(); + tokio::task::spawn_blocking(move || { let _lock = lock; - keyring::Entry::new(&service, &id).and_then(|entry| entry.delete_credential()) + backend.delete(&service, &id) }) .await .map_err(|_| unavailable())? - { - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(_) => Err(unavailable()), - } } } #[cfg(test)] mod tests { use super::*; + #[derive(Default)] + struct RecordedStore { + locked: std::sync::atomic::AtomicBool, + values: std::sync::Mutex>, + } + impl CredentialStore for RecordedStore { + fn set(&self, service: &str, id: &str, value: &str) -> Result<()> { + if self.locked.load(std::sync::atomic::Ordering::SeqCst) { + return Err(unavailable()); + } + self.values + .lock() + .unwrap() + .insert((service.into(), id.into()), value.into()); + Ok(()) + } + fn get(&self, service: &str, id: &str) -> Result> { + if self.locked.load(std::sync::atomic::Ordering::SeqCst) { + return Err(unavailable()); + } + Ok(self + .values + .lock() + .unwrap() + .get(&(service.into(), id.into())) + .cloned()) + } + fn delete(&self, service: &str, id: &str) -> Result<()> { + if self.locked.load(std::sync::atomic::Ordering::SeqCst) { + return Err(unavailable()); + } + self.values + .lock() + .unwrap() + .remove(&(service.into(), id.into())); + Ok(()) + } + } + #[tokio::test] + async fn locked_store_requires_explicit_session_fallback_and_removed_keys_stay_inaccessible() { + let backend = Arc::new(RecordedStore::default()); + let key = Credentials::key("token", "https://api.example.com"); + let credentials = Credentials { + backend: backend.clone(), + ..Credentials::default() + }; + credentials + .set("installation", &key, "synthetic-persisted".into(), false) + .await + .unwrap(); + backend + .locked + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(credentials.get("installation", &key).await.is_err()); + assert!(credentials + .set("installation", &key, "must-not-save".into(), false) + .await + .is_err()); + credentials + .set("installation", &key, "explicit-session".into(), true) + .await + .unwrap(); + assert_eq!( + credentials + .get("installation", &key) + .await + .unwrap() + .as_deref(), + Some("explicit-session") + ); + assert!(credentials.delete("installation", &key).await.is_err()); + assert_eq!(credentials.get("installation", &key).await.unwrap(), None); + backend + .locked + .store(false, std::sync::atomic::Ordering::SeqCst); + credentials.delete("installation", &key).await.unwrap(); + assert!(backend.values.lock().unwrap().is_empty()); + } + #[tokio::test] + async fn uninstall_retries_credentials_removed_from_later_manifests_even_when_data_is_kept() { + use super::super::{lifecycle::Reviews, manager::Manager, package::fixture_archive}; + let root = tempfile::tempdir().unwrap(); + let mut manager = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + let backend = Arc::new(RecordedStore::default()); + Arc::get_mut(&mut manager).unwrap().credentials = Credentials { + backend: backend.clone(), + ..Credentials::default() + }; + let path = root.path().join("fixture.cmxaddon"); + std::fs::write(&path, fixture_archive()).unwrap(); + let reviews = Reviews::default(); + let review = reviews.prepare_local(&manager, &path).unwrap(); + let installed = reviews + .accept(&manager, &review.token, false, false) + .await + .unwrap(); + let retired = Credentials::key("retired-token", "https://old.example.com"); + manager + .record_credential(&installed.installation_id, &retired) + .unwrap(); + manager + .credentials + .set( + &installed.installation_id, + &retired, + "synthetic-retired".into(), + false, + ) + .await + .unwrap(); + backend + .locked + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(!manager + .remove(&installed.manifest.id, true) + .await + .unwrap() + .is_empty()); + assert!(manager.list().unwrap().is_empty()); + assert_eq!( + manager + .credentials + .get(&installed.installation_id, &retired) + .await + .unwrap(), + None + ); + drop(manager); + let mut reopened = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + assert!(!reopened.cleanup_warnings().unwrap().is_empty()); + Arc::get_mut(&mut reopened).unwrap().credentials = Credentials { + backend: backend.clone(), + ..Credentials::default() + }; + backend + .locked + .store(false, std::sync::atomic::Ordering::SeqCst); + assert!(reopened.retry_cleanup().await.unwrap().is_empty()); + assert!(backend.values.lock().unwrap().is_empty()); + } + #[tokio::test] + #[ignore = "Requires the actual unlocked OS credential service; writes only a random test namespace"] + async fn native_os_backend_roundtrip_and_deletion() { + let installation = uuid::Uuid::new_v4().to_string(); + let key = Credentials::key("test-token", "https://fixture.example.com"); + let credentials = Credentials::default(); + credentials + .set( + &installation, + &key, + "synthetic-native-test-token".into(), + false, + ) + .await + .unwrap(); + let read = credentials.get(&installation, &key).await; + // Always attempt deletion before asserting the round-trip result. + let deleted = credentials.delete(&installation, &key).await; + assert_eq!( + read.unwrap().as_deref(), + Some("synthetic-native-test-token") + ); + deleted.unwrap(); + let reopened = Credentials::with_configured(vec![(installation.clone(), key.clone())]); + assert_eq!(reopened.get(&installation, &key).await.unwrap(), None); + } + #[tokio::test] + async fn named_credentials_do_not_transfer_to_another_origin_or_installation() { + let credentials = Credentials::default(); + let first = Credentials::key("token", "https://first.example"); + let second = Credentials::key("token", "https://second.example"); + credentials + .set("installation", &first, "synthetic-token".into(), true) + .await + .unwrap(); + assert_eq!( + credentials + .get("installation", &first) + .await + .unwrap() + .as_deref(), + Some("synthetic-token") + ); + assert_eq!( + credentials.get("installation", &second).await.unwrap(), + None + ); + assert_eq!(credentials.get("replacement", &first).await.unwrap(), None); + } #[tokio::test] async fn optional_unset_credentials_do_not_require_an_os_service() { let credentials = Credentials::default(); diff --git a/src-tauri/src/addons/lifecycle.rs b/src-tauri/src/addons/lifecycle.rs index b55a40b9..b45d6b52 100644 --- a/src-tauri/src/addons/lifecycle.rs +++ b/src-tauri/src/addons/lifecycle.rs @@ -780,6 +780,96 @@ mod tests { } } #[tokio::test] + async fn sqlite_full_during_update_preserves_the_previous_release_and_state() { + let root = tempfile::tempdir().unwrap(); + let manager = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + let reviews = Reviews::default(); + let package = Package::parse(super::super::package::fixture_archive(), None).unwrap(); + let first = reviews + .prepare( + &manager, + package, + Source::Local { + identity: Uuid::new_v4().to_string(), + }, + ) + .unwrap(); + let old = reviews + .accept(&manager, &first.token, false, false) + .await + .unwrap(); + let state = manager + .root + .join("state") + .join(&old.installation_id) + .join(&old.data_generation) + .join("state.sqlite"); + Storage::open(&state) + .unwrap() + .set("global", "value", &serde_json::json!("original")) + .unwrap(); + let package = Package::parse(super::super::package::fixture_archive(), None).unwrap(); + let mut files = package.files; + let mut manifest = package.manifest; + manifest.version = "2.0.0".into(); + manifest + .settings + .push(codemux_addon_protocol::manifest::Setting::String { + id: "large-default".into(), + label: "Fixture".into(), + default: "x".repeat(4096), + }); + files.insert( + "manifest.json".into(), + serde_json::to_vec(&manifest).unwrap(), + ); + let mut tar = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (name, bytes) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, name, bytes.as_slice()) + .unwrap(); + } + let candidate = Package::parse(tar.into_inner().unwrap().finish().unwrap(), None).unwrap(); + let review = reviews + .prepare(&manager, candidate, old.source.clone()) + .unwrap(); + { + let db = manager.registry.lock().unwrap(); + let pages: u64 = db.query_row("PRAGMA page_count", [], |r| r.get(0)).unwrap(); + // SQLite now returns the real SQLITE_FULL error when this larger + // record requires another page. No production fault bypass exists. + db.pragma_update(None, "max_page_count", pages).unwrap(); + } + let error = reviews + .accept(&manager, &review.token, false, false) + .await + .err() + .expect("update should hit SQLITE_FULL"); + assert_eq!(error.data.code, ErrorCode::StorageUnavailable); + let restored = manager.installation(&old.manifest.id).unwrap(); + assert_eq!(restored.digest, old.digest); + assert_eq!(restored.data_generation, old.data_generation); + assert_eq!( + Storage::open(&state) + .unwrap() + .get("global", "value") + .unwrap(), + serde_json::json!("original") + ); + drop(manager); + let reopened = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + assert_eq!( + reopened.installation(&old.manifest.id).unwrap().digest, + old.digest + ); + } + #[tokio::test] async fn rollback_copies_matching_data_and_preserves_snapshot_on_activation_failure() { let root = tempfile::tempdir().unwrap(); let manager = diff --git a/src-tauri/src/addons/manager.rs b/src-tauri/src/addons/manager.rs index 69ba95d8..d4623b35 100644 --- a/src-tauri/src/addons/manager.rs +++ b/src-tauri/src/addons/manager.rs @@ -164,7 +164,7 @@ pub struct Manager { pub credentials: Credentials, pub contexts: Mutex, pub(super) catalog_serial: Mutex<()>, - running: Mutex>>, + running: Arc>>>, operations: Mutex>>>, effects: Mutex>, pub events: broadcast::Sender, @@ -209,7 +209,7 @@ impl Manager { .map_err(storage_error)?; if entries.iter().any(|(installation, id)| { uuid::Uuid::parse_str(installation).is_err() - || !codemux_addon_protocol::manifest::local_id(id) + || !codemux_addon_protocol::catalog::hex(id, 64) }) { return Err(ProtocolError::invalid("Invalid credential index")); } @@ -229,7 +229,7 @@ impl Manager { credentials: Credentials::with_configured(configured_credentials), contexts: Mutex::new(Contexts::default()), catalog_serial: Mutex::new(()), - running: Mutex::new(HashMap::new()), + running: Arc::new(Mutex::new(HashMap::new())), operations: Mutex::new(HashMap::new()), effects: Mutex::new(HashMap::new()), events, @@ -635,7 +635,7 @@ impl Manager { Some(running) => { let stopped = running.stopped.clone(); drop(hosts); - stopped.cancelled().await; + let _ = tokio::time::timeout(Duration::from_secs(2), stopped.cancelled()).await; return; } None => None, @@ -656,7 +656,32 @@ impl Manager { generation: running.generation().into(), message: failure.clone().unwrap_or_else(|| "Plugin stopped".into()), }); - running.host.stop().await; + if !running.host.stop().await { + // A kernel-level termination delay must never make room for a + // second generation. Keep the cancelled instance registered + // until the supervisor confirms actual reaping. + if let Ok(mut installation) = self.installation(id) { + installation.status = Status::FailedDisabled; + installation.failure = + Some("The plugin process has not exited; it remains quarantined".into()); + let _ = self.save(&installation); + } + let hosts = self.running.clone(); + let instance = running.clone(); + let id = id.to_owned(); + tokio::spawn(async move { + instance.host.reaped().await; + let mut hosts = hosts.lock().await; + if hosts + .get(&id) + .is_some_and(|r| r.generation() == instance.generation()) + { + hosts.remove(&id); + } + instance.stopped.cancel(); + }); + return; + } } let _ = self.registry.lock().unwrap().execute( "DELETE FROM metadata WHERE key=?1", @@ -954,7 +979,12 @@ impl Manager { ) })?; let credential = if let Some(id) = &grant.credential { - self.credentials.get(&running.installation_id, id).await? + self.credentials + .get( + &running.installation_id, + &Credentials::key(id, &grant.origin), + ) + .await? } else { None }; diff --git a/src-tauri/src/addons/protocol.rs b/src-tauri/src/addons/protocol.rs index 7f8eb9b7..61290b99 100644 --- a/src-tauri/src/addons/protocol.rs +++ b/src-tauri/src/addons/protocol.rs @@ -25,8 +25,17 @@ pub enum Event { struct Outbound { bytes: Vec, } +struct HostLease(CancellationToken); +impl Drop for HostLease { + fn drop(&mut self) { + self.0.cancel(); + } +} #[derive(Clone)] pub struct Host { + // The supervising task owns the child, not this lease. Losing the last + // caller (including a cancelled/failed initialization) must reap the child. + _lease: Arc, pub generation: String, writer: mpsc::Sender, cancel: CancellationToken, @@ -76,6 +85,7 @@ impl Host { let generation = uuid::Uuid::new_v4().to_string(); let progress = Arc::new(Mutex::new(HashMap::new())); let host = Self { + _lease: Arc::new(HostLease(cancel.clone())), generation: generation.clone(), writer, cancel: cancel.clone(), @@ -98,7 +108,7 @@ impl Host { let mut system = sysinfo::System::new(); let pid = child.id().map(sysinfo::Pid::from_u32); let result:Result<()>=async{loop{ - let next_deadline = progress.lock().await.values().min().map(|time| *time + Duration::from_secs(2)); + let next_deadline = progress.lock().await.values().min().map(|time| *time + Duration::from_millis(1500)); let watchdog = async { match next_deadline { Some(deadline) => tokio::time::sleep_until(deadline.into()).await, None => std::future::pending::<()>().await } }; tokio::select!{ biased; @@ -117,17 +127,21 @@ impl Host { if let Some(id)=message.params.as_ref().and_then(|p|p["requestId"].as_u64()){progress.lock().await.remove(&id);} } // Bounded backpressure; never discard a patch and continue a corrupt tree. - tokio::select!{_ = cancel.cancelled()=>return Ok(()),result=tokio::time::timeout(Duration::from_secs(2),events.send(Event::Message(message)))=>{result.map_err(|_|ProtocolError::new(ErrorCode::ResourceLimit,"Plugin event queue overflow"))?.map_err(|_|ProtocolError::new(ErrorCode::PluginStopped,"Plugin receiver closed"))?;}} + tokio::select!{_ = cancel.cancelled()=>return Ok(()),result=tokio::time::timeout(Duration::from_millis(1500),events.send(Event::Message(message)))=>{result.map_err(|_|ProtocolError::new(ErrorCode::ResourceLimit,"Plugin event queue overflow"))?.map_err(|_|ProtocolError::new(ErrorCode::PluginStopped,"Plugin receiver closed"))?;}} } } }}.await; cancel.cancel(); write_task.abort(); let _ = write_task.await; - reap(&mut child).await; + // Revoke broker authority even if the kernel delays termination. + // Closing a full event queue also makes the manager stop after it + // drains the already-bounded messages; it cannot wait on reaping. if let Err(error) = result { let _ = events.try_send(Event::Stopped(error)); } + drop(events); + reap(&mut child).await; done.cancel(); }); if source.len() > limits::BUNDLE { @@ -192,19 +206,24 @@ impl Host { )); } bytes.push(b'\n'); - tokio::select! {_ = self.cancel.cancelled()=>Err(ProtocolError::new(ErrorCode::PluginStopped,"Plugin is stopped")),result=tokio::time::timeout(Duration::from_secs(2),self.writer.send(Outbound{bytes}))=>result.map_err(|_|ProtocolError::new(ErrorCode::Timeout,"Plugin input queue timed out"))?.map_err(|_|ProtocolError::new(ErrorCode::PluginStopped,"Plugin input closed"))} + tokio::select! {_ = self.cancel.cancelled()=>Err(ProtocolError::new(ErrorCode::PluginStopped,"Plugin is stopped")),result=tokio::time::timeout(Duration::from_millis(1500),self.writer.send(Outbound{bytes}))=>result.map_err(|_|ProtocolError::new(ErrorCode::Timeout,"Plugin input queue timed out"))?.map_err(|_|ProtocolError::new(ErrorCode::PluginStopped,"Plugin input closed"))} } - pub async fn stop(&self) { + pub async fn stop(&self) -> bool { let finished = tokio::time::timeout(Duration::from_millis(500), async { let _ = self.send("deactivate", json!({})).await; self.done.cancelled().await; }) .await; if finished.is_ok() { - return; + return true; } self.cancel.cancel(); - let _ = tokio::time::timeout(Duration::from_millis(1500), self.done.cancelled()).await; + tokio::time::timeout(Duration::from_millis(1500), self.done.cancelled()) + .await + .is_ok() + } + pub async fn reaped(&self) { + self.done.cancelled().await; } pub fn revoke(&self) { self.cancel.cancel(); @@ -244,3 +263,55 @@ async fn read_frame( } } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + fn unresponsive(root: &Path) -> std::path::PathBuf { + let path = root.join("unresponsive-host"); + // exec replaces the shell: the supervisor owns the only process. + std::fs::write(&path, "#!/bin/sh\nexec /bin/sleep 60\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + path + } + #[tokio::test] + async fn unresponsive_child_is_reaped_inside_the_fault_and_shutdown_deadlines() { + let root = tempfile::tempdir().unwrap(); + let executable = unresponsive(root.path()); + let manifest = Manifest::parse( + include_bytes!("../../addon-protocol/fixtures/hello.json"), + None, + ) + .unwrap(); + let (host, mut events) = Host::spawn(&executable, &manifest, "").await.unwrap(); + let start = Instant::now(); + host.send("activate", json!({})).await.unwrap(); + let event = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .expect("fault must be contained within 2 s"); + assert!(matches!(event, Some(Event::Stopped(_)))); + host.reaped().await; + assert!(start.elapsed() < Duration::from_secs(2)); + let (host, _events) = Host::spawn(&executable, &manifest, "").await.unwrap(); + let start = Instant::now(); + assert!(host.stop().await); + assert!(start.elapsed() < Duration::from_secs(2)); + } + #[tokio::test] + async fn dropped_initialization_cancels_supervision_and_reaps_the_owned_child() { + let root = tempfile::tempdir().unwrap(); + let executable = unresponsive(root.path()); + let manifest = Manifest::parse( + include_bytes!("../../addon-protocol/fixtures/hello.json"), + None, + ) + .unwrap(); + let (host, _events) = Host::spawn(&executable, &manifest, "").await.unwrap(); + let reaped = host.done.clone(); + drop(host); + tokio::time::timeout(Duration::from_secs(2), reaped.cancelled()) + .await + .expect("last host handle must not leak a process"); + } +} diff --git a/src-tauri/src/commands/addons.rs b/src-tauri/src/commands/addons.rs index cb8f15dd..3a885714 100644 --- a/src-tauri/src/commands/addons.rs +++ b/src-tauri/src/commands/addons.rs @@ -394,6 +394,14 @@ pub async fn addon_credential_set( "Credential was not declared", )); } + let origin = &installation + .manifest + .credentials + .iter() + .find(|c| c.id == credential_id) + .unwrap() + .origin; + let credential_id = super::super::addons::credentials::Credentials::key(&credential_id, origin); // Persist the host-owned index before the non-cancellable OS write. If // this IPC task is dropped, uninstall/restart can still find the credential. if !session_only { diff --git a/src/components/addons/addon-view.test.tsx b/src/components/addons/addon-view.test.tsx new file mode 100644 index 00000000..a9278cd3 --- /dev/null +++ b/src/components/addons/addon-view.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +vi.mock("@/lib/addons/bridge", () => ({ + addonInvoke: vi.fn().mockResolvedValue(null), + mountAddon: vi.fn(), +})); +import { AddonView } from "./addon-view"; +import { addonInvoke, mountAddon } from "@/lib/addons/bridge"; +import { useAddonsStore } from "@/stores/addons-store"; +import type { AddonInstallation } from "@/lib/addons/types"; +const installed = { + installationId: "installation", + manifest: { id: "test.plugin", name: "Fixture" }, + digest: "first", + dataGeneration: "data-1", + desiredEnabled: true, + status: "enabled-running", + source: { kind: "local", identity: "fixture" }, + failure: null, + previous: null, +} as AddonInstallation; +beforeEach(() => { + vi.clearAllMocks(); + let generation = 0; + vi.mocked(mountAddon).mockImplementation(async () => ({ + generation: `generation-${++generation}`, + viewId: `view-${generation}`, + })); + useAddonsStore.setState({ + installed: [installed], + ready: true, + paused: false, + trees: {}, + failures: {}, + hostEpochs: {}, + revoking: {}, + }); +}); +afterEach(cleanup); +describe("view lifecycle across native generations", () => { + it("remounts after an accepted update and after restoration of the previous release", async () => { + render(); + await waitFor(() => expect(mountAddon).toHaveBeenCalledTimes(1)); + await act(async () => { + useAddonsStore.setState({ + installed: [ + { ...installed, digest: "second", dataGeneration: "data-2" }, + ], + }); + }); + await waitFor(() => expect(mountAddon).toHaveBeenCalledTimes(2)); + expect(addonInvoke).toHaveBeenCalledWith( + "addon_unmount", + expect.objectContaining({ generation: "generation-1" }), + ); + await act(async () => { + useAddonsStore.setState({ + installed: [installed], + hostEpochs: { "test.plugin": 1 }, + }); + }); + await waitFor(() => expect(mountAddon).toHaveBeenCalledTimes(3)); + expect(addonInvoke).toHaveBeenCalledWith( + "addon_unmount", + expect.objectContaining({ generation: "generation-2" }), + ); + }); + it("keeps a failed release inert until explicit retry changes its native status", async () => { + render(); + await waitFor(() => expect(mountAddon).toHaveBeenCalledTimes(1)); + await act(async () => { + useAddonsStore.setState({ + installed: [ + { + ...installed, + status: "failed-disabled", + failure: "Synthetic failure", + }, + ], + hostEpochs: { "test.plugin": 1 }, + }); + }); + expect(mountAddon).toHaveBeenCalledTimes(1); + await act(async () => { + useAddonsStore.setState({ + installed: [{ ...installed, status: "enabled-idle" }], + }); + }); + await waitFor(() => expect(mountAddon).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/components/addons/addon-view.tsx b/src/components/addons/addon-view.tsx index af619799..50fbfe2a 100644 --- a/src/components/addons/addon-view.tsx +++ b/src/components/addons/addon-view.tsx @@ -4,6 +4,7 @@ import { addonInvoke, mountAddon } from "@/lib/addons/bridge"; import { composerForWorkspace } from "@/lib/addons/composer-registry"; import { addonMessage, + addonEnabled, type AddonMount, type AddonNode, } from "@/lib/addons/types"; @@ -23,9 +24,11 @@ export function AddonView({ }) { const ready = useAddonsStore((s) => s.ready); const revision = useAddonsStore((s) => s.contextRevision); + const hostEpoch = useAddonsStore((s) => s.hostEpochs[id] ?? 0); const installation = useAddonsStore((s) => s.installed.find((i) => i.manifest.id === id), ); + const canMount = !!installation && addonEnabled(installation); const [mounted, setMounted] = useState(null); const [error, setError] = useState(null); const tree = useAddonsStore((s) => @@ -39,7 +42,7 @@ export function AddonView({ useEffect(() => { setMounted(null); setError(null); - if (!ready || installation?.status === "failed-disabled") return; + if (!ready || !canMount) return; let closed = false; let target: AddonMount | null = null; const unmount = () => { @@ -83,7 +86,10 @@ export function AddonView({ composerId, ready, revision, - installation?.status === "failed-disabled", + canMount, + hostEpoch, + installation?.digest, + installation?.dataGeneration, ]); useEffect(() => { if (tree) diff --git a/src/components/settings/addons-settings.tsx b/src/components/settings/addons-settings.tsx index cf3fbd55..12fd9c6a 100644 --- a/src/components/settings/addons-settings.tsx +++ b/src/components/settings/addons-settings.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/dialog"; import { isRemoteClient } from "@/components/remote/is-remote-client"; import { AddonCatalog } from "@/components/addons/addon-catalog"; -import { useAddonsStore } from "@/stores/addons-store"; +import { beginAddonRevocation, useAddonsStore } from "@/stores/addons-store"; import { addonInvoke } from "@/lib/addons/bridge"; import { refreshAddons } from "@/lib/addons/platform"; import { @@ -300,7 +300,6 @@ export function AddonsSettings() { const showReview = (value: AddonReview) => { setRestoreData(false); setReplace(false); - setRestoreData(false); setEnable(true); setReview(value); }; @@ -313,7 +312,8 @@ export function AddonsSettings() { useAddonsStore.setState({ developmentReview: null }); } }, [state.developmentReview]); - const perform = async (action: () => Promise) => { + const perform = async (action: () => Promise, revoke?: string) => { + const release = revoke ? beginAddonRevocation(revoke) : undefined; setBusy(true); setError(""); try { @@ -322,6 +322,7 @@ export function AddonsSettings() { setError(addonMessage(cause)); } finally { await refreshAddons(); + release?.(); setBusy(false); } }; @@ -349,8 +350,10 @@ export function AddonsSettings() { variant="outline" disabled={busy} onClick={() => { - void perform(() => - addonInvoke(state.paused ? "addon_resume" : "addon_pause_all"), + void perform( + () => + addonInvoke(state.paused ? "addon_resume" : "addon_pause_all"), + state.paused ? undefined : "*", ); }} > @@ -541,14 +544,16 @@ export function AddonsSettings() { variant="outline" disabled={busy} onClick={() => - void perform(() => - addonInvoke( - item.desiredEnabled && - item.status !== "failed-disabled" - ? "addon_disable" - : "addon_enable", - { id: item.manifest.id }, - ), + void perform( + () => + addonInvoke( + item.desiredEnabled && + item.status !== "failed-disabled" + ? "addon_disable" + : "addon_enable", + { id: item.manifest.id }, + ), + item.desiredEnabled ? item.manifest.id : undefined, ) } > @@ -820,7 +825,7 @@ export function AddonsSettings() { }); setRemove(null); if (warnings.length) setError(warnings.join(" ")); - }) + }, remove!.manifest.id) } > Remove add-on diff --git a/src/lib/addons/platform.test.ts b/src/lib/addons/platform.test.ts new file mode 100644 index 00000000..dabc1031 --- /dev/null +++ b/src/lib/addons/platform.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("./bridge", () => ({ + addonInvoke: vi.fn(), + addonInventory: vi.fn(), + subscribeAddons: vi.fn(), +})); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +vi.mock("sonner", () => ({ toast: { info: vi.fn(), error: vi.fn() } })); +import { addonInvoke, addonInventory } from "./bridge"; +import { applyAddonEffect, refreshAddons } from "./platform"; +import { registerAddonComposer } from "./composer-registry"; +import { + beginAddonRevocation, + clearAddonContext, + useAddonsStore, +} from "@/stores/addons-store"; +import { useAppStore } from "@/stores/app-store"; +import { useFeatureFlags } from "@/stores/feature-flags"; +import type { AppStateSnapshot } from "@/tauri/types"; +import type { AddonEvent, AddonInstallation, AddonInventory } from "./types"; +const installation = { + installationId: "installation", + manifest: { id: "test.plugin", name: "Fixture" }, + desiredEnabled: true, + status: "enabled-running", +} as AddonInstallation; +const event: Extract = { + type: "effect", + requestId: "request", + generation: "generation", + pluginId: "test.plugin", + operation: "composer.appendText", + params: { workspaceId: "workspace", composerId: "composer", text: "brief" }, +}; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +let unregister: () => void; +const append = vi.fn(() => 1); +beforeEach(() => { + vi.resetAllMocks(); + useAppStore.setState({ + appState: { + active_workspace_id: "workspace", + workspaces: [{ workspace_id: "workspace" }], + } as AppStateSnapshot, + pendingActiveWorkspaceId: null, + }); + useFeatureFlags.setState({ enableAgentChat: true }); + useAddonsStore.setState({ + installed: [installation], + paused: false, + ready: true, + failures: {}, + revoking: {}, + error: null, + }); + unregister = registerAddonComposer("composer", { + workspaceId: "workspace", + append, + }); + vi.mocked(addonInvoke).mockResolvedValue(null); +}); +afterEach(() => unregister()); +describe("delayed native effect completion", () => { + it.each(["disable", "remove", "pause", "workspace", "thread", "failure"])( + "does not insert after %s while a claim is delayed", + async (action) => { + const claim = deferred(); + vi.mocked(addonInvoke).mockImplementation((command) => + command === "addon_effect_claim" + ? claim.promise + : Promise.resolve(null), + ); + const pending = applyAddonEffect(event); + let release: (() => void) | undefined; + if (action === "disable" || action === "remove") + release = beginAddonRevocation("test.plugin"); + if (action === "pause") release = beginAddonRevocation("*"); + if (action === "workspace") clearAddonContext(); + if (action === "thread") unregister(); + if (action === "failure") + useAddonsStore.setState({ failures: { generation: "Child stopped" } }); + claim.resolve(null); + await pending; + expect(append).not.toHaveBeenCalled(); + expect(addonInvoke).toHaveBeenLastCalledWith( + "addon_effect_result", + expect.objectContaining({ error: expect.any(Object) }), + ); + release?.(); + }, + ); + it("applies an authorized current effect once and reports its result", async () => { + await applyAddonEffect(event); + expect(append).toHaveBeenCalledExactlyOnceWith("brief"); + expect(addonInvoke).toHaveBeenLastCalledWith( + "addon_effect_result", + expect.objectContaining({ error: null, value: 1 }), + ); + }); + it("does not let an old inventory response restore an enabled installation", async () => { + const old = deferred(), + current = deferred(); + vi.mocked(addonInventory) + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(current.promise); + const first = refreshAddons(), + second = refreshAddons(); + current.resolve({ installed: [], paused: true, error: null }); + await second; + old.resolve({ installed: [installation], paused: false, error: null }); + await first; + expect(useAddonsStore.getState().paused).toBe(true); + expect(useAddonsStore.getState().installed).toEqual([]); + }); +}); diff --git a/src/lib/addons/platform.ts b/src/lib/addons/platform.ts index 909b5ab7..8508ceb7 100644 --- a/src/lib/addons/platform.ts +++ b/src/lib/addons/platform.ts @@ -31,32 +31,47 @@ export function activeAddonWorkspace(): string | null { ? id : null; } -export async function refreshAddons() { - try { - const inventory = await addonInventory(); - useAddonsStore.setState({ ...inventory, loaded: true }); - const enabled = new Set( - inventory.paused - ? [] - : inventory.installed - .filter((i) => addonEnabled(i) || i.status === "failed-disabled") - .map((i) => i.manifest.id), - ); - const ui = useUIStore.getState(); - for (const [workspace, panes] of Object.entries(ui.rightPanelPanes)) - for (const pane of panes) { - if (pane.startsWith("addon:") && !enabled.has(pane.split(":")[1])) - ui.closeRightPanelPane(workspace, pane); - } - } catch (error) { - useAddonsStore.setState({ - loaded: true, - error: addonMessage(error), - paused: true, - }); - } +let inventoryRevision = 0; +let latestInventory: Promise = Promise.resolve(); +export function refreshAddons(): Promise { + const revision = ++inventoryRevision; + const request = (async () => { + try { + const inventory = await addonInventory(); + if (revision !== inventoryRevision) return; + useAddonsStore.setState({ ...inventory, loaded: true }); + const enabled = new Set( + inventory.paused + ? [] + : inventory.installed + .filter((i) => addonEnabled(i) || i.status === "failed-disabled") + .map((i) => i.manifest.id), + ); + const ui = useUIStore.getState(); + for (const [workspace, panes] of Object.entries(ui.rightPanelPanes)) + for (const pane of panes) { + if (pane.startsWith("addon:") && !enabled.has(pane.split(":")[1])) + ui.closeRightPanelPane(workspace, pane); + } + } catch (error) { + if (revision !== inventoryRevision) return; + useAddonsStore.setState({ + loaded: true, + error: addonMessage(error), + paused: true, + }); + } + })(); + const complete: Promise = request.then(() => + latestInventory === complete ? undefined : latestInventory, + ); + latestInventory = complete; + return complete; } -async function effect(event: Extract) { + +export async function applyAddonEffect( + event: Extract, +) { let value: unknown = null; let error = null; try { @@ -71,6 +86,8 @@ async function effect(event: Extract) { ); if ( state.paused || + state.revoking["*"] || + state.revoking[event.pluginId] || !installation || !addonEnabled(installation) || state.failures[event.generation] @@ -151,7 +168,7 @@ function receive(event: AddonEvent) { if (event.type === "development-error") toast.error("Development reload failed", { description: event.message }); if (event.type === "inventory") void refreshAddons(); - if (event.type === "effect") void effect(event); + if (event.type === "effect") void applyAddonEffect(event); if (event.type === "tree") useAddonsStore.setState((state) => state.failures[event.generation] || !state.ready @@ -165,6 +182,10 @@ function receive(event: AddonEvent) { ); if (event.type === "stopped") useAddonsStore.setState((state) => ({ + hostEpochs: { + ...state.hostEpochs, + [event.pluginId]: (state.hostEpochs[event.pluginId] ?? 0) + 1, + }, failures: Object.fromEntries([ ...Object.entries(state.failures).slice(-63), [event.generation, event.message], diff --git a/src/lib/addons/types.ts b/src/lib/addons/types.ts index aa42c21f..78d89aa3 100644 --- a/src/lib/addons/types.ts +++ b/src/lib/addons/types.ts @@ -51,6 +51,7 @@ export interface AddonInstallation { manifest: AddonManifest; source: AddonSource; digest: string; + dataGeneration: string; desiredEnabled: boolean; status: | "installed-disabled" diff --git a/src/stores/addons-store.ts b/src/stores/addons-store.ts index 1d81d395..6e2072e6 100644 --- a/src/stores/addons-store.ts +++ b/src/stores/addons-store.ts @@ -6,6 +6,8 @@ import type { } from "@/lib/addons/types"; interface AddonsState extends AddonInventory { developmentReview: AddonReview | null; + revoking: Record; + hostEpochs: Record; loaded: boolean; ready: boolean; contextRevision: number; @@ -20,6 +22,8 @@ interface AddonsState extends AddonInventory { } export const useAddonsStore = create(() => ({ developmentReview: null, + revoking: {}, + hostEpochs: {}, paused: false, installed: [], error: null, @@ -40,3 +44,21 @@ export function clearAddonContext() { contextRevision: s.contextRevision + 1, })); } + +/** Fence frontend effects synchronously when the user starts a stop operation. */ +export function beginAddonRevocation(id: string) { + useAddonsStore.setState((s) => ({ + revoking: { ...s.revoking, [id]: (s.revoking[id] ?? 0) + 1 }, + })); + let released = false; + return () => { + if (released) return; + released = true; + useAddonsStore.setState((s) => { + const revoking = { ...s.revoking }; + if ((revoking[id] ?? 0) <= 1) delete revoking[id]; + else revoking[id] -= 1; + return { revoking }; + }); + }; +} From 7e55603fef051b2840777e1d806e23439304e7c1 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 19:00:51 +0200 Subject: [PATCH 002/155] fix: isolate plugin git reads and verify native failure boundaries --- .github/workflows/addon-packaged.yml | 7 +- docs/addons/IMPLEMENTATION.md | 64 ++- docs/addons/evidence/packaged-windows.json | 35 ++ .../addons/evidence/settings-chat-gui-off.png | Bin 0 -> 34670 bytes docs/addons/evidence/settings-small-dark.png | Bin 0 -> 34229 bytes docs/addons/evidence/settings-small-light.png | Bin 0 -> 34444 bytes scripts/addons/elf-provenance.mjs | 173 +++++++ scripts/addons/elf-provenance.test.mjs | 52 +++ scripts/addons/packaged-smoke.mjs | 35 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/addon-catalog/Cargo.lock | 2 + src-tauri/addon-catalog/Cargo.toml | 3 + src-tauri/src/addons/git.rs | 423 ++++++++++++++++-- src-tauri/src/addons/http.rs | 70 ++- src-tauri/src/addons/http_native_tests.rs | 266 +++++++++++ src-tauri/src/addons/lifecycle.rs | 158 +++++++ .../src/addons/test-fixtures/http/README.md | 12 + .../src/addons/test-fixtures/http/ca.der | Bin 0 -> 815 bytes .../addons/test-fixtures/http/server-key.der | Bin 0 -> 1218 bytes .../src/addons/test-fixtures/http/server.der | Bin 0 -> 874 bytes src/components/addons/addon-renderer.tsx | 4 +- .../settings/addons-settings.test.tsx | 56 +++ src/components/settings/addons-settings.tsx | 40 +- src/lib/addons/use-addon-composer-adapter.ts | 6 +- 25 files changed, 1325 insertions(+), 83 deletions(-) create mode 100644 docs/addons/evidence/packaged-windows.json create mode 100644 docs/addons/evidence/settings-chat-gui-off.png create mode 100644 docs/addons/evidence/settings-small-dark.png create mode 100644 docs/addons/evidence/settings-small-light.png create mode 100644 scripts/addons/elf-provenance.mjs create mode 100644 scripts/addons/elf-provenance.test.mjs create mode 100644 src-tauri/src/addons/http_native_tests.rs create mode 100644 src-tauri/src/addons/test-fixtures/http/README.md create mode 100644 src-tauri/src/addons/test-fixtures/http/ca.der create mode 100644 src-tauri/src/addons/test-fixtures/http/server-key.der create mode 100644 src-tauri/src/addons/test-fixtures/http/server.der create mode 100644 src/components/settings/addons-settings.test.tsx diff --git a/.github/workflows/addon-packaged.yml b/.github/workflows/addon-packaged.yml index 08f5a8a7..224601bc 100644 --- a/.github/workflows/addon-packaged.yml +++ b/.github/workflows/addon-packaged.yml @@ -2,7 +2,7 @@ name: Packaged add-on runtime on: workflow_dispatch: pull_request: - paths: ['scripts/addons/packaged-smoke.mjs', 'scripts/addons/host-limits.mjs', 'scripts/addons/sdk-native.mjs', 'src-tauri/addon-host/**', 'src-tauri/tauri.conf.json', 'scripts/build-addon-host.sh', '.github/workflows/addon-packaged.yml'] + paths: ['scripts/addons/packaged-smoke.mjs', 'scripts/addons/elf-provenance.mjs', 'scripts/addons/host-limits.mjs', 'scripts/addons/sdk-native.mjs', 'src-tauri/addon-host/**', 'src-tauri/tauri.conf.json', 'scripts/build-addon-host.sh', '.github/workflows/addon-packaged.yml'] permissions: contents: read concurrency: @@ -59,6 +59,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri -> target + cache-on-failure: true key: addon-packaged-${{ matrix.os }} - name: Setup Node @@ -82,6 +83,10 @@ jobs: fi node scripts/addons/sdk-native.mjs "$addon_host" + - name: Verify AppImage provenance checks + if: runner.os == 'Linux' + run: node --test scripts/addons/elf-provenance.test.mjs + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index 195fc76f..82032c23 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -19,7 +19,7 @@ standalone host tests, browser mocks, or the research probe is not release appro | 5 | Independent examples and native resource packaging | Draft [#395](https://github.com/Zeus-Deus/codemux/pull/395); packaged gates pending | | 6 | Reviewed catalog schema, validator, immutable artifact publication | Draft [#396](https://github.com/Zeus-Deus/codemux/pull/396); empty seed, no published example releases | | 7 | Website pinned catalog, search/detail/handoff, author documentation | Draft [website #7](https://github.com/Zeus-Deus/codemux-sitev2/pull/7); build and affected tests pass | -| 8 | Release hardening and complete acceptance evidence | In progress; see unresolved gates below | +| 8 | Release hardening and complete acceptance evidence | Draft [#397](https://github.com/Zeus-Deus/codemux/pull/397); see unresolved gates below | Later PRs stack on their specified prerequisites. The native catalog reader and transactional installer are included in the manager foundation because its grant, @@ -36,23 +36,42 @@ publication and Settings remain separate deliverables. parent EOF, stale/oversized IPC and hostile synchronous/asynchronous workloads. - SDK native integration passes: Preact view, batched Remote DOM updates, callback, scoped request, property removal and unmount. -- Desktop manager: 24 focused native tests passed after credential origin binding, - locked-service fixtures, retired-credential cleanup, and real development watch - review/cancellation. A subsequent real SQLite page-limit test passed, preserving - the original tuple/state after SQLITE_FULL. Supervision changes await final rerun. -- Three explicitly enabled real-child tests passed again after credential changes: +- Desktop manager: 36 focused native tests pass, including repository filter + isolation, dropped/unresponsive child supervision, real SQLite page-limit + failure, and recovery after interruption at eight durable update transitions. + The interruption matrix preserves the old tuple before completion and the new + tuple after the atomic completion marker; matching private data is checked. +- Three explicitly enabled real-child tests passed again after Git and supervision changes: broker scope/disposal, Project Brief installer/Git/view/composer request, and Issue Companion installer/view/recorded HTTP states/explicit draft action. These tests supply the frontend effect result; they are not stock-app GUI E2E. - Frontend TypeScript passes. 87 affected tests passed, plus 10 new tests for delayed effects during disable/remove/pause/workspace/thread/failure, stale inventory responses, update/rollback remounting and explicit retry. Composer - tests include preservation of user input. No theme or footer customization subsystem was repurposed. + tests include preservation of user input. Six repository theme/UUID checks and + a new Settings dialog Escape/focus regression test also pass. No theme or footer + customization subsystem was repurposed. - Both examples build/check/pack with packed SDK/CLI tarballs. Issue Companion also builds/checks/packs outside the app checkout using those packages only. - Actual Linux Secret Service round-trip and deletion passed using a synthetic token in a random installation namespace. A public GitHub /zen request through the production HTTP broker passed with pinned DNS/TLS and no credential/data. +- Five native loopback TLS tests pass through the real HTTP client: pinned + hostname/credential request, trust and hostname rejection, mixed/private DNS + rejection, redirect/encoding/declared/streamed body limits, timeout/cancellation, + concurrency release and private rolling quotas. The local fixture CA is trusted + only by the test's client; no system trust or real credentials are used. +- Hosted desktop CI passed on Linux and Windows, including Windows native OS + keyring and the installed independent packages. Initial frontend CI identified + semantic-color and UUID-helper violations; both are corrected and checked. +- Windows NSIS installation, exact bundled-host digest, clean-environment SDK + callbacks and five hostile workloads passed in [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35368002067). + Maximum measured fault latency was 1011.9 ms on the 4-vCPU AMD EPYC runner. + See [Windows payload evidence](evidence/packaged-windows.json), collected from + commit `68e07cd1`; this does not establish desktop GUI behavior. +- Linux deb/AppImage build succeeded. The deb payload passed SDK and hostile-host + checks. AppImage provenance initially failed because linuxdeploy patches ELF + RPATH; the corrected semantic provenance check awaits the next packaged run. - Standalone release-host timing: 20 samples each of five hostile workloads; maximum 1010.4 ms, activation-loop p95 1007.2 ms on Ryzen 5 7600 / Linux. See [machine-readable evidence](evidence/host-timing-linux.json). These are @@ -62,7 +81,10 @@ publication and Settings remain separate deliverables. non-secret placeholder, without calling its email route. - Browser evidence: Settings empty/paused/offline states; desktop catalog; mobile catalog at 390px without horizontal overflow; synthetic detail fixture - and exact-version install-link UI. Fixtures are removed from the catalog seed. + and exact-version install-link UI. Add-on Settings works at 800×600 in light + and dark themes, remains accessible with chat GUI off, and preserves keyboard + focus when Escape closes its install dialog. These remain browser-preview + checks, not native screen-reader/desktop E2E. Fixtures are removed from the seed. ## Evidence-backed implementation clarifications @@ -95,6 +117,21 @@ publication and Settings remain separate deliverables. fires at 1.5 s, reserving cleanup within the 2 s ceiling. A cancelled instance remains registered if the OS delays termination; no new generation can overlap. The final host handle owns a cancellation lease, including failed initialization. +- A native fixture proved that ordinary `git status` executes repository clean + filters despite disabled hooks/fsmonitor. The bounded runner now discovers inert + metadata, snapshots the index/refs into a private Git directory, and copies only + safe status/tracking configuration. No filter, hook, include, remote URL, promisor, + or external helper configuration reaches the status child. The whole operation + retains a 5 s deadline; index snapshots are bounded to 128 MiB. Linked/split-index, + detached and unborn repositories are covered. A workspace below the repository + root is rejected rather than expanding authority outside its authorized root. + Status compares file bytes without custom filter transformations; repositories + whose filters transform content can therefore report different changed files. +- linuxdeploy rewrites an AppImage host's ELF RPATH, so its packaged bytes cannot + equal the staged release digest. AppImages instead require identical program + sections and symbol/dynamic-link semantics, allowing only the `$ORIGIN` RPATH + relocation. Tests accept a real patchelf rewrite and reject changed program + data or added dependencies. Deb and NSIS retain exact SHA-256 comparison. - Frontend stop actions fence effects synchronously until inventory refresh completes. Older inventory responses cannot restore stale enabled state. Native generation changes remount views; failed releases still need explicit Retry. @@ -103,14 +140,15 @@ publication and Settings remain separate deliverables. - Stock built-app E2E for both examples, including real frontend/native IPC, actual controlled draft insertion and core UI operation during hostile plugins. -- Linux AppImage/deb and Windows NSIS installation/launch and bundled-host smoke - evidence, with no dependency on a development directory or compiler. +- Corrected Linux AppImage provenance/smoke and latest-commit packaged rerun. + Windows NSIS install/uninstall and bundled-host behavior passed; stock desktop + GUI launch and integration still require separate evidence. - Complete failure-injection matrix: disk-full boundaries, every journal/crash point, unexpected child exit/ignored shutdown, concurrent update/uninstall, and delayed workspace/thread/composer races through the actual desktop. -- Windows OS credential backend execution (added to desktop CI); complete native - HTTPS/DNS-rebinding/decoded-body/timeout fixtures. Linux OS round-trip and - recorded locked-service/deletion-retry tests are passing. +- Hosted rerun of the new native TLS and recovery tests on Windows. Native OS + credential checks pass on both platforms; installed-app cancellation races + and external network behavior remain part of desktop E2E. - Complete keyboard/screen-reader, light/dark, small-window, chat-GUI-off and core pane restoration evidence; measured runtime fault and UI budgets with hardware/workload details. diff --git a/docs/addons/evidence/packaged-windows.json b/docs/addons/evidence/packaged-windows.json new file mode 100644 index 00000000..5e758dcb --- /dev/null +++ b/docs/addons/evidence/packaged-windows.json @@ -0,0 +1,35 @@ +{ + "platform": "win32", + "os": "10.0.26100", + "cpu": "AMD EPYC 9V74 80-Core Processor ", + "logicalCpus": 4, + "hostSha256": "e52ddcb7d3d5c982ab033c9a90a97a13b9ca5b341e1699037cc0bd602eda661a", + "bundles": [ + { + "format": ".exe", + "hostPath": "binaries\\codemux-addon-host-windows-x64.exe", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1011.8606999999993 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 10.281199999999444 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 8.343700000001263 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1009.1594999999998 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 9.226599999999962 + } + ] + } + ] +} diff --git a/docs/addons/evidence/settings-chat-gui-off.png b/docs/addons/evidence/settings-chat-gui-off.png new file mode 100644 index 0000000000000000000000000000000000000000..74aafadce8237a92714f59b81ed1bb38adba6427 GIT binary patch literal 34670 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

6K-Jb&Sg`QpMo)U0yb^k_d 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

ucBWX!f>>NZnx_)F*c(2-Jm2@z5% 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 I_}AqB0Z-|*3;+NC literal 0 HcmV?d00001 diff --git a/docs/addons/evidence/settings-small-dark.png b/docs/addons/evidence/settings-small-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..b3be98114ab39749a141a4ed792468ccecba455e GIT binary patch literal 34229 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 YRzU%mM#k`Y{Q_J{>HL@3_-p$A0cTRkzyJUM literal 0 HcmV?d00001 diff --git a/docs/addons/evidence/settings-small-light.png b/docs/addons/evidence/settings-small-light.png new file mode 100644 index 0000000000000000000000000000000000000000..93b7bb5bef048afb5113038d4ea567dc5b2e9f9d GIT binary patch literal 34444 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%0AqHQCmOvS`i_Qymb&lrfgWG4V9&4D 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 { + const value = bytes.readBigUInt64LE(offset); + assert.ok(value <= BigInt(Number.MAX_SAFE_INTEGER)); + return Number(value); + }; + const slice = (offset, size) => { + assert.ok( + offset >= 0 && size >= 0 && offset + size <= bytes.length, + "Invalid ELF section bounds", + ); + return bytes.subarray(offset, offset + size); + }; + const table = number(40), + count = bytes.readUInt16LE(60), + stride = bytes.readUInt16LE(58); + assert.ok(count > 0 && stride >= 64); + slice(table, count * stride); + const raw = Array.from({ length: count }, (_, index) => { + const at = table + index * stride; + return { + nameOffset: bytes.readUInt32LE(at), + type: bytes.readUInt32LE(at + 4), + flags: number(at + 8), + address: number(at + 16), + offset: number(at + 24), + size: number(at + 32), + link: bytes.readUInt32LE(at + 40), + }; + }); + const names = raw[bytes.readUInt16LE(62)]; + assert.ok(names); + const string = (data, at) => { + assert.ok(at < data.length); + const end = data.indexOf(0, at); + assert.ok(end >= at); + return data.subarray(at, end).toString("utf8"); + }; + const strings = slice(names.offset, names.size); + const sections = new Map( + raw + .slice(1) + .map((section) => [ + string(strings, section.nameOffset), + { + ...section, + data: + section.type === 8 + ? Buffer.alloc(0) + : slice(section.offset, section.size), + }, + ]), + ); + const symbols = (section) => { + const table = raw[section.link]; + const names = slice(table.offset, table.size); + const result = []; + assert.equal(section.size % 24, 0); + for (let at = 0; at < section.size; at += 24) { + const data = section.data; + const index = data.readUInt16LE(at + 6); + const location = Number(data.readBigUInt64LE(at + 8)); + const target = index > 0 && index < 0xff00 ? raw[index] : undefined; + result.push([ + string(names, data.readUInt32LE(at)), + data[at + 4], + data[at + 5], + target ? string(strings, target.nameOffset) : index, + section.type === 2 + ? location + : target + ? location - target.address + : location, + data.readBigUInt64LE(at + 16).toString(), + ]); + } + return result; + }; + const dynamic = sections.get(".dynamic"), + dynstr = sections.get(".dynstr"); + assert.ok(dynamic && dynstr); + const entries = [], + paths = []; + // Dynamic entries whose values point at sections rather than being lengths, + // flags or string offsets. Address relocation is normalized to section+offset. + const pointers = new Set([ + 3, 4, 5, 6, 7, 12, 13, 17, 21, 23, 25, 26, 32, 0x6ffffef5, 0x6ffffff0, + 0x6ffffffc, 0x6ffffffe, + ]); + for (let at = 0; at < dynamic.data.length; at += 16) { + const tag = Number(dynamic.data.readBigInt64LE(at)); + const value = Number(dynamic.data.readBigUInt64LE(at + 8)); + if (tag === 0) break; + if (tag === 15 || tag === 29) { + paths.push(string(dynstr.data, value)); + continue; + } + if (tag === 10) continue; // DT_STRSZ grows only with the rewritten path. + let semantic = value; + if ([1, 14].includes(tag)) semantic = string(dynstr.data, value); + else if (pointers.has(tag) && value !== 0) { + const section = [...sections].find( + ([, s]) => value >= s.address && value < s.address + s.size, + ); + assert.ok(section, `Unknown dynamic pointer ${tag}`); + semantic = `${section[0]}+${value - section[1].address}`; + } + entries.push([tag, semantic]); + } + entries.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + return { + type: bytes.readUInt16LE(16), + machine: bytes.readUInt16LE(18), + entry: number(24), + sections, + entries, + paths, + symbols, + }; +} +export function verifyAppImageElf(original, packaged) { + const before = elf(original), + after = elf(packaged); + assert.equal(after.type, before.type); + assert.equal(after.machine, before.machine); + assert.equal(after.entry, before.entry); + assert.deepEqual( + [...after.sections.keys()].sort(), + [...before.sections.keys()].sort(), + ); + for (const [name, section] of before.sections) { + const candidate = after.sections.get(name); + assert.equal(candidate.type, section.type, `${name}: section type changed`); + assert.equal( + candidate.flags, + section.flags, + `${name}: section permissions changed`, + ); + if ([".dynamic", ".dynstr", ".shstrtab"].includes(name)) continue; + assert.equal(candidate.size, section.size, `${name}: section size changed`); + if (section.type === 2 || section.type === 11) { + assert.deepEqual( + after.symbols(candidate), + before.symbols(section), + `${name}: symbol semantics changed`, + ); + continue; + } + assert.ok( + candidate.data.equals(section.data), + `${name}: program section differs from release build`, + ); + } + assert.deepEqual( + after.entries, + before.entries, + "Dynamic-link metadata changed beyond RPATH relocation", + ); + assert.deepEqual( + after.paths, + ["$ORIGIN"], + "Unexpected AppImage host runtime search path", + ); + return "elf-sections-and-dynamic-metadata"; +} diff --git a/scripts/addons/elf-provenance.test.mjs b/scripts/addons/elf-provenance.test.mjs new file mode 100644 index 00000000..96d1b758 --- /dev/null +++ b/scripts/addons/elf-provenance.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, writeFile, copyFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { test } from "node:test"; +import { verifyAppImageElf } from "./elf-provenance.mjs"; +test( + "accept only RPATH relocation, reject changed program bytes and dependencies", + { skip: process.platform !== "linux" }, + async () => { + const root = await mkdtemp(join(tmpdir(), "cmx-elf-check-")); + const run = (bin, args) => { + const result = spawnSync(bin, args, { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.error?.message); + }; + try { + const source = join(root, "fixture.c"), + original = join(root, "original"), + patched = join(root, "patched"); + await writeFile( + source, + '#include \nint main(void) { puts("fixture"); return 0; }\n', + ); + run("cc", [source, "-o", original]); + await copyFile(original, patched); + run("patchelf", ["--set-rpath", "$ORIGIN", patched]); + const before = await readFile(original), + after = await readFile(patched); + assert.equal( + verifyAppImageElf(before, after), + "elf-sections-and-dynamic-metadata", + ); + const changed = Buffer.from(after); + const location = changed.indexOf(Buffer.from("fixture\0")); + assert.ok(location >= 0); + changed[location] = 0x58; + assert.throws( + () => verifyAppImageElf(before, changed), + /program section differs/, + ); + run("patchelf", ["--add-needed", "unapproved.so", patched]); + const dependency = await readFile(patched); + assert.throws( + () => verifyAppImageElf(before, dependency), + /Dynamic-link metadata/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/addons/packaged-smoke.mjs b/scripts/addons/packaged-smoke.mjs index cf2a1e56..7ed4faf1 100644 --- a/scripts/addons/packaged-smoke.mjs +++ b/scripts/addons/packaged-smoke.mjs @@ -7,6 +7,7 @@ import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { cpus, platform, release, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fault } from "./host-limits.mjs"; +import { verifyAppImageElf } from "./elf-provenance.mjs"; const bundleRoot = resolve( process.argv[2] ?? "src-tauri/target/release/bundle", ); @@ -15,9 +16,8 @@ const hostName = process.platform === "win32" ? "codemux-addon-host-windows-x64.exe" : "codemux-addon-host-linux-x64"; -const expected = createHash("sha256") - .update(await readFile(`src-tauri/binaries/${hostName}`)) - .digest("hex"); +const releaseHost = await readFile(`src-tauri/binaries/${hostName}`); +const expected = createHash("sha256").update(releaseHost).digest("hex"); assert.match( await readFile(`src-tauri/binaries/.${hostName}.profile`, "utf8"), /^profile=release$/m, @@ -75,13 +75,20 @@ try { "Installer must contain exactly one platform host", ); const host = hosts[0]; - assert.equal( - createHash("sha256") - .update(await readFile(host)) - .digest("hex"), - expected, - "Installer host differs from release build", - ); + const packagedBytes = await readFile(host); + const packagedSha256 = createHash("sha256") + .update(packagedBytes) + .digest("hex"); + let provenance = "exact-sha256"; + if (format === ".AppImage" && packagedSha256 !== expected) { + provenance = verifyAppImageElf(releaseHost, packagedBytes); + } else { + assert.equal( + packagedSha256, + expected, + "Installer host differs from release build", + ); + } run(process.execPath, ["scripts/addons/sdk-native.mjs", host]); const workloads = [ "while(true){}", @@ -98,9 +105,15 @@ try { }); evidence.bundles.push({ format, + packagedSha256, + provenance, hostPath: host.slice(unpack.length + 1), faults, }); + await writeFile( + "addon-packaged-evidence.json", + JSON.stringify(evidence, null, 2) + "\n", + ); if (format === ".exe") { const uninstall = (await files(unpack)).find((file) => /uninstall\.exe$/i.test(file), @@ -114,7 +127,7 @@ try { JSON.stringify(evidence, null, 2) + "\n", ); console.log( - "PASS: installer payload digests, clean-environment SDK callbacks and packaged hostile-runtime deadlines", + "PASS: installer host provenance, clean-environment SDK callbacks and packaged hostile-runtime deadlines", ); } finally { await rm(root, { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ceda93bb..1d6b96f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1239,6 +1239,7 @@ dependencies = [ "tauri-plugin-updater", "tempfile", "tokio", + "tokio-rustls", "tokio-tungstenite 0.26.2", "tokio-util", "toml 1.1.2+spec-1.1.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 40eb2318..19387898 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -224,6 +224,7 @@ windows-sys = { version = "0.59", features = [ test-fixtures = [] [dev-dependencies] +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } mockito = "1" serial_test = "3" # Enable tauri's `test` feature so integration tests can build a diff --git a/src-tauri/addon-catalog/Cargo.lock b/src-tauri/addon-catalog/Cargo.lock index 062dc620..47844e41 100644 --- a/src-tauri/addon-catalog/Cargo.lock +++ b/src-tauri/addon-catalog/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "sha2", "tar", "tokio", + "tokio-rustls", "tokio-util", "url", ] @@ -945,6 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", diff --git a/src-tauri/addon-catalog/Cargo.toml b/src-tauri/addon-catalog/Cargo.toml index 3f7377b9..189d1440 100644 --- a/src-tauri/addon-catalog/Cargo.toml +++ b/src-tauri/addon-catalog/Cargo.toml @@ -15,3 +15,6 @@ url = "2" reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["full"] } tokio-util = "0.7" + +[dev-dependencies] +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } diff --git a/src-tauri/src/addons/git.rs b/src-tauri/src/addons/git.rs index 515aefd9..2128c4c6 100644 --- a/src-tauri/src/addons/git.rs +++ b/src-tauri/src/addons/git.rs @@ -58,15 +58,13 @@ impl Git { Ok(summary) } } -async fn run(root: &Path, cancel: &CancellationToken) -> Result> { - let executable = which::which("git") - .map_err(|_| ProtocolError::new(ErrorCode::NotAGitRepo, "Git is unavailable"))?; - let empty = tempfile::tempdir().map_err(|_| { - ProtocolError::new( - ErrorCode::StorageUnavailable, - "Cannot prepare Git operation", - ) - })?; +// Repository configuration is untrusted: even `status` can execute clean filters. +// Discovery commands only read metadata. The status child receives a private Git +// directory with inert configuration, copied index/refs and a read-only object path. +const OUTPUT_LIMIT: usize = 2 * 1024 * 1024; +const INDEX_LIMIT: usize = 128 * 1024 * 1024; + +fn command(executable: &Path, root: &Path) -> tokio::process::Command { let mut cmd = tokio::process::Command::new(executable); cmd.env_clear() .current_dir(root) @@ -84,36 +82,38 @@ async fn run(root: &Path, cancel: &CancellationToken) -> Result> { if cfg!(windows) { "NUL" } else { "/dev/null" }, ) .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_NO_LAZY_FETCH", "1") .env("GIT_PAGER", "") - .env("LC_ALL", "C"); - cmd.args([ - "--no-optional-locks", - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", - "-c", - ]) - .arg(format!("core.hooksPath={}", empty.path().display())) - .args([ - "status", - "--porcelain=v2", - "--branch", - "-z", - "--untracked-files=normal", - "--ignore-submodules=all", - ]); + .env("LC_ALL", "C") + .args([ + "--no-optional-locks", + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "-c", + "protocol.allow=never", + ]); + cmd +} + +async fn capture( + mut cmd: tokio::process::Command, + cancel: &CancellationToken, + deadline: tokio::time::Instant, +) -> Result> { let mut child = cmd .spawn() - .map_err(|_| ProtocolError::new(ErrorCode::NotAGitRepo, "Cannot read Git status"))?; - let mut output = child.stdout.take().unwrap().take(2 * 1024 * 1024 + 1); + .map_err(|_| ProtocolError::new(ErrorCode::NotAGitRepo, "Cannot read Git metadata"))?; + let mut output = child.stdout.take().unwrap().take(OUTPUT_LIMIT as u64 + 1); let mut bytes = Vec::new(); let work = async { output .read_to_end(&mut bytes) .await .map_err(|_| ProtocolError::invalid("Cannot read Git output"))?; - if bytes.len() > 2 * 1024 * 1024 { + if bytes.len() > OUTPUT_LIMIT { return Err(ProtocolError::new( ErrorCode::ResourceLimit, "Git output limit", @@ -126,12 +126,15 @@ async fn run(root: &Path, cancel: &CancellationToken) -> Result> { if !status.success() { return Err(ProtocolError::new( ErrorCode::NotAGitRepo, - "This workspace is not a Git repository", + "Cannot read this Git repository", )); } Ok(()) }; - let result = tokio::select! {_ = cancel.cancelled()=>Err(ProtocolError::new(ErrorCode::ContextStale,"Workspace changed")),result=tokio::time::timeout(Duration::from_secs(5),work)=>result.unwrap_or_else(|_|Err(ProtocolError::new(ErrorCode::Timeout,"Git summary timed out")))}; + let result = tokio::select! { + _ = cancel.cancelled() => Err(ProtocolError::new(ErrorCode::ContextStale, "Workspace changed")), + result = tokio::time::timeout_at(deadline, work) => result.unwrap_or_else(|_| Err(ProtocolError::new(ErrorCode::Timeout, "Git summary timed out"))) + }; if result.is_err() { let _ = child.kill().await; let _ = child.wait().await; @@ -139,6 +142,233 @@ async fn run(root: &Path, cancel: &CancellationToken) -> Result> { result?; Ok(bytes) } + +fn snapshot_error(_: impl std::fmt::Display) -> ProtocolError { + ProtocolError::new( + ErrorCode::StorageUnavailable, + "Cannot prepare private Git metadata", + ) +} + +async fn copy_metadata( + source: &Path, + target: &Path, + remaining: &mut usize, + cancel: &CancellationToken, + deadline: tokio::time::Instant, +) -> Result<()> { + let work = async { + let file = match tokio::fs::File::open(source).await { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(snapshot_error(error)), + }; + let mut bytes = Vec::new(); + file.take(*remaining as u64 + 1) + .read_to_end(&mut bytes) + .await + .map_err(snapshot_error)?; + if bytes.len() > *remaining { + return Err(ProtocolError::new( + ErrorCode::ResourceLimit, + "Git metadata snapshot limit", + )); + } + *remaining -= bytes.len(); + tokio::fs::write(target, bytes) + .await + .map_err(snapshot_error) + }; + tokio::select! { + _ = cancel.cancelled() => Err(ProtocolError::new(ErrorCode::ContextStale, "Workspace changed")), + result = tokio::time::timeout_at(deadline, work) => result.unwrap_or_else(|_| Err(ProtocolError::new(ErrorCode::Timeout, "Git summary timed out"))) + } +} + +fn quote_config(value: &str) -> Result { + if value + .chars() + .any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\u{8}')) + { + return Err(ProtocolError::invalid( + "Unsupported Git configuration value", + )); + } + Ok(format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\t', "\\t") + .replace('\u{8}', "\\b") + )) +} + +fn safe_config(bytes: &[u8], format: &str) -> Result { + let mut config = format!("[core]\nrepositoryformatversion = 1\nbare = false\n[extensions]\nobjectformat = {format}\n"); + for record in bytes.split(|b| *b == 0).filter(|v| !v.is_empty()) { + let record = std::str::from_utf8(record) + .map_err(|_| ProtocolError::invalid("Unsupported Git configuration encoding"))?; + let (key, value) = record.split_once('\n').unwrap_or((record, "true")); + let Some((section, tail)) = key.split_once('.') else { + continue; + }; + if section == "core" + && matches!( + tail, + "filemode" | "ignorecase" | "precomposeunicode" | "autocrlf" | "eol" + ) + { + config.push_str(&format!("[core]\n{tail} = {}\n", quote_config(value)?)); + } else if matches!(section, "branch" | "remote") { + let Some((subsection, name)) = tail.rsplit_once('.') else { + continue; + }; + if (section == "branch" && matches!(name, "remote" | "merge")) + || (section == "remote" && name == "fetch") + { + config.push_str(&format!( + "[{section} {}]\n{name} = {}\n", + quote_config(subsection)?, + quote_config(value)? + )); + } + } + } + Ok(config) +} + +async fn run(root: &Path, cancel: &CancellationToken) -> Result> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let executable = which::which("git") + .map_err(|_| ProtocolError::new(ErrorCode::NotAGitRepo, "Git is unavailable"))?; + let private = tempfile::tempdir().map_err(snapshot_error)?; + let mut discover = command(&executable, root); + discover.args([ + "rev-parse", + "--path-format=absolute", + "--absolute-git-dir", + "--git-path", + "objects", + "--git-path", + "shallow", + "--show-object-format", + "--show-toplevel", + ]); + let locations = capture(discover, cancel, deadline).await?; + let locations = std::str::from_utf8(&locations) + .map_err(|_| ProtocolError::invalid("Unsupported Git path encoding"))?; + let locations: Vec<_> = locations.lines().collect(); + if locations.len() != 5 || !matches!(locations[3], "sha1" | "sha256") { + return Err(ProtocolError::invalid( + "Unsupported Git repository metadata", + )); + } + let git_dir = Path::new(locations[0]); + // A workspace rooted below a repository must not expand its read authority. + if tokio::fs::canonicalize(locations[4]) + .await + .map_err(snapshot_error)? + != tokio::fs::canonicalize(root) + .await + .map_err(snapshot_error)? + { + return Err(ProtocolError::new( + ErrorCode::NotAGitRepo, + "Workspace is not a Git repository root", + )); + } + let mut config_cmd = command(&executable, root); + config_cmd.args(["config", "--null", "--list", "--includes"]); + let config = safe_config(&capture(config_cmd, cancel, deadline).await?, locations[3])?; + let mut refs_cmd = command(&executable, root); + refs_cmd.args([ + "for-each-ref", + "--format=%(objectname) %(refname)", + "refs/heads/", + "refs/remotes/", + ]); + let refs = capture(refs_cmd, cancel, deadline).await?; + tokio::fs::create_dir(private.path().join("refs")) + .await + .map_err(snapshot_error)?; + tokio::fs::create_dir(private.path().join("objects")) + .await + .map_err(snapshot_error)?; + tokio::fs::write(private.path().join("config"), config) + .await + .map_err(snapshot_error)?; + // `for-each-ref` supplies validated ref names; packed-refs avoids creating + // filesystem paths from them (including Windows reserved basenames). + tokio::fs::write(private.path().join("packed-refs"), refs) + .await + .map_err(snapshot_error)?; + let mut head_limit = 4096; + copy_metadata( + &git_dir.join("HEAD"), + &private.path().join("HEAD"), + &mut head_limit, + cancel, + deadline, + ) + .await?; + let mut index_limit = INDEX_LIMIT; + copy_metadata( + &git_dir.join("index"), + &private.path().join("index"), + &mut index_limit, + cancel, + deadline, + ) + .await?; + let mut entries = tokio::fs::read_dir(git_dir).await.map_err(snapshot_error)?; + while let Some(entry) = entries.next_entry().await.map_err(snapshot_error)? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.strip_prefix("sharedindex.").is_some_and(|hash| { + matches!(hash.len(), 40 | 64) && hash.bytes().all(|b| b.is_ascii_hexdigit()) + }) { + copy_metadata( + &entry.path(), + &private.path().join(name.as_ref()), + &mut index_limit, + cancel, + deadline, + ) + .await?; + } + if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + return Err(ProtocolError::new( + ErrorCode::ContextStale, + "Git metadata read interrupted", + )); + } + } + let mut shallow_limit = OUTPUT_LIMIT; + copy_metadata( + Path::new(locations[2]), + &private.path().join("shallow"), + &mut shallow_limit, + cancel, + deadline, + ) + .await?; + let mut status = command(&executable, root); + status + .env("GIT_DIR", private.path()) + .env("GIT_WORK_TREE", root) + .env("GIT_OBJECT_DIRECTORY", locations[1]) + .args([ + "status", + "--porcelain=v2", + "--branch", + "-z", + "--untracked-files=normal", + "--ignore-submodules=all", + ]); + capture(status, cancel, deadline).await +} fn parse(bytes: &[u8]) -> Result

{ let mut result = Summary::default(); let mut records = bytes.split(|b| *b == 0); @@ -222,6 +452,135 @@ fn parse(bytes: &[u8]) -> Result { #[cfg(test)] mod tests { use super::*; + fn fixture_git(root: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .current_dir(root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env( + "GIT_CONFIG_GLOBAL", + if cfg!(windows) { "NUL" } else { "/dev/null" }, + ) + .args([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "Git fixture: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + fn repository(root: &Path) { + fixture_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("file"), "original\n").unwrap(); + std::fs::write(root.join(".gitattributes"), "file filter=fixture\n").unwrap(); + fixture_git(root, &["add", "."]); + fixture_git(root, &["commit", "-m", "fixture"]); + } + #[tokio::test] + async fn repository_programs_are_inert_and_normal_status_is_preserved() { + let root = tempfile::tempdir().unwrap(); + repository(root.path()); + fixture_git( + root.path(), + &[ + "config", + "filter.fixture.clean", + "echo executed > filter-executed; cat", + ], + ); + std::fs::write(root.path().join("file"), "modified\n").unwrap(); + // Prove the fixture reaches Git's clean-filter execution path. + fixture_git(root.path(), &["status", "--porcelain=v2"]); + assert!(root.path().join("filter-executed").exists()); + std::fs::remove_file(root.path().join("filter-executed")).unwrap(); + let summary = parse(&run(root.path(), &CancellationToken::new()).await.unwrap()).unwrap(); + assert_eq!(summary.branch.as_deref(), Some("main")); + assert_eq!(summary.unstaged, 1); + assert_eq!(summary.paths, ["file"]); + assert!(!root.path().join("filter-executed").exists()); + // Long-running process filters are independently disabled as well. + fixture_git( + root.path(), + &[ + "config", + "filter.fixture.process", + "echo executed > process-executed; exit 1", + ], + ); + fixture_git( + root.path(), + &[ + "config", + "core.fsmonitor", + "echo executed > fsmonitor-executed", + ], + ); + fixture_git(root.path(), &["config", "core.hooksPath", ".hooks"]); + run(root.path(), &CancellationToken::new()).await.unwrap(); + for name in ["filter-executed", "process-executed", "fsmonitor-executed"] { + assert!(!root.path().join(name).exists()); + } + } + #[tokio::test] + async fn snapshots_support_tracking_split_index_detached_and_linked_worktrees() { + let root = tempfile::tempdir().unwrap(); + let repo = root.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + repository(&repo); + fixture_git(&repo, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + fixture_git( + &repo, + &[ + "config", + "remote.origin.fetch", + "+refs/heads/*:refs/remotes/origin/*", + ], + ); + fixture_git(&repo, &["config", "branch.main.remote", "origin"]); + fixture_git(&repo, &["config", "branch.main.merge", "refs/heads/main"]); + std::fs::write(repo.join("staged"), "fixture").unwrap(); + fixture_git(&repo, &["add", "."]); + fixture_git(&repo, &["commit", "-m", "ahead"]); + fixture_git(&repo, &["update-index", "--split-index"]); + std::fs::write(repo.join("file"), "modified\n").unwrap(); + let summary = parse(&run(&repo, &CancellationToken::new()).await.unwrap()).unwrap(); + assert_eq!((summary.ahead, summary.behind, summary.unstaged), (1, 0, 1)); + let linked = root.path().join("linked"); + fixture_git( + &repo, + &[ + "worktree", + "add", + "--detach", + linked.to_str().unwrap(), + "HEAD", + ], + ); + std::fs::write(linked.join("untracked"), "fixture").unwrap(); + let summary = parse(&run(&linked, &CancellationToken::new()).await.unwrap()).unwrap(); + assert_eq!(summary.branch, None); + assert_eq!(summary.paths, ["untracked"]); + } + #[tokio::test] + async fn unborn_repository_and_cancelled_context_are_handled() { + let root = tempfile::tempdir().unwrap(); + fixture_git(root.path(), &["init", "-b", "main"]); + std::fs::write(root.path().join("new"), "fixture").unwrap(); + let summary = parse(&run(root.path(), &CancellationToken::new()).await.unwrap()).unwrap(); + assert_eq!(summary.branch.as_deref(), Some("main")); + assert_eq!(summary.untracked, 1); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(run(root.path(), &cancel).await.is_err()); + } #[test] fn porcelain_paths_and_detached_head_are_preserved() { let summary=parse(b"# branch.head (detached)\0# branch.ab +2 -3\01 M. N... 100644 100644 100644 abc def path with spaces\0? new\0").unwrap(); diff --git a/src-tauri/src/addons/http.rs b/src-tauri/src/addons/http.rs index 8dd58868..dc35f528 100644 --- a/src-tauri/src/addons/http.rs +++ b/src-tauri/src/addons/http.rs @@ -31,9 +31,19 @@ struct Traffic { entries: VecDeque<(Instant, usize)>, total: usize, } +#[cfg(test)] +#[derive(Clone)] +struct NativeFixture { + answers: Vec, + connect: std::net::SocketAddr, + trust_certificate: bool, + timeout: Duration, +} #[derive(Clone)] pub struct Http { slots: Arc, + #[cfg(test)] + native_fixture: Option, traffic: Arc>, #[cfg(test)] fixture: Arc>>>, @@ -44,6 +54,8 @@ impl Default for Http { fn default() -> Self { Self { slots: Arc::new(Semaphore::new(4)), + #[cfg(test)] + native_fixture: None, traffic: Arc::new(Mutex::new(Traffic::default())), #[cfg(test)] fixture: Arc::new(Mutex::new(None)), @@ -178,6 +190,12 @@ impl Http { })?; let url = validate(&request, grant)?; let host = url.host_str().ok_or_else(denied)?.to_string(); + let timeout = Duration::from_secs(30); + #[cfg(test)] + let timeout = self + .native_fixture + .as_ref() + .map_or(timeout, |fixture| fixture.timeout); let work = async { #[cfg(test)] { @@ -218,21 +236,49 @@ impl Http { return Ok(response); } } - let addresses = tokio::net::lookup_host((host.as_str(), 443)) - .await - .map_err(|_| denied())? - .collect::>(); + let resolve = async { + let addresses = tokio::net::lookup_host((host.as_str(), 443)) + .await + .map_err(|_| denied())? + .collect::>(); + Ok::<_, ProtocolError>(addresses) + }; + #[cfg(not(test))] + let addresses: Vec = resolve.await?; + #[cfg(test)] + let addresses: Vec = if let Some(fixture) = &self.native_fixture { + fixture.answers.clone() + } else { + resolve.await? + }; if addresses.is_empty() || addresses.iter().any(|a| !public_address(a.ip())) { return Err(denied()); } // DNS is resolved once, vetted, then pinned while TLS still verifies hostname. - let client = reqwest::Client::builder() + #[cfg(test)] + let addresses = self + .native_fixture + .as_ref() + .map_or(addresses, |fixture| vec![fixture.connect]); + let builder = reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(30)) - .resolve_to_addrs(&host, &addresses) - .build() - .map_err(|_| denied())?; + .timeout(timeout) + .resolve_to_addrs(&host, &addresses); + #[cfg(test)] + let builder = if self + .native_fixture + .as_ref() + .is_some_and(|fixture| fixture.trust_certificate) + { + builder.add_root_certificate( + reqwest::Certificate::from_der(include_bytes!("test-fixtures/http/ca.der")) + .unwrap(), + ) + } else { + builder + }; + let client = builder.build().map_err(|_| denied())?; let method = match request.method { HttpMethod::GET => reqwest::Method::GET, HttpMethod::POST => reqwest::Method::POST, @@ -311,7 +357,7 @@ impl Http { body, }) }; - tokio::select! {_ = cancel.cancelled()=>Err(ProtocolError::new(ErrorCode::PluginStopped,"Plugin request was cancelled")),result=tokio::time::timeout(Duration::from_secs(30),work)=>result.map_err(|_|ProtocolError::new(ErrorCode::Timeout,"HTTP request timed out"))?} + tokio::select! {_ = cancel.cancelled()=>Err(ProtocolError::new(ErrorCode::PluginStopped,"Plugin request was cancelled")),result=tokio::time::timeout(timeout,work)=>result.map_err(|_|ProtocolError::new(ErrorCode::Timeout,"HTTP request timed out"))?} } } #[cfg(test)] @@ -394,3 +440,7 @@ mod tests { assert!(validate(&request, &grant).is_err()); } } + +#[cfg(test)] +#[path = "http_native_tests.rs"] +mod native_tests; diff --git a/src-tauri/src/addons/http_native_tests.rs b/src-tauri/src/addons/http_native_tests.rs new file mode 100644 index 00000000..c12698dd --- /dev/null +++ b/src-tauri/src/addons/http_native_tests.rs @@ -0,0 +1,266 @@ +use super::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_rustls::{rustls, TlsAcceptor}; + +fn grant() -> HttpGrant { + HttpGrant { + origin: "https://addon-http.invalid".into(), + methods: vec![HttpMethod::GET], + credential: None, + } +} +fn request() -> Request { + Request { + context: "fixture".into(), + origin: grant().origin, + path: "/fixture".into(), + method: HttpMethod::GET, + headers: BTreeMap::new(), + body: None, + } +} +async fn server( + response: Vec, + delay: Duration, +) -> ( + Http, + tokio::task::JoinHandle, + tokio::sync::oneshot::Receiver<()>, +) { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let config = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![rustls::pki_types::CertificateDer::from( + include_bytes!("test-fixtures/http/server.der").to_vec(), + )], + rustls::pki_types::PrivateKeyDer::Pkcs8(rustls::pki_types::PrivatePkcs8KeyDer::from( + include_bytes!("test-fixtures/http/server-key.der").to_vec(), + )), + ) + .unwrap(); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let connect = listener.local_addr().unwrap(); + let (received, ready) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let Ok(mut stream) = TlsAcceptor::from(Arc::new(config)).accept(tcp).await else { + return String::new(); + }; + let mut headers = Vec::new(); + loop { + let Ok(byte) = stream.read_u8().await else { + return String::new(); + }; + headers.push(byte); + assert!(headers.len() <= 16 * 1024); + if headers.ends_with(b"\r\n\r\n") { + break; + } + } + let _ = received.send(()); + tokio::time::sleep(delay).await; + let _ = stream.write_all(&response).await; + let _ = stream.shutdown().await; + String::from_utf8(headers).unwrap() + }); + let http = Http { + native_fixture: Some(NativeFixture { + answers: vec!["8.8.8.8:443".parse().unwrap()], + connect, + trust_certificate: true, + timeout: Duration::from_secs(2), + }), + ..Http::default() + }; + (http, task, ready) +} +async fn finish(task: tokio::task::JoinHandle) -> String { + tokio::time::timeout(Duration::from_secs(3), task) + .await + .unwrap() + .unwrap() +} +#[tokio::test] +async fn pinned_native_tls_preserves_hostname_credentials_and_safe_response_headers() { + let (http, task, _) = server(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nETag: fixture\r\nSet-Cookie: hidden=value\r\nConnection: close\r\n\r\nok".to_vec(), Duration::ZERO).await; + let response = http + .fetch( + "test.http", + request(), + &grant(), + Some("synthetic-test-token".into()), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(response.body, "ok"); + assert_eq!( + response.headers.get("etag").map(String::as_str), + Some("fixture") + ); + assert!(!response.headers.contains_key("set-cookie")); + let headers = finish(task).await.to_ascii_lowercase(); + assert!(headers.contains("host: addon-http.invalid")); + assert!(headers.contains("authorization: bearer synthetic-test-token")); + // .invalid has no public DNS: a successful real TLS connection proves the + // client uses the pinned endpoint, not a second lookup of this hostname. +} +#[tokio::test] +async fn untrusted_certificate_and_rebound_private_dns_are_rejected() { + let (mut http, task, _) = server( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec(), + Duration::ZERO, + ) + .await; + http.native_fixture.as_mut().unwrap().trust_certificate = false; + assert!(http + .fetch( + "test.http", + request(), + &grant(), + None, + &CancellationToken::new() + ) + .await + .is_err()); + assert!(finish(task).await.is_empty()); + let (other_http, other_task, _) = server( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec(), + Duration::ZERO, + ) + .await; + let mut other_grant = grant(); + other_grant.origin = "https://wrong-host.invalid".into(); + let mut other_request = request(); + other_request.origin = other_grant.origin.clone(); + assert!(other_http + .fetch( + "test.http", + other_request, + &other_grant, + None, + &CancellationToken::new() + ) + .await + .is_err()); + assert!(finish(other_task).await.is_empty()); + for addresses in [ + vec![], + vec!["127.0.0.1:443"], + vec!["8.8.8.8:443", "[::ffff:127.0.0.1]:443"], + ] { + http.native_fixture.as_mut().unwrap().answers = + addresses.iter().map(|s| s.parse().unwrap()).collect(); + let result = http + .fetch( + "test.http", + request(), + &grant(), + None, + &CancellationToken::new(), + ) + .await; + assert!(matches!( + result, + Err(error) if error.data.code == ErrorCode::NetworkDenied + )); + } +} +#[tokio::test] +async fn native_redirect_encoding_and_streamed_body_limits_are_enforced() { + let cases = [ + b"HTTP/1.1 302 Found\r\nLocation: https://127.0.0.1/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 4\r\nConnection: close\r\n\r\nbomb".to_vec(), + b"HTTP/1.1 200 OK\r\nContent-Length: 524289\r\nConnection: close\r\n\r\n".to_vec(), + { + let mut response = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n80001\r\n".to_vec(); + response.extend(vec![b'x'; 512 * 1024 + 1]); + response.extend_from_slice(b"\r\n0\r\n\r\n"); + response + }, + ]; + for response in cases { + let (http, task, _) = server(response, Duration::ZERO).await; + assert!(http + .fetch( + "test.http", + request(), + &grant(), + None, + &CancellationToken::new() + ) + .await + .is_err()); + finish(task).await; + } +} +#[tokio::test] +async fn native_timeout_cancellation_and_concurrency_release_the_request_slot() { + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(); + let (mut http, task, ready) = server(response.clone(), Duration::from_millis(1000)).await; + http.native_fixture.as_mut().unwrap().timeout = Duration::from_millis(500); + let began = Instant::now(); + let request_future = http + .fetch( + "test.http", + request(), + &grant(), + None, + &CancellationToken::new(), + ) + .await; + assert!(request_future.is_err()); + assert!(began.elapsed() < Duration::from_secs(2)); + assert_eq!(http.slots.available_permits(), 4); + drop(ready); + finish(task).await; + let (http, task, ready) = server(response, Duration::from_millis(100)).await; + let cancel = CancellationToken::new(); + let cloned = http.clone(); + let child_cancel = cancel.clone(); + let fetch = tokio::spawn(async move { + cloned + .fetch("test.http", request(), &grant(), None, &child_cancel) + .await + }); + ready.await.unwrap(); + cancel.cancel(); + assert!(matches!( + fetch.await.unwrap(), + Err(error) if error.data.code == ErrorCode::PluginStopped + )); + assert_eq!(http.slots.available_permits(), 4); + finish(task).await; + let permits = http.slots.acquire_many(4).await.unwrap(); + assert!(matches!( + http.fetch( + "test.http", + request(), + &grant(), + None, + &CancellationToken::new() + ) + .await, + Err(error) if error.data.code == ErrorCode::ResourceLimit + )); + drop(permits); +} + +#[tokio::test] +async fn rolling_network_quota_is_private_and_recovers_after_its_window() { + let http = Http::default(); + http.charge(10 * 1024 * 1024).await.unwrap(); + assert!(http.charge(1).await.is_err()); + Http::default().charge(1).await.unwrap(); + { + let mut traffic = http.traffic.lock().await; + traffic.entries.front_mut().unwrap().0 = Instant::now() - Duration::from_secs(62); + } + http.charge(1).await.unwrap(); + assert_eq!(http.traffic.lock().await.total, 1); +} diff --git a/src-tauri/src/addons/lifecycle.rs b/src-tauri/src/addons/lifecycle.rs index b45d6b52..a2e1b8f4 100644 --- a/src-tauri/src/addons/lifecycle.rs +++ b/src-tauri/src/addons/lifecycle.rs @@ -53,6 +53,8 @@ struct Pending { #[derive(Default)] pub struct Reviews { pending: Mutex>, + #[cfg(test)] + interruption: Mutex)>>, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -89,6 +91,22 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { Ok(()) } impl Reviews { + #[cfg(test)] + async fn checkpoint(&self, stage: &'static str) { + let notify = self + .interruption + .lock() + .unwrap() + .as_ref() + .filter(|(expected, _)| *expected == stage) + .map(|(_, notify)| notify.clone()); + if let Some(notify) = notify { + notify.notify_one(); + // The test drops this transaction future here, exactly as an + // interrupted process would abandon it without error-path cleanup. + std::future::pending::<()>().await; + } + } pub fn prepare_local(&self, manager: &Manager, path: &Path) -> Result { let package = Package::read(path, None)?; self.prepare( @@ -353,6 +371,8 @@ impl Reviews { std::fs::create_dir_all(directory.parent().unwrap()).map_err(|_| unavailable())?; std::fs::rename(&staging, &directory).map_err(|_| unavailable())?; } + #[cfg(test)] + self.checkpoint("package-staged").await; let journal = manager .root .join("recovery") @@ -366,6 +386,8 @@ impl Reviews { }) .unwrap(), )?; + #[cfg(test)] + self.checkpoint("journal-saved").await; let result: Result<()> = async { active_review()?; manager.stop(id, None).await; @@ -386,20 +408,28 @@ impl Reviews { Storage::open(&previous)?.snapshot(&data.join("state.sqlite"))?; } } + #[cfg(test)] + self.checkpoint("data-snapshotted").await; if enable { manager .activate(candidate.clone(), pending.package.source(), true) .await?; manager.stop(id, None).await; } + #[cfg(test)] + self.checkpoint("candidate-probed").await; active_review()?; manager.commit_replacement(old.as_ref(), &candidate)?; + #[cfg(test)] + self.checkpoint("registry-switched").await; if enable { manager .activate(candidate.clone(), pending.package.source(), false) .await?; candidate.status = Status::EnabledRunning; } + #[cfg(test)] + self.checkpoint("activated").await; active_review()?; // Completion and retirement of a replaced source share a durable // commit marker; recovery must never delete restored credentials. @@ -452,7 +482,11 @@ impl Reviews { std::fs::remove_dir_all(journal.parent().unwrap()).map_err(|_| unavailable())?; return Err(error); } + #[cfg(test)] + self.checkpoint("completion-committed").await; std::fs::remove_dir_all(journal.parent().unwrap()).map_err(|_| unavailable())?; + #[cfg(test)] + self.checkpoint("journal-removed").await; manager .registry @@ -1047,6 +1081,130 @@ mod tests { installed.data_generation ); } + fn package_version(version: &str) -> Package { + let package = Package::parse(super::super::package::fixture_archive(), None).unwrap(); + let mut files = package.files; + let mut manifest = package.manifest; + manifest.version = version.into(); + files.insert( + "manifest.json".into(), + serde_json::to_vec(&manifest).unwrap(), + ); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (name, bytes) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive + .append_data(&mut header, name, bytes.as_slice()) + .unwrap(); + } + Package::parse(archive.into_inner().unwrap().finish().unwrap(), None).unwrap() + } + #[tokio::test] + async fn interrupted_update_recovers_at_every_durable_transition() { + for stage in [ + "package-staged", + "journal-saved", + "data-snapshotted", + "candidate-probed", + "registry-switched", + "activated", + "completion-committed", + "journal-removed", + ] { + let root = tempfile::tempdir().unwrap(); + let manager = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + let reviews = Reviews::default(); + let first = reviews + .prepare( + &manager, + package_version("1.0.0"), + Source::Local { + identity: Uuid::new_v4().to_string(), + }, + ) + .unwrap(); + let old = reviews + .accept(&manager, &first.token, false, false) + .await + .unwrap(); + let state_path = |record: &Installation| { + root.path() + .join("private/state") + .join(&record.installation_id) + .join(&record.data_generation) + .join("state.sqlite") + }; + Storage::open(&state_path(&old)) + .unwrap() + .set( + "global", + "fixture", + &serde_json::json!({"value":"original"}), + ) + .unwrap(); + let review = reviews + .prepare(&manager, package_version("2.0.0"), old.source.clone()) + .unwrap(); + let reached = Arc::new(tokio::sync::Notify::new()); + *reviews.interruption.lock().unwrap() = Some((stage, reached.clone())); + tokio::select! { + result = reviews.accept(&manager, &review.token, false, false) => panic!("{stage}: transaction completed before interruption: {}", result.is_ok()), + _ = reached.notified() => {}, + _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => panic!("{stage}: checkpoint not reached"), + } + drop(manager); + let reopened = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + let installed = reopened.installation(&old.manifest.id).unwrap(); + let complete = matches!(stage, "completion-committed" | "journal-removed"); + assert_eq!( + installed.digest, + if complete { + review.digest + } else { + old.digest.clone() + }, + "{stage}" + ); + assert_eq!(installed.installation_id, old.installation_id, "{stage}"); + assert_eq!(installed.source, old.source, "{stage}"); + assert!(!installed.desired_enabled, "{stage}"); + assert_eq!( + Storage::open(&state_path(&installed)) + .unwrap() + .get("global", "fixture") + .unwrap(), + serde_json::json!({"value":"original"}), + "{stage}" + ); + assert_eq!( + installed.grant.as_ref().unwrap().digest, + installed.digest, + "{stage}" + ); + if complete { + assert_eq!( + installed.previous.as_ref().unwrap().data_generation, + old.data_generation, + "{stage}" + ); + } else { + assert_eq!(installed.data_generation, old.data_generation, "{stage}"); + } + assert_eq!( + std::fs::read_dir(root.path().join("private/recovery")) + .unwrap() + .count(), + 0, + "{stage}" + ); + } + } #[test] fn incomplete_update_restores_exact_previous_tuple_and_private_snapshot() { let root = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/addons/test-fixtures/http/README.md b/src-tauri/src/addons/test-fixtures/http/README.md new file mode 100644 index 00000000..a67e10ca --- /dev/null +++ b/src-tauri/src/addons/test-fixtures/http/README.md @@ -0,0 +1,12 @@ +# Disposable native TLS fixture + +Public test CA and server certificate for `addon-http.invalid`, plus the server's +**non-secret test key**. Never use this key or CA outside these tests. The private +CA signing key is not committed. These certificates expire September 2046. + +The test-only network seam routes a vetted synthetic public DNS answer to a +loopback TLS listener. Production still resolves public DNS and pins those actual +addresses. Only the selected fixture trusts this CA; system trust is untouched. +The tests exercise the real HTTP/TLS client, including rejection with no added +CA, redirects, streaming body ceilings and cancellation. Fixture state is removed +from non-test compilation. diff --git a/src-tauri/src/addons/test-fixtures/http/ca.der b/src-tauri/src/addons/test-fixtures/http/ca.der new file mode 100644 index 0000000000000000000000000000000000000000..a6974482cfe27002ee587bff8ad2a43f940e2785 GIT binary patch literal 815 zcmXqLV%9cjViI1!%*4pVBvQ2Ru{1aH>|=KyEDADw^K5R;MkYoBUN%mxHjlRNyo`+8 ztPBRKhROztY|No7%sf)g`6;Qsr4Pjs|k#yhdgQmWCFF zW+o=arcvU&CLpdcluN8JO^iy&4q#+uU~XdMXE11DugbJ+~*pIXLt zO(owdbzG+OJzKt1^~n6wdOLne>`ivfJT7;z>ESeon>rn~&JioqGko<@-D;GkN3t&! zW!crGQ6VNU-{C{I(yg*r5&JYgWWHNh^y1BveQk})T#xNteyuS6o8#lVB6$m6-;+D- z_jFB~;EL52H$*ngZoT%X=2+U3lm@MYb?tLDT+x`z-?mTqt;V};-Rr#4L<9no6E<(* zS}yc_)3Sqg$M+pu<G4sSHKTa zAk4`4pM}+c8Au@qJ22jX!OqB#dwI)m;X6@%>c-KwoB{KE`MR~#0%LqP`--1RTc=Xg zt+R-8XN_fpnX#LCrXz!_#F=uTH>|7r?GN8iliRALYWS)nPw{J-^n2~X#XFpm7G`@d z7i}^5EyWRDIzz5S$4_E6PTNCteK=&(QC7O=JuA>t1r5h8C4!c@ur{;{)JZGLz fvubzS(oI4R+3S+D^F-Jy)*tI^wGh_7J?B0E1#3gf literal 0 HcmV?d00001 diff --git a/src-tauri/src/addons/test-fixtures/http/server-key.der b/src-tauri/src/addons/test-fixtures/http/server-key.der new file mode 100644 index 0000000000000000000000000000000000000000..4fc0f23e2fc7159b9a0d5f3441119f41a29ee075 GIT binary patch literal 1218 zcmV;z1U>sOf&{(-0RS)!1_>&LNQUrs4#*Aqyhl|0)hbn0I@=Cs1UA; zYoG!iN4U4+4~S?*NN@ix({Z#`0Zv^sr4~Z5;~g-sMu?rNPPEM{@O4a6P@vNyDxlwl6mk$|+uJBXx2LN8WmD~QYf&Kj_0#}D!Hh&3U!fDQ$;N{) z#MA8TuYYrIM&3V=zJDs*??5jhw~5UMcl{JB7BkxUIYGSKZ98cnol-h0H%BUIaI+66 zR*-i8{Z^GXNp$b$d{4LwQ%N_@Zr!~X)`jA9(r2J(Fai=s{q18I?wz_%&z4Kj5afiEign9U<44OWzQPK=~dflH#vvjPJF009Dm0RRHH zkWyM7Gsvwb8!Y0g|MN!{!l{4$oXch%Y>bSRWv_b;{|O6osofnT2owNBJTLdhKx+R<;-%qm?>of@O%gIDE^qw1ot@KcZ z-Y9=i8R*$R zE{Qj9t1gfCkx0){AkaAbqMK_$#uaJ)htTYF=nfwPTPw`KgZq^GP-VI_T4`7MPC&8= z_^U(9huRjlWg0qVJV>G;+LY4R+nmXtV%Y+LfIoRMT6MM;9B=_hJiV3LfZGzN<0(=R z68&$=){WTLJ#KpPO;+icY|6xJlgNiAY>`#JC}s5p7JX)Xf7n-eaa{n$1Z9hAgVh#{ z88LOgR?`gjTY|f3AWCz+b!cJPpHG>KeYp; zH3ETw0I)}=84!2PfY#*1Mh6u$f(Ju}XG3xHrd{kG@wg*+tHRDxX=*v6LC!+tDCSW4 z6eQ9v1jFOT9z0AC_#GK3&gC<5LWQ!SD8JL?G z`5A!XTue=jj0_u{a#skf>&{-lBThcB z+v4m%gcF%sozIv@Y?;Z3Nw|AapFaIl| zBW8T-r={bbJGs`G@^gc%w9VbMG7C2IYlKZG`~Np=s<~&$`{y = { const colors: Record = { default: "text-foreground", muted: "text-muted-foreground", - success: "text-emerald-600 dark:text-emerald-400", - warning: "text-amber-600 dark:text-amber-400", + success: "text-success", + warning: "text-warning", danger: "text-destructive", accent: "text-primary", }; diff --git a/src/components/settings/addons-settings.test.tsx b/src/components/settings/addons-settings.test.tsx new file mode 100644 index 00000000..eedc80ae --- /dev/null +++ b/src/components/settings/addons-settings.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); +vi.mock("@/lib/addons/platform", () => ({ + refreshAddons: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/addons/bridge", () => ({ + addonInvoke: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/components/remote/is-remote-client", () => ({ + isRemoteClient: () => false, +})); +import { AddonsSettings } from "./addons-settings"; +import { useAddonsStore } from "@/stores/addons-store"; +beforeEach(() => + useAddonsStore.setState({ + installed: [], + loaded: true, + paused: false, + warnings: [], + error: null, + developmentReview: null, + }), +); +afterEach(cleanup); +it("Escape dismisses only the install dialog and restores its opener", async () => { + const outerEscape = vi.fn(); + window.addEventListener("keydown", outerEscape); + try { + render(); + const opener = screen.getByRole("button", { + name: "Install from link / ID", + }); + opener.focus(); + fireEvent.click(opener); + const input = screen.getByRole("textbox", { + name: "Add-on install link or ID", + }); + await waitFor(() => expect(document.activeElement).toBe(input)); + fireEvent.keyDown(input, { key: "Escape", code: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(opener)); + expect(outerEscape).not.toHaveBeenCalled(); + expect( + screen.getByRole("heading", { name: "Add-ons" }), + ).toBeInTheDocument(); + } finally { + window.removeEventListener("keydown", outerEscape); + } +}); diff --git a/src/components/settings/addons-settings.tsx b/src/components/settings/addons-settings.tsx index 12fd9c6a..a8fef109 100644 --- a/src/components/settings/addons-settings.tsx +++ b/src/components/settings/addons-settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, type ComponentProps } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { ArrowLeft, @@ -30,6 +30,27 @@ import { type AddonManifest, type AddonReview, } from "@/lib/addons/types"; +// These controlled dialogs have no Radix Trigger. Restore their actual opener, +// and keep Escape from also reaching the window-level Settings close shortcut. +function AddonDialogContent(props: ComponentProps) { + const opener = useRef(null); + return ( + { + opener.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + if (opener.current?.isConnected) opener.current.focus(); + }} + onEscapeKeyDown={(event) => event.stopPropagation()} + /> + ); +} function Capabilities({ manifest }: { manifest: AddonManifest }) { return (
@@ -666,7 +687,7 @@ export function AddonsSettings() { )} - + Install from link or ID @@ -690,7 +711,6 @@ export function AddonsSettings() { > setTarget(e.target.value)} placeholder="codemux.project-brief" @@ -701,7 +721,7 @@ export function AddonsSettings() { - + - + Review {review?.manifest.name} @@ -789,7 +809,7 @@ export function AddonsSettings() { )} - + - + Remove {remove?.manifest.name}? @@ -831,7 +851,7 @@ export function AddonsSettings() { Remove add-on - + - + Restore {rollback?.previous?.manifest.version}? @@ -865,7 +885,7 @@ export function AddonsSettings() { Restore previous release - +
); diff --git a/src/lib/addons/use-addon-composer-adapter.ts b/src/lib/addons/use-addon-composer-adapter.ts index 9220b538..7c7cd9a8 100644 --- a/src/lib/addons/use-addon-composer-adapter.ts +++ b/src/lib/addons/use-addon-composer-adapter.ts @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { randomUUID } from "@/lib/uuid"; import { useFeatureFlags } from "@/stores/feature-flags"; import { useAddonsStore } from "@/stores/addons-store"; import { addonInvoke } from "./bridge"; @@ -12,10 +13,7 @@ export function useAddonComposerAdapter( onDraftChange: (text: string) => void, ) { const enabled = useFeatureFlags((s) => s.enableAgentChat); - const id = useMemo( - () => crypto.randomUUID(), - [workspaceId, threadId, enabled], - ); + const id = useMemo(() => randomUUID(), [workspaceId, threadId, enabled]); const [registered, setRegistered] = useState(false); const latest = useRef({ draft, onDraftChange }); const revision = useRef(0); From 12521d3f88a6eb7b279125861fa253c9d17d93e9 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 19:30:56 +0200 Subject: [PATCH 003/155] fix(addons): close cancellation and composer lifecycle gaps --- .github/workflows/addon-packaged.yml | 4 +- docs/addons/IMPLEMENTATION.md | 62 +++++++--- docs/addons/evidence/README.md | 11 +- docs/addons/evidence/packaged-linux.json | 65 ++++++++++ ...ance.test.mjs => elf-provenance-check.mjs} | 0 src-tauri/addon-protocol/tests/ui.rs | 18 +++ src-tauri/src/addons/git.rs | 113 ++++++++++++++++-- src-tauri/src/addons/lifecycle.rs | 98 ++++++++++++++- src-tauri/src/addons/manager.rs | 91 ++++++++++++++ src-tauri/src/addons/package.rs | 16 ++- src/components/addons/addon-view.test.tsx | 83 +++++++++++++ src/components/addons/addon-view.tsx | 22 ++-- src/lib/addons/composer-registry.ts | 16 ++- 13 files changed, 552 insertions(+), 47 deletions(-) create mode 100644 docs/addons/evidence/packaged-linux.json rename scripts/addons/{elf-provenance.test.mjs => elf-provenance-check.mjs} (100%) diff --git a/.github/workflows/addon-packaged.yml b/.github/workflows/addon-packaged.yml index 224601bc..677e97f3 100644 --- a/.github/workflows/addon-packaged.yml +++ b/.github/workflows/addon-packaged.yml @@ -2,7 +2,7 @@ name: Packaged add-on runtime on: workflow_dispatch: pull_request: - paths: ['scripts/addons/packaged-smoke.mjs', 'scripts/addons/elf-provenance.mjs', 'scripts/addons/host-limits.mjs', 'scripts/addons/sdk-native.mjs', 'src-tauri/addon-host/**', 'src-tauri/tauri.conf.json', 'scripts/build-addon-host.sh', '.github/workflows/addon-packaged.yml'] + paths: ['scripts/addons/packaged-smoke.mjs', 'scripts/addons/elf-provenance.mjs', 'scripts/addons/elf-provenance-check.mjs', 'scripts/addons/host-limits.mjs', 'scripts/addons/sdk-native.mjs', 'src-tauri/addon-host/**', 'src-tauri/tauri.conf.json', 'scripts/build-addon-host.sh', '.github/workflows/addon-packaged.yml'] permissions: contents: read concurrency: @@ -85,7 +85,7 @@ jobs: - name: Verify AppImage provenance checks if: runner.os == 'Linux' - run: node --test scripts/addons/elf-provenance.test.mjs + run: node --test scripts/addons/elf-provenance-check.mjs - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index 82032c23..0bf236b1 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -21,7 +21,9 @@ standalone host tests, browser mocks, or the research probe is not release appro | 7 | Website pinned catalog, search/detail/handoff, author documentation | Draft [website #7](https://github.com/Zeus-Deus/codemux-sitev2/pull/7); build and affected tests pass | | 8 | Release hardening and complete acceptance evidence | Draft [#397](https://github.com/Zeus-Deus/codemux/pull/397); see unresolved gates below | -Later PRs stack on their specified prerequisites. The native catalog reader and +Later PRs stack on their specified prerequisites. Full desktop CI was enabled +for the stack in PR 8; earlier draft heads do not each have full desktop CI +evidence and must be revalidated as they are prepared for merging. The native catalog reader and transactional installer are included in the manager foundation because its grant, activation, removal and recovery paths must share one authority boundary. Catalog publication and Settings remain separate deliverables. @@ -30,26 +32,37 @@ publication and Settings remain separate deliverables. - PR 1 hosted CI: Linux and Windows GNU host, manifest contracts, SDK callback integration all passed. Schema comparison normalizes Windows CRLF only. -- Protocol: 10 focused tests pass, including atomic malformed UI rejection, - callback disposal, catalog ownership/release history and strict manifests. +- Protocol: 11 focused tests pass, including atomic malformed UI rejection, + callback disposal, mutation/tree limits, catalog ownership/release history and + strict manifests. Raw archive validation covers traversal, absolute/Windows/UNC + paths, alternate data streams, case collisions, links and special entries. - Independent host: 3 real-process tests pass, covering absent ambient authority, parent EOF, stale/oversized IPC and hostile synchronous/asynchronous workloads. - SDK native integration passes: Preact view, batched Remote DOM updates, callback, scoped request, property removal and unmount. -- Desktop manager: 36 focused native tests pass, including repository filter +- Desktop manager: 39 focused native tests pass, including repository filter isolation, dropped/unresponsive child supervision, real SQLite page-limit failure, and recovery after interruption at eight durable update transitions. + Git cancellation also owns and reaps the child before releasing its permit, + and metadata snapshots reject nonregular files and symlinks. The interruption matrix preserves the old tuple before completion and the new tuple after the atomic completion marker; matching private data is checked. -- Three explicitly enabled real-child tests passed again after Git and supervision changes: + Uninstall waits for an update at three transaction stages, then removes the + committed candidate and private state; restart does not restore it. +- Four explicitly enabled real-child tests passed after the final changes: broker scope/disposal, Project Brief installer/Git/view/composer request, and - Issue Companion installer/view/recorded HTTP states/explicit draft action. - These tests supply the frontend effect result; they are not stock-app GUI E2E. + Issue Companion installer/view/recorded HTTP states/explicit draft action, + and quarantine of five hostile workloads plus unexpected child exit while a + second native plugin remains usable. Each failing generation is reaped within + two seconds, its context is revoked, and implicit restart is denied. + The example tests supply the frontend effect result; they are not stock-app GUI E2E. - Frontend TypeScript passes. 87 affected tests passed, plus 10 new tests for delayed effects during disable/remove/pause/workspace/thread/failure, stale inventory responses, update/rollback remounting and explicit retry. Composer tests include preservation of user input. Six repository theme/UUID checks and - a new Settings dialog Escape/focus regression test also pass. No theme or footer + Settings dialog Escape/focus and late composer-registration regression tests + also pass. Panels react to replacement or ambiguity in their workspace + composer registry. No theme or footer customization subsystem was repurposed. - Both examples build/check/pack with packed SDK/CLI tarballs. Issue Companion also builds/checks/packs outside the app checkout using those packages only. @@ -64,14 +77,20 @@ publication and Settings remain separate deliverables. - Hosted desktop CI passed on Linux and Windows, including Windows native OS keyring and the installed independent packages. Initial frontend CI identified semantic-color and UUID-helper violations; both are corrected and checked. + The next run exposed Vitest discovering a Node-only ELF test: it is now named + outside Vitest's discovery pattern and still runs explicitly with Node. The + native TLS/recovery tests passed on Windows at `5a643feb`. - Windows NSIS installation, exact bundled-host digest, clean-environment SDK callbacks and five hostile workloads passed in [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35368002067). Maximum measured fault latency was 1011.9 ms on the 4-vCPU AMD EPYC runner. See [Windows payload evidence](evidence/packaged-windows.json), collected from commit `68e07cd1`; this does not establish desktop GUI behavior. -- Linux deb/AppImage build succeeded. The deb payload passed SDK and hostile-host - checks. AppImage provenance initially failed because linuxdeploy patches ELF - RPATH; the corrected semantic provenance check awaits the next packaged run. +- Linux deb and AppImage payloads passed at `5a643feb` in + [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35371843758): + exact deb digest, AppImage ELF provenance, SDK callbacks and five hostile + workloads in each. Maximum fault latency was 1005.4 ms on the 4-vCPU AMD EPYC + runner. See [Linux payload evidence](evidence/packaged-linux.json). + This resolves the linuxdeploy RPATH check failure; it is not desktop GUI E2E. - Standalone release-host timing: 20 samples each of five hostile workloads; maximum 1010.4 ms, activation-loop p95 1007.2 ms on Ryzen 5 7600 / Linux. See [machine-readable evidence](evidence/host-timing-linux.json). These are @@ -132,6 +151,10 @@ publication and Settings remain separate deliverables. sections and symbol/dynamic-link semantics, allowing only the `$ORIGIN` RPATH relocation. Tests accept a real patchelf rewrite and reject changed program data or added dependencies. Deb and NSIS retain exact SHA-256 comparison. +- A dropped Git broker future cancels a separately owned job; its concurrency + permit remains held until the child is killed and reaped. Unix metadata opens + reject symlinks and use nonblocking mode to prevent a swapped FIFO from + stranding the filesystem worker. - Frontend stop actions fence effects synchronously until inventory refresh completes. Older inventory responses cannot restore stale enabled state. Native generation changes remount views; failed releases still need explicit Retry. @@ -140,15 +163,18 @@ publication and Settings remain separate deliverables. - Stock built-app E2E for both examples, including real frontend/native IPC, actual controlled draft insertion and core UI operation during hostile plugins. -- Corrected Linux AppImage provenance/smoke and latest-commit packaged rerun. - Windows NSIS install/uninstall and bundled-host behavior passed; stock desktop - GUI launch and integration still require separate evidence. +- Latest-commit packaged rerun after the final integration follow-ups. Linux + AppImage/deb and Windows NSIS payload behavior passed on recorded revisions; + stock desktop GUI launch and integration still require separate evidence. - Complete failure-injection matrix: disk-full boundaries, every journal/crash point, unexpected child exit/ignored shutdown, concurrent update/uninstall, - and delayed workspace/thread/composer races through the actual desktop. -- Hosted rerun of the new native TLS and recovery tests on Windows. Native OS - credential checks pass on both platforms; installed-app cancellation races - and external network behavior remain part of desktop E2E. + and delayed workspace/thread/composer races through the actual desktop. Native + update/uninstall serialization and dropped Git requests are covered, but those + do not establish all installed-app race cases. +- Latest-commit hosted native checks after the cancellation and concurrency + follow-ups. Native TLS, recovery and OS credential checks have passed on both + platforms; installed-app cancellation races and external network behavior + remain part of desktop E2E. - Complete keyboard/screen-reader, light/dark, small-window, chat-GUI-off and core pane restoration evidence; measured runtime fault and UI budgets with hardware/workload details. diff --git a/docs/addons/evidence/README.md b/docs/addons/evidence/README.md index caa89060..237bfdb0 100644 --- a/docs/addons/evidence/README.md +++ b/docs/addons/evidence/README.md @@ -1,10 +1,17 @@ -# Browser evidence +# Verification artifacts -All images use the repository's synthetic Tauri mock. They establish visual +All PNG images use the repository's synthetic Tauri mock. They establish visual layout only, not native plugin operation. - `core-before.png`: baseline workspace without the plugin platform. - `core-after.png`: implementation workspace with zero installed add-ons. - `settings-after.png`: permanent Add-ons Settings, default empty state. +- `settings-small-dark.png`, `settings-small-light.png`: 800×600 Settings. +- `settings-chat-gui-off.png`: Add-ons Settings remains available with chat GUI off. + +`host-timing-linux.json` contains standalone host timing samples. +`packaged-windows.json` records installed NSIS payload checks from `68e07cd1`. +`packaged-linux.json` records deb/AppImage payload checks from `5a643feb`. +These JSON files cover runtime containment, not stock desktop GUI behavior. Native and packaged verification is tracked separately in ../IMPLEMENTATION.md. diff --git a/docs/addons/evidence/packaged-linux.json b/docs/addons/evidence/packaged-linux.json new file mode 100644 index 00000000..8d98b3eb --- /dev/null +++ b/docs/addons/evidence/packaged-linux.json @@ -0,0 +1,65 @@ +{ + "platform": "linux", + "os": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "hostSha256": "596d0feca78ac8d29f757023b025cf6834393878c43837919cc9d5bb3a8cb24a", + "bundles": [ + { + "format": ".deb", + "packagedSha256": "596d0feca78ac8d29f757023b025cf6834393878c43837919cc9d5bb3a8cb24a", + "provenance": "exact-sha256", + "hostPath": "usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1005.3854839999999 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 3.4434689999998227 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 2.564151999999922 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1002.983788 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.0863899999999376 + } + ] + }, + { + "format": ".AppImage", + "packagedSha256": "99deefd50560c56c965e1c431ee3b10dc891d50a4dca1bc7632ccbc9d7221ac3", + "provenance": "elf-sections-and-dynamic-metadata", + "hostPath": "squashfs-root/usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1003.4534299999996 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 3.559374000000389 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 2.632434000000103 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.2999260000006 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.2605430000003253 + } + ] + } + ] +} diff --git a/scripts/addons/elf-provenance.test.mjs b/scripts/addons/elf-provenance-check.mjs similarity index 100% rename from scripts/addons/elf-provenance.test.mjs rename to scripts/addons/elf-provenance-check.mjs diff --git a/src-tauri/addon-protocol/tests/ui.rs b/src-tauri/addon-protocol/tests/ui.rs index f15943e1..7d6eb5c6 100644 --- a/src-tauri/addon-protocol/tests/ui.rs +++ b/src-tauri/addon-protocol/tests/ui.rs @@ -53,3 +53,21 @@ fn nested_and_oversized_trees_fail_before_render() { .apply(&[json!([0,"~",{"id":"root","type":1,"element":"cmx-stack","children":children},0])]) .is_err()); } + +#[test] +fn mutation_and_serialized_tree_limits_reject_atomically() { + let mut tree = Tree::default(); + tree.apply(&[json!([0,"~",{"id":"text","type":3,"data":"original"},0])]) + .unwrap(); + let flood = vec![json!([2, "text", "changed"]); 1001]; + assert!(tree.apply(&flood).is_err()); + assert_eq!(tree.children[0].data.as_deref(), Some("original")); + let large = (0..9) + .map(|i| json!({"id":format!("child-{i}"),"type":3,"data":"x".repeat(32768)})) + .collect::>(); + assert!(tree + .apply(&[json!([0,"~",{"id":"large","type":1,"element":"cmx-stack","children":large},1])]) + .is_err()); + assert_eq!(tree.children.len(), 1); + assert_eq!(tree.children[0].data.as_deref(), Some("original")); +} diff --git a/src-tauri/src/addons/git.rs b/src-tauri/src/addons/git.rs index 2128c4c6..7071892a 100644 --- a/src-tauri/src/addons/git.rs +++ b/src-tauri/src/addons/git.rs @@ -48,11 +48,23 @@ impl Git { return Ok(summary.clone()); } } - let _slot = self - .slots - .try_acquire() - .map_err(|_| ProtocolError::new(ErrorCode::ResourceLimit, "Too many Git requests"))?; - let bytes = run(&workspace.root, cancel).await?; + let slot = + self.slots.clone().try_acquire_owned().map_err(|_| { + ProtocolError::new(ErrorCode::ResourceLimit, "Too many Git requests") + })?; + // The broker can drop its request future before our cancellation select + // runs. Keep ownership (and the concurrency permit) in a separate task + // until the child is explicitly killed and reaped. + let job_cancel = cancel.child_token(); + let _cancel_on_drop = job_cancel.clone().drop_guard(); + let root = workspace.root.clone(); + let job = tokio::spawn(async move { + let _slot = slot; + run(&root, &job_cancel).await + }); + let bytes = job + .await + .map_err(|_| ProtocolError::new(ErrorCode::PluginStopped, "Git operation stopped"))??; let summary = parse(&bytes)?; *self.cache.lock().await = Some((key, Instant::now(), summary.clone())); Ok(summary) @@ -158,11 +170,24 @@ async fn copy_metadata( deadline: tokio::time::Instant, ) -> Result<()> { let work = async { - let file = match tokio::fs::File::open(source).await { - Ok(file) => file, + let metadata = match tokio::fs::symlink_metadata(source).await { + Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(error) => return Err(snapshot_error(error)), }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(snapshot_error("Git metadata is not a regular file")); + } + let mut options = tokio::fs::OpenOptions::new(); + options.read(true); + // A repository must not strand a blocking filesystem worker by swapping + // an index/HEAD for a FIFO between metadata inspection and opening it. + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + let file = options.open(source).await.map_err(snapshot_error)?; + if !file.metadata().await.map_err(snapshot_error)?.is_file() { + return Err(snapshot_error("Git metadata is not a regular file")); + } let mut bytes = Vec::new(); file.take(*remaining as u64 + 1) .read_to_end(&mut bytes) @@ -569,6 +594,80 @@ mod tests { assert_eq!(summary.branch, None); assert_eq!(summary.paths, ["untracked"]); } + #[cfg(unix)] + #[tokio::test] + async fn dropped_broker_request_cancels_and_reaps_its_blocked_git_child() { + let root = tempfile::tempdir().unwrap(); + repository(root.path()); + let config = root.path().join(".git/config"); + std::fs::remove_file(&config).unwrap(); + use std::os::unix::ffi::OsStrExt; + let path = std::ffi::CString::new(config.as_os_str().as_bytes()).unwrap(); + // Block real Git during config discovery without running repository code. + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + let git = Git::default(); + let clone = git.clone(); + let context = CancellationToken::new(); + let token = context.clone(); + let workspace = Workspace { + id: "fixture".into(), + name: "Fixture".into(), + root_name: "fixture".into(), + location: "local", + root: root.path().to_path_buf(), + }; + let request = tokio::spawn(async move { clone.summary(&workspace, &token).await }); + tokio::time::timeout(Duration::from_secs(1), async { + while git.slots.available_permits() == 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + assert_eq!(git.slots.available_permits(), 1); + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(2), async { + while git.slots.available_permits() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("Git job must kill/reap before releasing its permit"); + assert!( + !context.is_cancelled(), + "Only the abandoned job is cancelled" + ); + } + #[cfg(unix)] + #[tokio::test] + async fn metadata_snapshot_rejects_fifos_and_symlinks_without_blocking() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("metadata"); + use std::os::unix::ffi::OsStrExt; + let path = std::ffi::CString::new(source.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + for attempt in 0..2 { + let result = tokio::time::timeout( + Duration::from_millis(500), + copy_metadata( + &source, + &root.path().join("copy"), + &mut 4096, + &CancellationToken::new(), + tokio::time::Instant::now() + Duration::from_secs(1), + ), + ) + .await + .unwrap(); + assert!(result.is_err()); + if attempt == 0 { + std::fs::remove_file(&source).unwrap(); + std::os::unix::fs::symlink("/dev/null", &source).unwrap(); + } + } + } #[tokio::test] async fn unborn_repository_and_cancelled_context_are_handled() { let root = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/addons/lifecycle.rs b/src-tauri/src/addons/lifecycle.rs index a2e1b8f4..060b4f18 100644 --- a/src-tauri/src/addons/lifecycle.rs +++ b/src-tauri/src/addons/lifecycle.rs @@ -54,7 +54,13 @@ struct Pending { pub struct Reviews { pending: Mutex>, #[cfg(test)] - interruption: Mutex)>>, + interruption: Mutex< + Option<( + &'static str, + Arc, + Option>, + )>, + >, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -98,13 +104,17 @@ impl Reviews { .lock() .unwrap() .as_ref() - .filter(|(expected, _)| *expected == stage) - .map(|(_, notify)| notify.clone()); - if let Some(notify) = notify { + .filter(|(expected, _, _)| *expected == stage) + .map(|(_, notify, resume)| (notify.clone(), resume.clone())); + if let Some((notify, resume)) = notify { notify.notify_one(); // The test drops this transaction future here, exactly as an // interrupted process would abandon it without error-path cleanup. - std::future::pending::<()>().await; + if let Some(resume) = resume { + resume.notified().await; + } else { + std::future::pending::<()>().await; + } } } pub fn prepare_local(&self, manager: &Manager, path: &Path) -> Result { @@ -1152,7 +1162,7 @@ mod tests { .prepare(&manager, package_version("2.0.0"), old.source.clone()) .unwrap(); let reached = Arc::new(tokio::sync::Notify::new()); - *reviews.interruption.lock().unwrap() = Some((stage, reached.clone())); + *reviews.interruption.lock().unwrap() = Some((stage, reached.clone(), None)); tokio::select! { result = reviews.accept(&manager, &review.token, false, false) => panic!("{stage}: transaction completed before interruption: {}", result.is_ok()), _ = reached.notified() => {}, @@ -1205,6 +1215,82 @@ mod tests { ); } } + #[tokio::test] + async fn uninstall_waits_for_update_and_removes_the_committed_candidate() { + for stage in [ + "data-snapshotted", + "registry-switched", + "completion-committed", + ] { + let root = tempfile::tempdir().unwrap(); + let manager = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + let reviews = Reviews::default(); + let first = reviews + .prepare( + &manager, + package_version("1.0.0"), + Source::Local { + identity: Uuid::new_v4().to_string(), + }, + ) + .unwrap(); + let old = reviews + .accept(&manager, &first.token, false, false) + .await + .unwrap(); + let review = reviews + .prepare(&manager, package_version("2.0.0"), old.source.clone()) + .unwrap(); + let reached = Arc::new(tokio::sync::Notify::new()); + let resume = Arc::new(tokio::sync::Notify::new()); + *reviews.interruption.lock().unwrap() = + Some((stage, reached.clone(), Some(resume.clone()))); + let update = reviews.accept(&manager, &review.token, false, false); + tokio::pin!(update); + tokio::select! { + result = &mut update => panic!("{stage}: update escaped checkpoint: {}", result.is_ok()), + _ = reached.notified() => {}, + _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => panic!("{stage}: checkpoint not reached"), + } + let removal = manager.remove(&old.manifest.id, false); + tokio::pin!(removal); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(30), &mut removal) + .await + .is_err(), + "{stage}: removal overlapped update" + ); + resume.notify_one(); + let (updated, removed) = + tokio::time::timeout(std::time::Duration::from_secs(3), async { + tokio::join!(&mut update, &mut removal) + }) + .await + .expect("serialized operations finish"); + assert_eq!(updated.unwrap().digest, review.digest, "{stage}"); + assert!(removed.unwrap().is_empty(), "{stage}"); + assert!(manager.list().unwrap().is_empty(), "{stage}"); + assert!( + !manager + .root + .join("state") + .join(&old.installation_id) + .exists(), + "{stage}" + ); + assert!( + !manager + .root + .join("packages") + .join(&old.manifest.id) + .join(&review.digest) + .exists(), + "{stage}" + ); + let reopened = Manager::open(root.path().join("private"), "unused".into()).unwrap(); + assert!(reopened.list().unwrap().is_empty(), "{stage}"); + } + } #[test] fn incomplete_update_restores_exact_previous_tuple_and_private_snapshot() { let root = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/addons/manager.rs b/src-tauri/src/addons/manager.rs index d4623b35..9a47edb8 100644 --- a/src-tauri/src/addons/manager.rs +++ b/src-tauri/src/addons/manager.rs @@ -1519,6 +1519,97 @@ mod tests { .is_err()); } #[tokio::test] + #[ignore = "Requires the independently built host; run scripts/addons/test-native.sh"] + async fn native_runtime_faults_quarantine_one_plugin_and_preserve_another() { + let root = tempfile::tempdir().unwrap(); + let manager = Manager::open(root.path().into(), test_host_path()).unwrap(); + let manifest = Manifest::parse( + include_bytes!("../../addon-protocol/fixtures/hello.json"), + None, + ) + .unwrap(); + let mut healthy_manifest = manifest.clone(); + healthy_manifest.id = "example.healthy".into(); + let healthy_install = installed(healthy_manifest); + manager.save(&healthy_install).unwrap(); + let healthy_source = "__codemuxRegister({}, ({send}) => m => {if(m.method==='activate')send('ready',{phase:'activated',registrations:['commands/hello']});});"; + let healthy = manager + .activate(healthy_install, healthy_source.into(), false) + .await + .unwrap(); + let failing_installation = installed(manifest.clone()); + for code in [ + "throw Error('private failure details')", + "while(true){}", + "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "function f(){f()} f()", + "new ArrayBuffer(128*1024*1024)", + // Ask only the child to exit: the manager has no pending stop and + // must treat the resulting EOF as an unexpected runtime failure. + "", + ] { + let installation = failing_installation.clone(); + manager.save(&installation).unwrap(); + let source = format!("__codemuxRegister({{}}, ({{send}}) => m => {{if(m.method==='activate')send('ready',{{phase:'activated',registrations:['commands/hello']}});else if(m.method==='command.execute'){{{code}}}}});"); + let failing = manager.activate(installation, source, false).await.unwrap(); + let context = manager.context_handle(&failing, None, None).await.unwrap(); + let start = Instant::now(); + failing + .host + .send( + if code.is_empty() { + "deactivate" + } else { + "command.execute" + }, + json!({"id":"hello"}), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), failing.stopped.cancelled()) + .await + .expect("fault contained and child reaped within two seconds"); + assert!(start.elapsed() < Duration::from_secs(2)); + assert!(matches!( + manager.installation(&manifest.id).unwrap().status, + Status::FailedDisabled + )); + assert!(manager + .contexts + .lock() + .await + .get(&context, failing.generation()) + .is_err()); + assert!( + manager.ensure_active(&manifest.id).await.is_err(), + "a fault cannot auto-restart the plugin" + ); + assert!(!healthy.cancel.is_cancelled()); + assert_eq!(manager.running.lock().await.len(), 1); + manager + .request( + &healthy, + "storage.set", + json!({"scope":"global","key":"alive","value":true}), + ) + .await + .unwrap(); + assert_eq!( + manager + .request( + &healthy, + "storage.get", + json!({"scope":"global","key":"alive"}) + ) + .await + .unwrap(), + json!(true) + ); + } + manager.shutdown().await; + assert!(manager.running.lock().await.is_empty()); + } + #[tokio::test] #[ignore = "Build the independent Project Brief package and host first"] async fn native_project_brief_package_uses_installer_git_ui_and_composer_broker() { let root = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/addons/package.rs b/src-tauri/src/addons/package.rs index 73cb70a9..c30670ed 100644 --- a/src-tauri/src/addons/package.rs +++ b/src-tauri/src/addons/package.rs @@ -177,8 +177,12 @@ mod tests { header.set_entry_type(kind); header.set_size(0); header.set_mode(0o777); + // Write the raw field: tar::Builder::append_data would sanitize or + // reject traversal itself, preventing us from testing our reader. + assert!(name.len() < 100); + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); header.set_cksum(); - tar.append_data(&mut header, name, &[][..]).unwrap(); + tar.append(&header, &[][..]).unwrap(); } let bytes = tar.into_inner().unwrap(); let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); @@ -195,6 +199,16 @@ mod tests { ("manifest.json", tar::EntryType::Regular), ("plugin.js", tar::EntryType::Symlink), ("unexpected.exe", tar::EntryType::Regular), + ("../plugin.js", tar::EntryType::Regular), + ("/plugin.js", tar::EntryType::Regular), + ("C:/plugin.js", tar::EntryType::Regular), + ("..\\plugin.js", tar::EntryType::Regular), + ("//server/share/plugin.js", tar::EntryType::Regular), + ("plugin.js:stream", tar::EntryType::Regular), + ("PLUGIN.JS", tar::EntryType::Regular), + ("plugin.js", tar::EntryType::Link), + ("plugin.js", tar::EntryType::Fifo), + ("plugin.js", tar::EntryType::GNULongName), ] { assert!(Package::parse(archive(Some((name, kind))), None).is_err()); } diff --git a/src/components/addons/addon-view.test.tsx b/src/components/addons/addon-view.test.tsx index a9278cd3..4564f24d 100644 --- a/src/components/addons/addon-view.test.tsx +++ b/src/components/addons/addon-view.test.tsx @@ -4,6 +4,7 @@ vi.mock("@/lib/addons/bridge", () => ({ addonInvoke: vi.fn().mockResolvedValue(null), mountAddon: vi.fn(), })); +import { registerAddonComposer } from "@/lib/addons/composer-registry"; import { AddonView } from "./addon-view"; import { addonInvoke, mountAddon } from "@/lib/addons/bridge"; import { useAddonsStore } from "@/stores/addons-store"; @@ -65,6 +66,88 @@ describe("view lifecycle across native generations", () => { expect.objectContaining({ generation: "generation-2" }), ); }); + it("rebinds a panel when its composer registers late, becomes ambiguous, or closes", async () => { + const cleanupComposers: Array<() => void> = []; + try { + render( + , + ); + await waitFor(() => + expect(mountAddon).toHaveBeenLastCalledWith( + "test.plugin", + "panel", + "panels", + "workspace", + null, + ), + ); + await act(async () => { + cleanupComposers.push( + registerAddonComposer("first", { + workspaceId: "workspace", + append: () => 1, + }), + ); + }); + await waitFor(() => + expect(mountAddon).toHaveBeenLastCalledWith( + "test.plugin", + "panel", + "panels", + "workspace", + "first", + ), + ); + await act(async () => { + cleanupComposers.push( + registerAddonComposer("second", { + workspaceId: "workspace", + append: () => 1, + }), + ); + }); + await waitFor(() => + expect(mountAddon).toHaveBeenLastCalledWith( + "test.plugin", + "panel", + "panels", + "workspace", + null, + ), + ); + await act(async () => { + cleanupComposers.shift()!(); + }); + await waitFor(() => + expect(mountAddon).toHaveBeenLastCalledWith( + "test.plugin", + "panel", + "panels", + "workspace", + "second", + ), + ); + await act(async () => { + cleanupComposers.shift()!(); + }); + await waitFor(() => + expect(mountAddon).toHaveBeenLastCalledWith( + "test.plugin", + "panel", + "panels", + "workspace", + null, + ), + ); + expect(addonInvoke).toHaveBeenCalledWith( + "addon_unmount", + expect.objectContaining({ viewId: "view-1" }), + ); + } finally { + cleanup(); + for (const remove of cleanupComposers) remove(); + } + }); it("keeps a failed release inert until explicit retry changes its native status", async () => { render(); await waitFor(() => expect(mountAddon).toHaveBeenCalledTimes(1)); diff --git a/src/components/addons/addon-view.tsx b/src/components/addons/addon-view.tsx index 50fbfe2a..4527323d 100644 --- a/src/components/addons/addon-view.tsx +++ b/src/components/addons/addon-view.tsx @@ -1,7 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { addonTreeKey, useAddonsStore } from "@/stores/addons-store"; import { addonInvoke, mountAddon } from "@/lib/addons/bridge"; -import { composerForWorkspace } from "@/lib/addons/composer-registry"; +import { + composerForWorkspace, + subscribeAddonComposers, +} from "@/lib/addons/composer-registry"; import { addonMessage, addonEnabled, @@ -22,6 +25,11 @@ export function AddonView({ kind?: "panels" | "composerViews"; composerId?: string; }) { + const currentComposer = useSyncExternalStore( + subscribeAddonComposers, + () => composerId ?? composerForWorkspace(workspaceId), + () => null, + ); const ready = useAddonsStore((s) => s.ready); const revision = useAddonsStore((s) => s.contextRevision); const hostEpoch = useAddonsStore((s) => s.hostEpochs[id] ?? 0); @@ -59,13 +67,7 @@ export function AddonView({ })); } }; - void mountAddon( - id, - view, - kind, - workspaceId, - composerId ?? composerForWorkspace(workspaceId), - ) + void mountAddon(id, view, kind, workspaceId, currentComposer) .then((result) => { target = result; if (closed) unmount(); @@ -83,7 +85,7 @@ export function AddonView({ view, workspaceId, kind, - composerId, + currentComposer, ready, revision, canMount, diff --git a/src/lib/addons/composer-registry.ts b/src/lib/addons/composer-registry.ts index d738429f..d523c3eb 100644 --- a/src/lib/addons/composer-registry.ts +++ b/src/lib/addons/composer-registry.ts @@ -4,13 +4,27 @@ export interface ComposerTarget { append(text: string): number; } const composers = new Map(); +const listeners = new Set<() => void>(); +export function subscribeAddonComposers(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} +function changed() { + for (const listener of listeners) listener(); +} export function registerAddonComposer( id: string, target: ComposerTarget, ): () => void { composers.set(id, target); + changed(); return () => { - if (composers.get(id) === target) composers.delete(id); + if (composers.get(id) === target) { + composers.delete(id); + changed(); + } }; } export function composerForWorkspace(workspaceId: string): string | null { From 680d15dce2f10285725235262ab86c83d65d2984 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 19:44:50 +0200 Subject: [PATCH 004/155] perf(addons): bound native UI batch validation cost --- docs/addons/IMPLEMENTATION.md | 29 +++-- docs/addons/evidence/README.md | 7 +- docs/addons/evidence/packaged-windows.json | 16 ++- docs/addons/evidence/ui-validation-linux.json | 61 +++++++++ packages/plugin-sdk/README.md | 9 ++ .../examples/ui-validation-benchmark.rs | 27 ++++ src-tauri/addon-protocol/src/ui.rs | 123 +++++++++++++++++- src-tauri/addon-protocol/tests/ui.rs | 32 +++++ 8 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 docs/addons/evidence/ui-validation-linux.json create mode 100644 src-tauri/addon-protocol/examples/ui-validation-benchmark.rs diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index 0bf236b1..833f299b 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -23,8 +23,8 @@ standalone host tests, browser mocks, or the research probe is not release appro Later PRs stack on their specified prerequisites. Full desktop CI was enabled for the stack in PR 8; earlier draft heads do not each have full desktop CI -evidence and must be revalidated as they are prepared for merging. The native catalog reader and -transactional installer are included in the manager foundation because its grant, +evidence and must be revalidated as they are prepared for merging. The native +catalog reader and transactional installer are included in the manager foundation because its grant, activation, removal and recovery paths must share one authority boundary. Catalog publication and Settings remain separate deliverables. @@ -32,7 +32,7 @@ publication and Settings remain separate deliverables. - PR 1 hosted CI: Linux and Windows GNU host, manifest contracts, SDK callback integration all passed. Schema comparison normalizes Windows CRLF only. -- Protocol: 11 focused tests pass, including atomic malformed UI rejection, +- Protocol: 14 focused tests pass, including atomic malformed UI rejection, callback disposal, mutation/tree limits, catalog ownership/release history and strict manifests. Raw archive validation covers traversal, absolute/Windows/UNC paths, alternate data streams, case collisions, links and special entries. @@ -62,8 +62,7 @@ publication and Settings remain separate deliverables. tests include preservation of user input. Six repository theme/UUID checks and Settings dialog Escape/focus and late composer-registration regression tests also pass. Panels react to replacement or ambiguity in their workspace - composer registry. No theme or footer - customization subsystem was repurposed. + composer registry. No theme or footer customization subsystem was repurposed. - Both examples build/check/pack with packed SDK/CLI tarballs. Issue Companion also builds/checks/packs outside the app checkout using those packages only. - Actual Linux Secret Service round-trip and deletion passed using a synthetic @@ -74,17 +73,17 @@ publication and Settings remain separate deliverables. rejection, redirect/encoding/declared/streamed body limits, timeout/cancellation, concurrency release and private rolling quotas. The local fixture CA is trusted only by the test's client; no system trust or real credentials are used. -- Hosted desktop CI passed on Linux and Windows, including Windows native OS +- Hosted native desktop jobs passed on Linux and Windows, including Windows native OS keyring and the installed independent packages. Initial frontend CI identified semantic-color and UUID-helper violations; both are corrected and checked. The next run exposed Vitest discovering a Node-only ELF test: it is now named outside Vitest's discovery pattern and still runs explicitly with Node. The native TLS/recovery tests passed on Windows at `5a643feb`. - Windows NSIS installation, exact bundled-host digest, clean-environment SDK - callbacks and five hostile workloads passed in [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35368002067). - Maximum measured fault latency was 1011.9 ms on the 4-vCPU AMD EPYC runner. + callbacks and five hostile workloads passed in [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35371843758). + Maximum measured fault latency was 1016.5 ms on the 4-vCPU AMD EPYC runner. See [Windows payload evidence](evidence/packaged-windows.json), collected from - commit `68e07cd1`; this does not establish desktop GUI behavior. + commit `5a643feb`; this does not establish desktop GUI behavior. - Linux deb and AppImage payloads passed at `5a643feb` in [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35371843758): exact deb digest, AppImage ELF provenance, SDK callbacks and five hostile @@ -155,6 +154,18 @@ publication and Settings remain separate deliverables. permit remains held until the child is killed and reaped. Unix metadata opens reject symlinks and use nonblocking mode to prevent a swapped FIFO from stranding the filesystem worker. +- Release-mode validation of 1,000 text mutations in a valid 1,999-node, + 222,790-byte tree measured 302–499 ms on this Linux Ryzen 5 7600 host. + Revalidating and serializing the entire tree after each content change made + the advertised batch ceiling too expensive. Content updates now account for + exact serialized-size deltas and validate only the changed value. Structural + and callback changes still validate the whole candidate. A 50 ms wall-clock + validation budget rejects an expensive batch atomically and quarantines the + generation through the existing resource-failure path. This extra bound is + justified by the measured native CPU cost; it does not replace desktop frame + timing or claim a universal latency on all machines. The same workload now + measures 2.5–3.9 ms; [before/after samples](evidence/ui-validation-linux.json) + and the reproducible protocol example are included. - Frontend stop actions fence effects synchronously until inventory refresh completes. Older inventory responses cannot restore stale enabled state. Native generation changes remount views; failed releases still need explicit Retry. diff --git a/docs/addons/evidence/README.md b/docs/addons/evidence/README.md index 237bfdb0..149dc67e 100644 --- a/docs/addons/evidence/README.md +++ b/docs/addons/evidence/README.md @@ -10,8 +10,13 @@ layout only, not native plugin operation. - `settings-chat-gui-off.png`: Add-ons Settings remains available with chat GUI off. `host-timing-linux.json` contains standalone host timing samples. -`packaged-windows.json` records installed NSIS payload checks from `68e07cd1`. +`packaged-windows.json` records installed NSIS payload checks from `5a643feb`. `packaged-linux.json` records deb/AppImage payload checks from `5a643feb`. These JSON files cover runtime containment, not stock desktop GUI behavior. Native and packaged verification is tracked separately in ../IMPLEMENTATION.md. + +`ui-validation-linux.json` compares native validation before and after incremental +content validation. Reproduce the workload with +`cargo run --release -j 2 --manifest-path src-tauri/addon-protocol/Cargo.toml --example ui-validation-benchmark`. +It excludes IPC and desktop rendering. diff --git a/docs/addons/evidence/packaged-windows.json b/docs/addons/evidence/packaged-windows.json index 5e758dcb..eef4dab6 100644 --- a/docs/addons/evidence/packaged-windows.json +++ b/docs/addons/evidence/packaged-windows.json @@ -1,33 +1,35 @@ { "platform": "win32", "os": "10.0.26100", - "cpu": "AMD EPYC 9V74 80-Core Processor ", + "cpu": "AMD EPYC 7763 64-Core Processor ", "logicalCpus": 4, - "hostSha256": "e52ddcb7d3d5c982ab033c9a90a97a13b9ca5b341e1699037cc0bd602eda661a", + "hostSha256": "3bb02ce44d035459a3c6f6b4cadad112007d9f403ae9018380712b500363dd9a", "bundles": [ { "format": ".exe", + "packagedSha256": "3bb02ce44d035459a3c6f6b4cadad112007d9f403ae9018380712b500363dd9a", + "provenance": "exact-sha256", "hostPath": "binaries\\codemux-addon-host-windows-x64.exe", "faults": [ { "workload": "while(true){}", - "elapsedMs": 1011.8606999999993 + "elapsedMs": 1016.4846999999991 }, { "workload": "function f(){f()} f()", - "elapsedMs": 10.281199999999444 + "elapsedMs": 13.874699999998484 }, { "workload": "new ArrayBuffer(128*1024*1024)", - "elapsedMs": 8.343700000001263 + "elapsedMs": 13.774599999998827 }, { "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", - "elapsedMs": 1009.1594999999998 + "elapsedMs": 1015.4512999999988 }, { "workload": "throw Error('fixture')", - "elapsedMs": 9.226599999999962 + "elapsedMs": 13.554199999998673 } ] } diff --git a/docs/addons/evidence/ui-validation-linux.json b/docs/addons/evidence/ui-validation-linux.json new file mode 100644 index 00000000..024b5b47 --- /dev/null +++ b/docs/addons/evidence/ui-validation-linux.json @@ -0,0 +1,61 @@ +{ + "scope": "native tree validation only; excludes desktop rendering", + "profile": "release", + "cpu": "AMD Ryzen 5 7600 6-Core Processor", + "os": "Linux-7.2.5-3-omarchy-x86_64-with-glibc2.44", + "beforeRevision": "abbef66f", + "before": { + "mutations": 1000, + "nodes": 1999, + "samplesMs": [ + 309.27556899999996, + 325.363121, + 317.45625, + 335.17134799999997, + 367.932893, + 305.527473, + 302.401511, + 318.32661099999996, + 324.188074, + 317.057896, + 327.036234, + 317.15141600000004, + 311.552704, + 310.49523, + 340.684726, + 322.706556, + 308.452561, + 317.536766, + 354.593086, + 498.97656 + ], + "treeBytes": 222790 + }, + "after": { + "mutations": 1000, + "nodes": 1999, + "samplesMs": [ + 3.5989720000000003, + 3.3492819999999996, + 2.662969, + 2.6882989999999998, + 2.96279, + 2.650559, + 3.374612, + 2.815029, + 2.8049199999999996, + 2.489119, + 2.5001189999999998, + 3.0858600000000003, + 2.9734000000000003, + 2.88762, + 2.519508, + 2.740759, + 2.604579, + 3.894684, + 3.135761, + 2.602768 + ], + "treeBytes": 222790 + } +} diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 252e22af..4f3a7921 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -36,3 +36,12 @@ and `dist/types.d.ts`; `schema/manifest.json` is generated from the Rust contrac The desktop performs semantic validation in addition to schema validation. Components accept token-based properties, never CSS or arbitrary HTML. Hooks are Preact hooks. Browser libraries requiring a real browser DOM are unsupported. + +## Rendering limits + +A mounted view is limited to 2,000 nodes, depth 32, and 256 KiB of serialized +state. A batch may contain at most 1,000 mutations; intermediate states must also +fit the limits. Native validation has a 50 ms wall-clock budget per batch. A +resource violation rejects the whole batch and stops that plugin generation. +Prefer small updates to existing content over replacing large subtrees. These +ceilings are bounds, not a guaranteed update rate on every machine. diff --git a/src-tauri/addon-protocol/examples/ui-validation-benchmark.rs b/src-tauri/addon-protocol/examples/ui-validation-benchmark.rs new file mode 100644 index 00000000..d236c2bf --- /dev/null +++ b/src-tauri/addon-protocol/examples/ui-validation-benchmark.rs @@ -0,0 +1,27 @@ +use codemux_addon_protocol::ui::Tree; +use serde_json::json; +use std::time::Instant; +fn main() { + let children = (0..1998) + .map(|i| json!({"id":format!("text-{i}"),"type":3,"data":"x".repeat(8)})) + .collect::>(); + let mut tree = Tree::default(); + tree.apply(&[ + json!([0,"~",{"id":"root","type":1,"element":"cmx-stack","children":children},0]), + ]) + .unwrap(); + let changes = (0..1000) + .map(|i| json!([2, format!("text-{i}"), "changed"])) + .collect::>(); + let mut samples = Vec::new(); + for _ in 0..20 { + let mut candidate = tree.clone(); + let start = Instant::now(); + candidate.apply(&changes).unwrap(); + samples.push(start.elapsed().as_secs_f64() * 1000.); + } + println!( + "{}", + json!({"nodes":1999,"mutations":1000,"treeBytes":serde_json::to_vec(&tree).unwrap().len(),"samplesMs":samples}) + ); +} diff --git a/src-tauri/addon-protocol/src/ui.rs b/src-tauri/addon-protocol/src/ui.rs index d6b8acb8..d5dfe63a 100644 --- a/src-tauri/addon-protocol/src/ui.rs +++ b/src-tauri/addon-protocol/src/ui.rs @@ -1,8 +1,11 @@ //! Validate the entire Remote DOM batch before committing a normalized tree. -use crate::{limits, ProtocolError}; +use crate::{limits, ErrorCode, ProtocolError}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::{BTreeMap, HashSet}; +use std::{ + collections::{BTreeMap, HashSet}, + time::{Duration, Instant}, +}; type Result = std::result::Result; fn invalid() -> ProtocolError { ProtocolError::invalid("Invalid plugin UI") @@ -135,11 +138,29 @@ fn property(key: &str, v: &Value) -> bool { } impl Tree { pub fn apply(&mut self, records: &[Value]) -> Result<()> { + self.apply_started(records, Instant::now()) + } + fn apply_started(&mut self, records: &[Value], started: Instant) -> Result<()> { + let budget = || { + if started.elapsed() > Duration::from_millis(50) { + Err(ProtocolError::new( + ErrorCode::ResourceLimit, + "Plugin UI validation budget exceeded", + )) + } else { + Ok(()) + } + }; + budget()?; if records.len() > limits::MUTATIONS { return Err(invalid()); } let mut candidate = self.clone(); + candidate.validate()?; + let mut bytes = serde_json::to_vec(&candidate).map_err(|_| invalid())?.len(); for record in records { + budget()?; + let mut content_only = false; let r = record.as_array().ok_or_else(invalid)?; let kind = r.first().and_then(Value::as_u64).ok_or_else(invalid)?; let id = r.get(1).and_then(Value::as_str).ok_or_else(invalid)?; @@ -171,7 +192,13 @@ impl Tree { if ![3, 8].contains(&node.kind) { return Err(invalid()); } - node.data = Some(r[2].as_str().ok_or_else(invalid)?.into()); + if !string(&r[2], 32768) { + return Err(invalid()); + } + let next = r[2].as_str().ok_or_else(invalid)?; + bytes = bytes - json_len(&node.data)? + json_len(&next)?; + node.data = Some(next.into()); + content_only = true; } 3 if r.len() == 4 || r.len() == 5 => { let node = find_mut(&mut candidate.children, id).ok_or_else(invalid)?; @@ -179,13 +206,24 @@ impl Tree { return Err(invalid()); } let key = r[2].as_str().ok_or_else(invalid)?.to_string(); - let target = match r.get(4).and_then(Value::as_u64).unwrap_or(1) { + let mutation_kind = match r.get(4) { + None => 1, + Some(value) => value.as_u64().ok_or_else(invalid)?, + }; + let target = match mutation_kind { 1 if property(&key, &r[3]) => &mut node.properties, 3 if matches!(key.as_str(), "press" | "change") => { &mut node.event_listeners } _ => return Err(invalid()), }; + // Ordinary properties cannot change node/callback identity. + // Account for the exact JSON key/value and comma delta rather + // than serializing unrelated rows/text after every update. + if mutation_kind == 1 { + bytes = object_update_bytes(bytes, target, &key, &r[3])?; + content_only = true; + } if r[3].is_null() { target.remove(&key); } else { @@ -195,8 +233,17 @@ impl Tree { _ => return Err(invalid()), } // Bound growth during the batch, as well as the final committed state. - candidate.validate()?; + if content_only { + if bytes > limits::TREE { + return Err(invalid()); + } + } else { + candidate.validate()?; + bytes = serde_json::to_vec(&candidate).map_err(|_| invalid())?.len(); + } + budget()?; } + budget()?; *self = candidate; Ok(()) } @@ -217,6 +264,72 @@ impl Tree { .is_some_and(|v| v["callbackId"].as_str() == Some(callback_id)) } } +fn json_len(value: &impl Serialize) -> Result { + Ok(serde_json::to_vec(value).map_err(|_| invalid())?.len()) +} +fn object_update_bytes( + total: usize, + object: &BTreeMap, + key: &str, + value: &Value, +) -> Result { + match (object.get(key), value.is_null()) { + (Some(old), false) => Ok(total - json_len(old)? + json_len(value)?), + (Some(old), true) => { + Ok(total - json_len(&key)? - 1 - json_len(old)? - usize::from(object.len() > 1)) + } + (None, false) => { + Ok(total + json_len(&key)? + 1 + json_len(value)? + usize::from(!object.is_empty())) + } + (None, true) => Ok(total), + } +} +#[cfg(test)] +mod budget_tests { + use super::*; + #[test] + fn incremental_property_sizes_match_actual_json_for_insert_replace_and_remove() { + for initial in [ + BTreeMap::new(), + BTreeMap::from([("label".to_string(), serde_json::json!("old"))]), + BTreeMap::from([ + ("label".to_string(), serde_json::json!("old")), + ("title".into(), serde_json::json!("retained")), + ]), + ] { + for key in ["label", "new\"key"] { + for value in [ + serde_json::json!("escaped\n雪\""), + serde_json::json!(["first", "second"]), + Value::Null, + ] { + let expected = object_update_bytes( + 100 + json_len(&initial).unwrap(), + &initial, + key, + &value, + ) + .unwrap(); + let mut changed = initial.clone(); + if value.is_null() { + changed.remove(key); + } else { + changed.insert(key.into(), value); + } + assert_eq!(expected, 100 + json_len(&changed).unwrap()); + } + } + } + } + #[test] + fn exhausted_budget_cannot_commit_any_mutation() { + let mut tree = Tree::default(); + let expired = Instant::now() - Duration::from_millis(51); + let error = tree.apply_started(&[], expired).unwrap_err(); + assert_eq!(error.data.code, ErrorCode::ResourceLimit); + assert!(tree.children.is_empty()); + } +} fn validate_node<'a>( node: &'a Node, depth: usize, diff --git a/src-tauri/addon-protocol/tests/ui.rs b/src-tauri/addon-protocol/tests/ui.rs index 7d6eb5c6..b252214e 100644 --- a/src-tauri/addon-protocol/tests/ui.rs +++ b/src-tauri/addon-protocol/tests/ui.rs @@ -21,6 +21,8 @@ fn invalid_batch_never_partially_commits() { json!([0,"~",{"id":"c","type":1,"element":"script"},0]), json!([3, "a", "href", "https://attacker", 2]), json!([3, "a", "dangerouslySetInnerHTML", null, 1]), + json!([3, "a", "label", "invalid mode", "property"]), + json!([3, "a", "label", "invalid mode", null]), json!([0,"~",{"id":"z","type":1,"element":"cmx-text","properties":{"style":null}},0]), ] { assert!(tree.apply(&[record]).is_err()); @@ -71,3 +73,33 @@ fn mutation_and_serialized_tree_limits_reject_atomically() { assert_eq!(tree.children.len(), 1); assert_eq!(tree.children[0].data.as_deref(), Some("original")); } + +#[test] +fn escaped_content_updates_cannot_exceed_the_intermediate_tree_limit() { + let mut children = (0..7) + .map(|i| json!({"id":format!("large-{i}"),"type":3,"data":"x".repeat(32768)})) + .collect::>(); + children.push(json!({"id":"target","type":3,"data":"original"})); + let mut tree = Tree::default(); + tree.apply(&[ + json!([0,"~",{"id":"root","type":1,"element":"cmx-stack","children":children},0]), + ]) + .unwrap(); + for update in [ + vec![ + json!([2, "target", "\n".repeat(32768)]), + json!([2, "target", "small again"]), + ], + vec![ + json!([3, "root", "value", "\n".repeat(32768)]), + json!([3, "root", "value", null]), + ], + ] { + assert!(tree.apply(&update).is_err()); + assert_eq!( + tree.children[0].children[7].data.as_deref(), + Some("original") + ); + assert!(tree.children[0].properties.is_empty()); + } +} From 6d99721c3c20331cfdd13761ec0d1807dafe07b2 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 20:15:20 +0200 Subject: [PATCH 005/155] test: exercise installed add-ons in native desktop CI --- .github/workflows/addon-native-ui.yml | 73 +++ .github/workflows/addon-packaged.yml | 52 +- .gitignore | 3 + docs/addons/IMPLEMENTATION.md | 17 + scripts/addons/build-examples.sh | 6 + .../addons/fixtures/fault-isolation/LICENSE | 21 + .../addons/fixtures/fault-isolation/README.md | 6 + .../fixtures/fault-isolation/manifest.json | 30 ++ .../fixtures/fault-isolation/src/index.tsx | 13 + scripts/addons/install-webdriver.ps1 | 26 + scripts/addons/native-ui.mjs | 478 ++++++++++++++++++ 11 files changed, 719 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/addon-native-ui.yml create mode 100644 scripts/addons/fixtures/fault-isolation/LICENSE create mode 100644 scripts/addons/fixtures/fault-isolation/README.md create mode 100644 scripts/addons/fixtures/fault-isolation/manifest.json create mode 100644 scripts/addons/fixtures/fault-isolation/src/index.tsx create mode 100644 scripts/addons/install-webdriver.ps1 create mode 100644 scripts/addons/native-ui.mjs diff --git a/.github/workflows/addon-native-ui.yml b/.github/workflows/addon-native-ui.yml new file mode 100644 index 00000000..9a2b16e8 --- /dev/null +++ b/.github/workflows/addon-native-ui.yml @@ -0,0 +1,73 @@ +name: Retry native add-on UI acceptance +on: + workflow_dispatch: + inputs: + build_run_id: + description: Packaged add-on workflow run containing the installer input artifacts + required: true + type: string +permissions: + contents: read + actions: read +jobs: + native-ui: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: Validate installer source run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + BUILD_RUN_ID: ${{ inputs.build_run_id }} + run: | + node --input-type=module <<'JS' + import assert from 'node:assert/strict'; + const id = process.env.BUILD_RUN_ID; + assert.match(id, /^\d+$/); + const response = await fetch(`https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/runs/${id}`, { + headers: { Authorization: `Bearer ${process.env.GH_TOKEN}`, Accept: 'application/vnd.github+json' } + }); + assert.equal(response.status, 200); + const run = await response.json(); + assert.equal(run.repository.full_name, process.env.GITHUB_REPOSITORY); + assert.equal(run.head_repository.full_name, process.env.GITHUB_REPOSITORY); + assert.equal(run.path, '.github/workflows/addon-packaged.yml'); + console.log(`Testing installers from ${run.head_sha}; harness ${process.env.GITHUB_SHA}`); + JS + - uses: actions/download-artifact@v4 + with: + name: addon-native-ui-inputs-${{ runner.os }} + run-id: ${{ inputs.build_run_id }} + github-token: ${{ github.token }} + - name: Native Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-0 libayatana-appindicator3-1 webkit2gtk-driver xvfb dbus-x11 + - uses: dtolnay/rust-toolchain@stable + - name: Install external WebDriver + run: cargo install tauri-driver --version 2.0.6 --locked + - name: Install matching Microsoft WebView2 driver + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/addons/install-webdriver.ps1 + - name: Native desktop UI acceptance (Linux) + if: runner.os == 'Linux' + run: dbus-run-session -- xvfb-run -a node scripts/addons/native-ui.mjs + - name: Native desktop UI acceptance (Windows) + if: runner.os == 'Windows' + run: node scripts/addons/native-ui.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: addon-native-ui-evidence-${{ runner.os }} + path: addon-native-ui-evidence/ + if-no-files-found: warn diff --git a/.github/workflows/addon-packaged.yml b/.github/workflows/addon-packaged.yml index 677e97f3..862b9185 100644 --- a/.github/workflows/addon-packaged.yml +++ b/.github/workflows/addon-packaged.yml @@ -2,12 +2,21 @@ name: Packaged add-on runtime on: workflow_dispatch: pull_request: - paths: ['scripts/addons/packaged-smoke.mjs', 'scripts/addons/elf-provenance.mjs', 'scripts/addons/elf-provenance-check.mjs', 'scripts/addons/host-limits.mjs', 'scripts/addons/sdk-native.mjs', 'src-tauri/addon-host/**', 'src-tauri/tauri.conf.json', 'scripts/build-addon-host.sh', '.github/workflows/addon-packaged.yml'] + paths: + [ + "scripts/addons/**", + "src-tauri/addon-host/**", + "src-tauri/tauri.conf.json", + "scripts/build-addon-host.sh", + ".github/workflows/addon-packaged.yml", + ] permissions: contents: read concurrency: group: addon-packaged-${{ github.ref }} - cancel-in-progress: true + # Preserve installer evidence and populated caches from an in-flight build. + # Harness-only iterations can reuse its artifacts through addon-native-ui.yml. + cancel-in-progress: false jobs: bundle: strategy: @@ -15,7 +24,7 @@ jobs: matrix: os: [ubuntu-22.04, windows-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 60 + timeout-minutes: 90 steps: - name: Checkout uses: actions/checkout@v5 @@ -33,7 +42,7 @@ jobs: librsvg2-dev \ patchelf \ libfuse2 \ - file + file webkit2gtk-driver xvfb dbus-x11 - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -159,7 +168,6 @@ jobs: git config --global user.email "release@codemux.dev" git config --global user.name "Codemux Release" - - name: Pre-cache stable linuxdeploy + plugin-appimage if: matrix.os == 'ubuntu-22.04' run: | @@ -172,12 +180,44 @@ jobs: - name: Build installers without publishing shell: bash env: - NO_STRIP: 'true' + NO_STRIP: "true" run: | if [ "$RUNNER_OS" = Windows ]; then formats=nsis; else formats=deb,appimage; fi npm run tauri -- build --verbose --bundles "$formats" --config '{"bundle":{"createUpdaterArtifacts":false}}' - name: Verify bundled host and runtime deadlines run: node scripts/addons/packaged-smoke.mjs src-tauri/target/release/bundle + - name: Record installer build revision + run: node -e "require('node:fs').writeFileSync('addon-native-ui-build.json', JSON.stringify({commit:process.env.GITHUB_SHA,run:process.env.GITHUB_RUN_ID,platform:process.platform}))" + - name: Preserve built acceptance inputs for harness-only retries + uses: actions/upload-artifact@v4 + with: + name: addon-native-ui-inputs-${{ runner.os }} + retention-days: 7 + path: | + addon-native-ui-build.json + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/nsis/*.exe + examples/addons/project-brief/*.cmxaddon + examples/addons/issue-companion/*.cmxaddon + scripts/addons/fixtures/fault-isolation/*.cmxaddon + - name: Install external WebDriver + run: cargo install tauri-driver --version 2.0.6 --locked + - name: Install matching Microsoft WebView2 driver + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/addons/install-webdriver.ps1 + - name: Native desktop UI acceptance (Linux) + if: runner.os == 'Linux' + run: dbus-run-session -- xvfb-run -a node scripts/addons/native-ui.mjs + - name: Native desktop UI acceptance (Windows) + if: runner.os == 'Windows' + run: node scripts/addons/native-ui.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: addon-native-ui-evidence-${{ runner.os }} + path: addon-native-ui-evidence/ + if-no-files-found: warn - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.gitignore b/.gitignore index bdaf731f..c6e12660 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,8 @@ docs/addons/research-probe/probe-ui.bundle.js # Independent add-on build products examples/addons/*/plugin.js +scripts/addons/fixtures/*/plugin.js +addon-native-ui-evidence/ +addon-native-ui-build.json src-tauri/addon-catalog/target/ diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index 833f299b..23245aaa 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -172,6 +172,23 @@ publication and Settings remain separate deliverables. ## Unresolved release gates +Native desktop UI acceptance now has a CI-only external `tauri-driver` harness +(`scripts/addons/native-ui.mjs`). It refuses local and self-hosted execution, +installs the ordinary deb/NSIS artifact, uses a synthetic loopback account API, +and supplies only the OS file chooser's selected package path. Package review, +installation, host execution, permissions, Remote DOM rendering, Git, HTTPS, +and controlled draft insertion use the production app. Windows downloads the +driver matching WebView2 and verifies Microsoft's executable signature. +Installer and harness revisions are recorded separately so a harness-only retry +cannot imply a newer app build was tested. This harness has passed syntax, +workflow-parse and local-refusal checks; **native execution is still pending**. +It currently targets import/review, enable/disable, both example views and draft +insertion, read-only public GitHub HTTPS, no auto-submit, pause/resume, and an +independently packaged blocking-command fixture while typing in the core draft +and reopening Project Brief. These targets are not evidence until execution +passes, and do not cover the full race matrix. The OS file chooser and account +service are explicit fixture seams. + - Stock built-app E2E for both examples, including real frontend/native IPC, actual controlled draft insertion and core UI operation during hostile plugins. - Latest-commit packaged rerun after the final integration follow-ups. Linux diff --git a/scripts/addons/build-examples.sh b/scripts/addons/build-examples.sh index 9c5bd0b3..4942200c 100644 --- a/scripts/addons/build-examples.sh +++ b/scripts/addons/build-examples.sh @@ -14,3 +14,9 @@ for addon in project-brief issue-companion; do npm run check --prefix "examples/addons/$addon" npm run pack --prefix "examples/addons/$addon" done +# Build the hostile CI fixture through the same packed public author tools. +# It is never included in a release workflow or catalog listing. +addon_fixture=scripts/addons/fixtures/fault-isolation +npm install --no-save --package-lock=false --ignore-scripts --prefix "$addon_fixture" "$addon_pack_dir/codemux-plugin-sdk-1.0.0.tgz" "$addon_pack_dir/codemux-plugin-cli-1.0.0.tgz" +node "$addon_fixture/node_modules/@codemux/plugin-cli/src/cli.mjs" build "$addon_fixture" +node "$addon_fixture/node_modules/@codemux/plugin-cli/src/cli.mjs" pack "$addon_fixture" diff --git a/scripts/addons/fixtures/fault-isolation/LICENSE b/scripts/addons/fixtures/fault-isolation/LICENSE new file mode 100644 index 00000000..ff92e51d --- /dev/null +++ b/scripts/addons/fixtures/fault-isolation/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CodeMux contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/addons/fixtures/fault-isolation/README.md b/scripts/addons/fixtures/fault-isolation/README.md new file mode 100644 index 00000000..aa770a9d --- /dev/null +++ b/scripts/addons/fixtures/fault-isolation/README.md @@ -0,0 +1,6 @@ +# Fault isolation acceptance fixture + +CI-only package using the public CodeMux SDK and CLI. Its command deliberately +loops forever in its native add-on host. Never publish or add it to the catalog. +The desktop acceptance harness verifies quarantine and continued core and +Project Brief operation. No permission or runtime exception is granted. diff --git a/scripts/addons/fixtures/fault-isolation/manifest.json b/scripts/addons/fixtures/fault-isolation/manifest.json new file mode 100644 index 00000000..702bae39 --- /dev/null +++ b/scripts/addons/fixtures/fault-isolation/manifest.json @@ -0,0 +1,30 @@ +{ + "format": "codemux.feature-plugin", + "manifestVersion": 1, + "id": "example.fault-isolation", + "name": "Fault Isolation Fixture", + "description": "CI-only hostile callback used to verify native host isolation.", + "version": "1.0.0", + "api": "^1.0.0", + "entry": "plugin.js", + "platforms": ["linux-x64", "windows-x64"], + "author": { "name": "CodeMux CI", "url": "https://codemux.org" }, + "repository": "https://github.com/Zeus-Deus/codemux", + "license": "MIT", + "permissions": [], + "http": [], + "credentials": [], + "settings": [], + "contributes": { + "commands": [ + { + "id": "block", + "title": "Run isolated blocking fixture", + "requiresWorkspace": true + } + ], + "panels": [], + "composerActions": [], + "composerViews": [] + } +} diff --git a/scripts/addons/fixtures/fault-isolation/src/index.tsx b/scripts/addons/fixtures/fault-isolation/src/index.tsx new file mode 100644 index 00000000..9cca2ef5 --- /dev/null +++ b/scripts/addons/fixtures/fault-isolation/src/index.tsx @@ -0,0 +1,13 @@ +import { definePlugin } from "@codemux/plugin-sdk"; + +// Deliberately hostile: never published or included in the catalog. This must +// execute exclusively in the same separate native host used by third parties. +export default definePlugin({ + activate(ctx) { + ctx.commands.register("block", () => { + while (true) { + /* the native execution deadline must interrupt this */ + } + }); + }, +}); diff --git a/scripts/addons/install-webdriver.ps1 b/scripts/addons/install-webdriver.ps1 new file mode 100644 index 00000000..e22a88a4 --- /dev/null +++ b/scripts/addons/install-webdriver.ps1 @@ -0,0 +1,26 @@ +# CI-only: use the driver matching the installed WebView2 runtime, not Edge's +# independently updated browser version. Download only from Microsoft. +$ErrorActionPreference = 'Stop' +if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { + throw 'Native UI acceptance requires a disposable GitHub-hosted runner' +} +$client = 'Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' +$version = $null +foreach ($prefix in @('HKLM:\SOFTWARE\WOW6432Node\', 'HKLM:\SOFTWARE\', 'HKCU:\SOFTWARE\WOW6432Node\', 'HKCU:\SOFTWARE\')) { + $entry = Get-ItemProperty -Path "$prefix$client" -ErrorAction SilentlyContinue + if ($entry.pv -match '^\d+\.\d+\.\d+\.\d+$') { $version = $entry.pv; break } +} +if (-not $version) { throw 'No installed WebView2 runtime version found' } +$destination = Join-Path $env:RUNNER_TEMP 'codemux-webdriver' +New-Item -ItemType Directory -Path $destination -Force | Out-Null +$archive = Join-Path $destination 'driver.zip' +Invoke-WebRequest "https://msedgedriver.microsoft.com/$version/edgedriver_win64.zip" -OutFile $archive +Expand-Archive $archive -DestinationPath $destination -Force +$driver = Join-Path $destination 'msedgedriver.exe' +$signature = Get-AuthenticodeSignature $driver +if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft Corporation') { + throw 'Microsoft Edge driver signature verification failed' +} +& $driver --version +if ($LASTEXITCODE -ne 0) { throw 'Microsoft Edge driver did not start' } +$destination | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append diff --git a/scripts/addons/native-ui.mjs b/scripts/addons/native-ui.mjs new file mode 100644 index 00000000..03914fa0 --- /dev/null +++ b/scripts/addons/native-ui.mjs @@ -0,0 +1,478 @@ +// CI-only WebDriver acceptance against an installed, unmodified release app. +// Never run on a developer profile. No embedded driver or production test hooks. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +assert.equal( + process.env.GITHUB_ACTIONS, + "true", + "Requires disposable GitHub CI", +); +assert.equal( + process.env.RUNNER_ENVIRONMENT, + "github-hosted", + "Refuses persistent/self-hosted runners", +); +assert.ok(process.env.RUNNER_TEMP); +const root = await mkdtemp(join(process.env.RUNNER_TEMP, "codemux-native-ui-")); +const evidenceDir = resolve("addon-native-ui-evidence"); +await mkdir(evidenceDir, { recursive: true }); +const evidence = { + commit: process.env.GITHUB_SHA, + platform: process.platform, + checks: [], + seams: [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native", + ], +}; +evidence.installerBuild = JSON.parse( + await readFile("addon-native-ui-build.json", "utf8"), +); +assert.equal(evidence.installerBuild.platform, process.platform); +const owned = new Set(); +function start(executable, args, options = {}) { + const child = spawn(executable, args, { + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + owned.add(child); + let output = ""; + child.stdout.on("data", (chunk) => { + output = (output + chunk).slice(-100000); + }); + child.stderr.on("data", (chunk) => { + output = (output + chunk).slice(-100000); + }); + child.done = new Promise((done, fail) => { + child.once("error", fail); + child.once("exit", (code) => { + owned.delete(child); + done({ code, output }); + }); + }); + child.done.catch(() => {}); + return child; +} +async function run(executable, args, options = {}) { + const child = start(executable, args, options); + const timer = setTimeout(() => child.kill(), 120000); + try { + const result = await child.done; + assert.equal(result.code, 0, `${executable}: ${result.output}`); + } finally { + clearTimeout(timer); + } +} +async function files(path) { + const result = []; + for (const entry of await readdir(path, { withFileTypes: true })) { + if (entry.isDirectory()) + result.push(...(await files(join(path, entry.name)))); + else if (entry.isFile()) result.push(join(path, entry.name)); + } + return result; +} +const bundle = resolve(process.argv[2] ?? "src-tauri/target/release/bundle"); +const installers = (await files(bundle)).filter((file) => + file.endsWith(process.platform === "win32" ? ".exe" : ".deb"), +); +assert.equal(installers.length, 1); +evidence.installerSha256 = createHash("sha256") + .update(await readFile(installers[0])) + .digest("hex"); +const installDir = join(root, "app"); +let application; +if (process.platform === "win32") { + await run(installers[0], ["/S", `/D=${installDir}`]); + application = join(installDir, "codemux.exe"); +} else { + await run("sudo", ["dpkg", "--install", installers[0]]); + application = "/usr/bin/codemux"; +} + +// The public release supports CODEMUX_API_URL and normal token login. Route only +// account traffic to this synthetic server; plugin HTTPS still uses its broker. +const token = "ci-synthetic-session-not-a-real-credential"; +const account = createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + if ( + req.url === "/api/auth/desktop/verify" && + req.headers.authorization === `Bearer ${token}` + ) { + res.end( + JSON.stringify({ + user: { + id: "ci-addon-user", + email: "addons@example.invalid", + name: "Add-on CI", + image: null, + }, + session: { expiresAt: "2099-01-01T00:00:00Z" }, + }), + ); + } else { + res.statusCode = 404; + res.end('{"error":"Synthetic CI account has no remote services"}'); + } +}); +await new Promise((done) => account.listen(0, "127.0.0.1", done)); +const env = { + ...process.env, + CODEMUX_API_URL: `http://127.0.0.1:${account.address().port}`, + WEBKIT_DISABLE_COMPOSITING_MODE: "1", +}; +let session; +let driver; +const endpoint = "http://127.0.0.1:4444"; +async function request(method, path, body) { + const response = await fetch(endpoint + path, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(60000), + }); + const data = await response.json(); + if (!response.ok || data.value?.error) + throw Error(`${method} ${path}: ${JSON.stringify(data)}`); + return data.value; +} +const wd = (method, path, body) => + request(method, `/session/${session}${path}`, body); +const script = (source, ...args) => + wd("POST", "/execute/sync", { script: source, args }); +async function native(command, args = {}) { + const result = await wd("POST", "/execute/async", { + script: `const done = arguments[arguments.length - 1]; window.__TAURI_INTERNALS__.invoke(arguments[0], arguments[1]).then(value => done({value}), error => done({error: JSON.stringify(error)}));`, + args: [command, args], + }); + assert.equal(result.error, undefined, `${command}: ${result.error}`); + return result.value; +} +async function until(label, fn, timeout = 30000) { + const end = Date.now() + timeout; + let last; + while (Date.now() < end) { + try { + const result = await fn(); + if (result) return result; + } catch (e) { + last = e; + } + await delay(250); + } + throw Error(`Timed out: ${label}${last ? `: ${last.message}` : ""}`); +} +const text = () => script("return document.body.innerText"); +const hasText = (value) => + until(value, async () => (await text()).includes(value)); +async function element(css) { + return until(css, () => + wd("POST", "/element", { using: "css selector", value: css }), + ); +} +const elementId = (el) => el["element-6066-11e4-a52e-4f735466cecf"]; +async function clickText(value, scope = "document") { + const el = await until(`click ${value}`, () => + script( + `return [...${scope}.querySelectorAll('button,[role="menuitem"],[role="option"]')].find(e => e.getClientRects().length && (e.innerText.trim() === arguments[0] || e.getAttribute('aria-label') === arguments[0])) ?? null`, + value, + ), + ); + await wd("POST", `/element/${elementId(el)}/click`, {}); +} +async function click(css) { + const el = await element(css); + await wd("POST", `/element/${elementId(el)}/click`, {}); +} +async function type(css, value) { + const el = await element(css); + await wd("POST", `/element/${elementId(el)}/value`, { text: value }); +} +async function shortcut(key) { + await wd("POST", "/actions", { + actions: [ + { + type: "key", + id: "keyboard", + actions: [ + { type: "keyDown", value: "\uE009" }, + { type: "keyDown", value: key }, + { type: "keyUp", value: key }, + { type: "keyUp", value: "\uE009" }, + ], + }, + ], + }); +} +async function capture(name) { + await writeFile(join(evidenceDir, `${name}.txt`), await text()); + await writeFile( + join(evidenceDir, `${name}.png`), + Buffer.from(await wd("GET", "/screenshot"), "base64"), + ); +} +async function step(name, fn) { + console.log(`Native UI: ${name}`); + await fn(); + evidence.checks.push(name); + await capture(name); +} +async function openSettings() { + await shortcut(","); + await clickText("Add-ons"); + await hasText("Import package"); +} +async function openCommand(title) { + await shortcut("k"); + await type('[role="combobox"]', title); + await until(title, () => + script( + `return [...document.querySelectorAll('[role="option"]')].find(e => e.innerText.includes(arguments[0])) ?? null`, + title, + ), + ).then((el) => wd("POST", `/element/${elementId(el)}/click`, {})); +} +try { + await run(application, ["login", "--token", token], { env }); + driver = start("tauri-driver", ["--host", "127.0.0.1", "--port", "4444"], { + env, + }); + await until("driver startup", () => request("GET", "/status")); + const created = await request("POST", "/session", { + capabilities: { alwaysMatch: { "tauri:options": { application } } }, + }); + session = created.sessionId; + await wd("POST", "/timeouts", { + implicit: 0, + script: 30000, + pageLoad: 60000, + }); + await until("native app ready", () => + script( + "return !!window.__TAURI_INTERNALS__ && document.body.innerText.length > 50", + ), + ); + await element('button[aria-label="Menu"]'); + await step("01-settings", openSettings); + for (const [slug, title, id] of [ + ["project-brief", "Project Brief", "codemux.project-brief"], + ["issue-companion", "Issue Companion", "codemux.issue-companion"], + ["fault-isolation", "Fault Isolation Fixture", "example.fault-isolation"], + ]) { + await step(`02-import-${slug}`, async () => { + const path = resolve( + slug === "fault-isolation" + ? `scripts/addons/fixtures/${slug}/${id}-1.0.0.cmxaddon` + : `examples/addons/${slug}/${id}-1.0.0.cmxaddon`, + ); + // One-shot, chooser-only seam. Restore before the real review command. + await script( + `const original = window.__TAURI_INTERNALS__.invoke; const path = arguments[0]; window.__TAURI_INTERNALS__.invoke = function(command, args, options) { if (command === 'plugin:dialog|open') { window.__TAURI_INTERNALS__.invoke = original; return Promise.resolve(path); } return original(command, args, options); };`, + path, + ); + await clickText("Import package"); + await hasText(`Review ${title}`); + await hasText("SHA-256"); + await capture(`review-${slug}`); + await clickText("Accept and install"); + await until(`native installation ${slug}`, async () => + (await native("addon_inventory")).installed.some( + (i) => i.manifest.id === id && i.desiredEnabled, + ), + ); + }); + } + await step("03-disable-enable", async () => { + const article = `([...document.querySelectorAll('article')].find(e => e.innerText.includes('Project Brief')))`; + await clickText("Disable", article); + await until( + "disabled", + async () => + !(await native("addon_inventory")).installed.find( + (i) => i.manifest.id === "codemux.project-brief", + ).desiredEnabled, + ); + await clickText("Enable", article); + await until( + "enabled", + async () => + (await native("addon_inventory")).installed.find( + (i) => i.manifest.id === "codemux.project-brief", + ).desiredEnabled, + ); + }); + await step("03-issue-configuration", async () => { + await clickText( + "Configure / Permissions", + `([...document.querySelectorAll('article')].find(e => e.innerText.includes('Issue Companion')))`, + ); + for (const [label, value] of [ + ["Repository owner", "octocat"], + ["Repository name", "Hello-World"], + ]) { + const input = await until(label, () => + script( + `return [...document.querySelectorAll('label')].find(e => e.innerText.trim() === arguments[0])?.querySelector('input') ?? null`, + label, + ), + ); + await wd("POST", `/element/${elementId(input)}/value`, { text: value }); + } + await clickText("Save settings"); + await hasText("Saved"); + const settings = await native("addon_settings_get", { + id: "codemux.issue-companion", + }); + assert.equal(settings.owner, "octocat"); + assert.equal(settings.repository, "Hello-World"); + }); + const project = join(root, "synthetic-project"); + await mkdir(project); + await run("git", ["init", "--initial-branch=main", project]); + await writeFile( + join(project, "README.md"), + "# Native add-on acceptance fixture\n", + ); + // Workspace creation normally writes its own MCP discovery file. Keep that + // normal behavior while making the deliberately untracked path unambiguous. + await writeFile(join(project, ".git", "info", "exclude"), ".mcp.json\n"); + await run("git", ["-C", project, "add", "README.md"]); + await run("git", [ + "-C", + project, + "-c", + "user.name=Add-on CI", + "-c", + "user.email=addons@example.invalid", + "commit", + "-m", + "fixture", + ]); + await writeFile( + join(project, "draft-context.txt"), + "Synthetic untracked file\n", + ); + await click('[aria-label="Close settings"]'); + const workspaceId = await native("create_workspace", { cwd: project }); + await native("agent_chat_create_pane", { + workspaceId, + cwd: project, + provider: null, + launchMode: null, + threadId: null, + }); + const sessionsBefore = await native("agent_chat_list_sessions", { + workspaceId, + }); + await step("04-project-brief-native-git", async () => { + await openCommand("Open Project Brief"); + await hasText("Branch: main"); + await hasText("1 untracked"); + await hasText("draft-context.txt"); + }); + await step("05-project-brief-real-draft", async () => { + await type("textarea", "Existing draft \n"); + await clickText("Add to draft"); + await until("literal draft appended", () => + script( + `return [...document.querySelectorAll('textarea')].some(e => e.value.startsWith('Existing draft ') && e.value.includes('Project:') && e.value.includes('draft-context.txt'))`, + ), + ); + }); + await step("06-issue-companion-native-https", async () => { + await openCommand("Open Issue Companion"); + // Public, read-only HTTPS through the production DNS/TLS broker. No token, + // intercepted fetch, or fixture-only origin exception. A rate limit fails + // this gate visibly instead of treating an error state as a successful fetch. + await element('section[aria-label="Add-on view"] select'); + await clickText( + "Add to draft", + `document.querySelector('section[aria-label="Add-on view"]')`, + ); + await until("issue appended to actual draft", () => + script( + `return [...document.querySelectorAll('textarea')].some(e => e.value.startsWith('Existing draft ') && e.value.includes('Project:') && e.value.includes('https://github.com/octocat/Hello-World/issues/'))`, + ), + ); + }); + await step("07-no-auto-submit", async () => { + assert.deepEqual( + await native("agent_chat_list_sessions", { workspaceId }), + sessionsBefore, + "Plugin draft insertion must not start or submit a session", + ); + await openSettings(); + await hasText("Pause all add-ons"); + await clickText("Pause all add-ons"); + await until("paused", async () => (await native("addon_inventory")).paused); + await clickText("Resume add-ons"); + await until( + "resumed", + async () => !(await native("addon_inventory")).paused, + ); + }); + await step("08-hostile-plugin-core-interactivity", async () => { + await click('[aria-label="Close settings"]'); + await openCommand("Run isolated blocking fixture"); + const started = performance.now(); + await type("textarea", " Core remains interactive."); + await until( + "hostile runtime quarantined", + async () => + (await native("addon_inventory")).installed.find( + (i) => i.manifest.id === "example.fault-isolation", + ).status === "failed-disabled", + 5000, + ); + evidence.hostileUiObservationMs = performance.now() - started; + await until("core input accepted", () => + script( + `return [...document.querySelectorAll('textarea')].some(e => e.value.includes('Core remains interactive.'))`, + ), + ); + await openCommand("Open Project Brief"); + await hasText("Branch: main"); + await clickText( + "Refresh", + `document.querySelector('section[aria-label="Add-on view"]')`, + ); + await hasText("draft-context.txt"); + await openSettings(); + await hasText("Fault Isolation Fixture"); + await hasText("failed disabled"); + assert.deepEqual( + await native("agent_chat_list_sessions", { workspaceId }), + sessionsBefore, + ); + }); + evidence.status = "passed"; +} catch (error) { + evidence.status = "failed"; + evidence.error = String(error); + if (session) await capture("failure").catch(() => {}); + throw error; +} finally { + if (session) await wd("DELETE", "").catch(() => {}); + for (const child of owned) child.kill(); + if (driver) { + const result = await Promise.race([ + driver.done, + delay(3000).then(() => null), + ]); + if (result) await writeFile(join(evidenceDir, "driver.log"), result.output); + } + account.closeAllConnections(); + account.close(); + await writeFile( + join(evidenceDir, "result.json"), + JSON.stringify(evidence, null, 2) + "\n", + ); + // Job VM disposal removes the synthetic profile; do not recursively delete + // system app-data directories or terminate unrelated processes here. +} From 03cf11539fc4316652e2495b6d77e2735d6f3090 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 20:24:48 +0200 Subject: [PATCH 006/155] test: activate the fixture workspace through desktop controls --- .github/workflows/addon-native-ui.yml | 26 ++++++++++++++++++++++++++ scripts/addons/native-ui.mjs | 7 +++++++ 2 files changed, 33 insertions(+) diff --git a/.github/workflows/addon-native-ui.yml b/.github/workflows/addon-native-ui.yml index 9a2b16e8..1ccda79a 100644 --- a/.github/workflows/addon-native-ui.yml +++ b/.github/workflows/addon-native-ui.yml @@ -1,5 +1,10 @@ name: Retry native add-on UI acceptance on: + pull_request: + paths: + - scripts/addons/native-ui.mjs + - scripts/addons/install-webdriver.ps1 + - .github/workflows/addon-native-ui.yml workflow_dispatch: inputs: build_run_id: @@ -10,7 +15,28 @@ permissions: contents: read actions: read jobs: + harness-syntax: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: node --check scripts/addons/native-ui.mjs + - name: Verify refusal outside disposable CI + run: | + node --input-type=module <<'JS' + import assert from 'node:assert/strict'; + import { spawnSync } from 'node:child_process'; + const result = spawnSync(process.execPath, ['scripts/addons/native-ui.mjs'], { + env: { ...process.env, GITHUB_ACTIONS: 'false' }, encoding: 'utf8' + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Requires disposable GitHub CI/); + JS native-ui: + if: github.event_name == 'workflow_dispatch' strategy: fail-fast: false matrix: diff --git a/scripts/addons/native-ui.mjs b/scripts/addons/native-ui.mjs index 03914fa0..d7c3c625 100644 --- a/scripts/addons/native-ui.mjs +++ b/scripts/addons/native-ui.mjs @@ -367,6 +367,13 @@ try { launchMode: null, threadId: null, }); + // Native setup creates the workspace, but only a normal frontend activation + // dismisses the startup Home draft. Select the real workspace via the palette + // instead of mutating the app's client stores from the test. + await shortcut("k"); + await type('[role="combobox"]', "synthetic-project"); + await click(`[role="option"][data-value="ws:${workspaceId}"]`); + await element("textarea"); const sessionsBefore = await native("agent_chat_list_sessions", { workspaceId, }); From c370130a7ca88c7258ff5f942af57e24e34f55db Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 20:29:08 +0200 Subject: [PATCH 007/155] test: preserve installer evidence and avoid draft submit keys --- .github/workflows/addon-native-ui.yml | 8 ++- docs/addons/IMPLEMENTATION.md | 16 +++-- docs/addons/evidence/packaged-linux-3045.json | 67 +++++++++++++++++++ .../evidence/packaged-windows-3045.json | 39 +++++++++++ scripts/addons/native-ui.mjs | 4 +- 5 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 docs/addons/evidence/packaged-linux-3045.json create mode 100644 docs/addons/evidence/packaged-windows-3045.json diff --git a/.github/workflows/addon-native-ui.yml b/.github/workflows/addon-native-ui.yml index 1ccda79a..64967c25 100644 --- a/.github/workflows/addon-native-ui.yml +++ b/.github/workflows/addon-native-ui.yml @@ -11,6 +11,12 @@ on: description: Packaged add-on workflow run containing the installer input artifacts required: true type: string + platform: + description: Run either ready installer artifact, or both platforms + required: true + type: choice + default: both + options: [both, Linux, Windows] permissions: contents: read actions: read @@ -40,7 +46,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, windows-latest] + os: ${{ inputs.platform == 'Linux' && fromJSON('["ubuntu-22.04"]') || inputs.platform == 'Windows' && fromJSON('["windows-latest"]') || fromJSON('["ubuntu-22.04", "windows-latest"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 20 steps: diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index 23245aaa..553e07c8 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -30,6 +30,15 @@ publication and Settings remain separate deliverables. ## Verified evidence (Linux x86_64 unless stated otherwise) +- Full desktop [CI at `3045f47f`](https://github.com/Zeus-Deus/codemux/actions/runs/35376157828) + passes on Linux and Windows, including 5,610 frontend tests on Linux. The same + revision passes [all installer payload checks](https://github.com/Zeus-Deus/codemux/actions/runs/35376157749): + Linux deb/AppImage and Windows NSIS. Maximum observed fault latencies are + 1007.0 ms and 1014.7 ms respectively. Recorded [Linux](evidence/packaged-linux-3045.json) + and [Windows](evidence/packaged-windows-3045.json) results include source-run + provenance. Subsequent CI harness changes do not change production app code; + native desktop UI acceptance remains separate and pending. + - PR 1 hosted CI: Linux and Windows GNU host, manifest contracts, SDK callback integration all passed. Schema comparison normalizes Windows CRLF only. - Protocol: 14 focused tests pass, including atomic malformed UI rejection, @@ -191,18 +200,11 @@ service are explicit fixture seams. - Stock built-app E2E for both examples, including real frontend/native IPC, actual controlled draft insertion and core UI operation during hostile plugins. -- Latest-commit packaged rerun after the final integration follow-ups. Linux - AppImage/deb and Windows NSIS payload behavior passed on recorded revisions; - stock desktop GUI launch and integration still require separate evidence. - Complete failure-injection matrix: disk-full boundaries, every journal/crash point, unexpected child exit/ignored shutdown, concurrent update/uninstall, and delayed workspace/thread/composer races through the actual desktop. Native update/uninstall serialization and dropped Git requests are covered, but those do not establish all installed-app race cases. -- Latest-commit hosted native checks after the cancellation and concurrency - follow-ups. Native TLS, recovery and OS credential checks have passed on both - platforms; installed-app cancellation races and external network behavior - remain part of desktop E2E. - Complete keyboard/screen-reader, light/dark, small-window, chat-GUI-off and core pane restoration evidence; measured runtime fault and UI budgets with hardware/workload details. diff --git a/docs/addons/evidence/packaged-linux-3045.json b/docs/addons/evidence/packaged-linux-3045.json new file mode 100644 index 00000000..23eea00b --- /dev/null +++ b/docs/addons/evidence/packaged-linux-3045.json @@ -0,0 +1,67 @@ +{ + "platform": "linux", + "os": "6.8.0-1064-azure", + "cpu": "AMD EPYC 7763 64-Core Processor", + "logicalCpus": 4, + "hostSha256": "dd6310532390ff0f3b4312db2615be3c4fa933e955bd7330f1db050241b20d3f", + "bundles": [ + { + "format": ".deb", + "packagedSha256": "dd6310532390ff0f3b4312db2615be3c4fa933e955bd7330f1db050241b20d3f", + "provenance": "exact-sha256", + "hostPath": "usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1007.0116740000001 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 4.0497770000001765 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 3.105837999999949 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.5204709999998 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.6716019999998935 + } + ] + }, + { + "format": ".AppImage", + "packagedSha256": "7e74ae728316e1403c33fd0690f20ba21483db2a6cdc290f506678d0adcf8c07", + "provenance": "elf-sections-and-dynamic-metadata", + "hostPath": "squashfs-root/usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1003.984684 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 4.069824000000153 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 3.232184000000416 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.9180489999999 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.816092000000026 + } + ] + } + ], + "workflowHead": "3045f47ffc3f20e34951922b72e06904ce231c78", + "workflowRun": "https://github.com/Zeus-Deus/codemux/actions/runs/35376157749" +} diff --git a/docs/addons/evidence/packaged-windows-3045.json b/docs/addons/evidence/packaged-windows-3045.json new file mode 100644 index 00000000..a6c8e0df --- /dev/null +++ b/docs/addons/evidence/packaged-windows-3045.json @@ -0,0 +1,39 @@ +{ + "platform": "win32", + "os": "10.0.26100", + "cpu": "AMD EPYC 9V74 80-Core Processor ", + "logicalCpus": 4, + "hostSha256": "1e55035d1d55e06ac1ec06a8df1fd3b36e524de072401cd2391e08c983732c21", + "bundles": [ + { + "format": ".exe", + "packagedSha256": "1e55035d1d55e06ac1ec06a8df1fd3b36e524de072401cd2391e08c983732c21", + "provenance": "exact-sha256", + "hostPath": "binaries\\codemux-addon-host-windows-x64.exe", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1014.7129999999997 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 12.016399999998612 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 10.23279999999977 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1010.6166999999987 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 11.13169999999991 + } + ] + } + ], + "workflowHead": "3045f47ffc3f20e34951922b72e06904ce231c78", + "workflowRun": "https://github.com/Zeus-Deus/codemux/actions/runs/35376157749" +} diff --git a/scripts/addons/native-ui.mjs b/scripts/addons/native-ui.mjs index d7c3c625..8c2e0378 100644 --- a/scripts/addons/native-ui.mjs +++ b/scripts/addons/native-ui.mjs @@ -384,7 +384,9 @@ try { await hasText("draft-context.txt"); }); await step("05-project-brief-real-draft", async () => { - await type("textarea", "Existing draft \n"); + // WebDriver translates a newline to Enter; never send a submit key. The + // plugin itself appends its multiline text through the real draft adapter. + await type("textarea", "Existing draft "); await clickText("Add to draft"); await until("literal draft appended", () => script( From fe647cd90eb01ac2a8d0dd604e37089c7337e5d3 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 20:38:20 +0200 Subject: [PATCH 008/155] fix: keep add-on Markdown links from submitting forms --- .github/workflows/addon-packaged.yml | 12 +++++ docs/addons/ACCEPTANCE.md | 48 +++++++++++++++++++ docs/addons/IMPLEMENTATION.md | 9 +++- src/components/addons/addon-renderer.test.tsx | 25 ++++++++++ src/components/addons/addon-renderer.tsx | 1 + 5 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 docs/addons/ACCEPTANCE.md diff --git a/.github/workflows/addon-packaged.yml b/.github/workflows/addon-packaged.yml index 862b9185..1ac79ea5 100644 --- a/.github/workflows/addon-packaged.yml +++ b/.github/workflows/addon-packaged.yml @@ -5,6 +5,18 @@ on: paths: [ "scripts/addons/**", + "packages/plugin-*/**", + "examples/addons/**", + "src/components/addons/**", + "src/components/settings/addons-settings*", + "src/components/chat/Composer.tsx", + "src/components/layout/right-panel.tsx", + "src/components/overlays/command-palette.tsx", + "src/lib/addons/**", + "src/stores/addons-store.ts", + "src-tauri/src/addons/**", + "src-tauri/src/commands/addons.rs", + "src-tauri/addon-protocol/**", "src-tauri/addon-host/**", "src-tauri/tauri.conf.json", "scripts/build-addon-host.sh", diff --git a/docs/addons/ACCEPTANCE.md b/docs/addons/ACCEPTANCE.md new file mode 100644 index 00000000..d50d7afa --- /dev/null +++ b/docs/addons/ACCEPTANCE.md @@ -0,0 +1,48 @@ +# Acceptance evidence map + +This maps chapter 12 of [the requirements](BUILD-SPEC.md) to executable checks. +It is a review aid, not a declaration that the release gates pass. Recorded +revisions, results and remaining release gates are in [the ledger](IMPLEMENTATION.md). +Native UI harness targets below remain unverified until their installed-app CI +run passes. Browser mocks and a native broker test that supplies frontend effect +results cannot establish the corresponding desktop behavior. + +| Requirement | Checks and evidence | Remaining desktop/release evidence | +| --- | --- | --- | +| Core independence | Existing full desktop CI; Settings browser evidence; native UI harness pause/resume | Clean/paused startup, zero host processes before use, terminal/projects/themes/update controls and pane restoration in installed apps | +| Functional SDK | Real-host package integrations in `manager.rs`; `sdk-native.mjs`; installed-app `native-ui.mjs` | Both packages' actual UI/draft/HTTPS operations must pass on Linux and Windows | +| Author independence | Both examples consume packed public SDK/CLI; Issue Companion built outside the checkout | Reviewed SDK distribution and final documentation against the published version | +| Runtime failure | `addon-host/tests`, native `protocol.rs`, manager fault isolation; packaged five-workload evidence | Installed-app responsiveness for all hostile workloads, including exit and ignored shutdown; the UI harness currently covers a blocking command | +| UI hostility | Protocol `tests/ui.rs` and `src/ui.rs`; renderer/view tests; native validation benchmark | Installed-app adversarial rendering and measured frame timing under load | +| Authority | `permissions.rs`, manager broker tests, generation/context checks | Installed-app forged/stale interaction scenarios in combination with real UI transitions | +| Context races | `platform.test.ts`, composer adapter/registry tests; real native broker cancellation, Git child reaping and update/uninstall serialization | Delayed request during project/thread/surface changes, uninstall during activation and HTTP/Git disable through the installed desktop | +| Package validation | `package.rs`: raw traversal/link/reserved-path fixtures, expansion limits, digest/identity checks; protocol manifest contracts | Supported-platform acceptance must retain these checks; installed import UX evidence is pending | +| Network | `http.rs`, `http_native_tests.rs`: real TLS, pinned DNS, limits, redirects, method/header/origin denial, timeout/cancellation and public HTTPS | Issue Companion's production HTTPS request and rendered result in the stock app | +| Secrets | `credentials.rs`: real Linux/Windows OS backends, locked-store fallback, source/origin isolation, historical-key cleanup and retry tombstones | Installed Settings credential/fallback/removal interaction and end-to-end redaction inspection | +| Persistence | Storage persistence, retained-source checks, corrupt registry and unclean-session tests | Installed app restart with grants/settings and core startup under corrupt plugin state | +| Updates | Lifecycle tests: eight durable interruptions, actual SQLite full, candidate failure, matching-data rollback, cancelled review, uninstall serialization | Filesystem disk-full boundaries and active-host desktop update/rollback races | +| UI/accessibility | Renderer/view/Settings tests and light/dark/small-window/GUI-off browser screenshots | Native keyboard/screen-reader checks, pane restoration, empty accessory/footer spacing | +| Remote boundary | Native RPC add-on rejection before rewriting and event forwarding; frontend remote/workspace checks | Installed desktop/remote client combination with active plugins | +| Website/catalog | Protocol/catalog validator, website catalog tests/build and desktop/mobile screenshots | Reviewed package releases and immutable catalog artifact; website/native digest identity against that artifact | +| Packaged platforms | deb/AppImage/NSIS payload provenance, clean-environment SDK callbacks and hostile-host deadlines | Stock desktop GUI acceptance is additional to payload checks | + +## Running the installed-app harness + +`addon-packaged.yml` builds the normal release installers and independent +packages, verifies their payloads, then runs external `tauri-driver` against the +installed desktop. It does not enable a production test server or change the +application binary. The harness refuses local and self-hosted runners. + +The account API is a synthetic loopback fixture. The single-use native file +chooser seam returns the chosen archive path; all package validation, review, +permissions, installation, native host execution and effects remain real. +Issue Companion uses unauthenticated read-only public GitHub HTTPS through the +production broker. A rate limit is a failed gate, not a substituted success. + +`addon-native-ui.yml` can retry the harness against the same run's saved +installer inputs, choosing Linux, Windows or both. Its ordinary PR job checks +only syntax and the refusal guard; that green check is **not native UI evidence**. +Every execution records the installer build revision, harness revision and +installer SHA-256 separately, plus per-step screenshots and text. A harness-only +retry never implies a newly compiled app was tested. Failure artifacts must be +read before changing either the harness or the implementation. diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index 553e07c8..1ce28584 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -7,6 +7,8 @@ pre-existing Hermes work and website changes are preserved. **Unreleased implementation. The acceptance matrix is not complete.** Passing standalone host tests, browser mocks, or the research probe is not release approval. +The [acceptance evidence map](ACCEPTANCE.md) connects each matrix row to its +checks and outstanding installed-app evidence. ## Ordered delivery @@ -36,8 +38,11 @@ publication and Settings remain separate deliverables. Linux deb/AppImage and Windows NSIS. Maximum observed fault latencies are 1007.0 ms and 1014.7 ms respectively. Recorded [Linux](evidence/packaged-linux-3045.json) and [Windows](evidence/packaged-windows-3045.json) results include source-run - provenance. Subsequent CI harness changes do not change production app code; - native desktop UI acceptance remains separate and pending. + provenance. Commits through `33acb551` subsequently changed the CI harness, + not production app code. A later renderer correction makes Markdown link + controls explicitly non-submitting: its containing-form regression failed + before the fix, then all six renderer tests and TypeScript passed. Installed + desktop UI acceptance of the final revision remains separate and pending. - PR 1 hosted CI: Linux and Windows GNU host, manifest contracts, SDK callback integration all passed. Schema comparison normalizes Windows CRLF only. diff --git a/src/components/addons/addon-renderer.test.tsx b/src/components/addons/addon-renderer.test.tsx index 0e00e601..8eea4918 100644 --- a/src/components/addons/addon-renderer.test.tsx +++ b/src/components/addons/addon-renderer.test.tsx @@ -116,6 +116,31 @@ describe("trusted add-on rendering", () => { fireEvent.keyDown(second, { key: "Home" }); expect(document.activeElement).toBe(first); }); + it("does not submit a containing form when a plugin Markdown link is activated", () => { + const submit = vi.fn(); + const link = vi.fn(); + render( +
{ + event.preventDefault(); + submit(); + }} + > + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + expect(link).toHaveBeenCalledOnce(); + expect(submit).not.toHaveBeenCalled(); + }); it("bounds mounted list rows even for the maximum 500-row input", () => { render( null, a: ({ href, children }) => ( + {saved && ( Saved @@ -414,7 +440,7 @@ export function AddonsSettings() { )} {installation ? ( setSelected(null)} onError={setError} From a6101f34282f8f4ca798bf4f0a3c940c644a2891 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 18 Sep 2026 21:08:16 +0200 Subject: [PATCH 012/155] fix: preserve repository excludes in add-on Git summaries --- .github/workflows/addon-native-ui.yml | 2 +- .github/workflows/addon-packaged.yml | 2 +- docs/addons/ACCEPTANCE.md | 6 +- docs/addons/IMPLEMENTATION.md | 38 ++++- .../addons/evidence/native-package-review.png | Bin 0 -> 109143 bytes .../native-settings-configuration.png | Bin 0 -> 113677 bytes .../evidence/native-ui-linux-partial.json | 28 +++ .../addons/fixtures/fault-isolation/README.md | 5 +- .../fixtures/fault-isolation/manifest.json | 25 ++- .../fixtures/fault-isolation/src/index.tsx | 17 ++ scripts/addons/native-ui.mjs | 161 +++++++++++++----- src-tauri/src/addons/git.rs | 47 ++++- 12 files changed, 276 insertions(+), 55 deletions(-) create mode 100644 docs/addons/evidence/native-package-review.png create mode 100644 docs/addons/evidence/native-settings-configuration.png create mode 100644 docs/addons/evidence/native-ui-linux-partial.json diff --git a/.github/workflows/addon-native-ui.yml b/.github/workflows/addon-native-ui.yml index 64967c25..6cc783e1 100644 --- a/.github/workflows/addon-native-ui.yml +++ b/.github/workflows/addon-native-ui.yml @@ -83,7 +83,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-0 libayatana-appindicator3-1 webkit2gtk-driver xvfb dbus-x11 + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-0 libayatana-appindicator3-1 webkit2gtk-driver xvfb dbus-x11 imagemagick - uses: dtolnay/rust-toolchain@stable - name: Install external WebDriver run: cargo install tauri-driver --version 2.0.6 --locked diff --git a/.github/workflows/addon-packaged.yml b/.github/workflows/addon-packaged.yml index 1ac79ea5..eb674bd5 100644 --- a/.github/workflows/addon-packaged.yml +++ b/.github/workflows/addon-packaged.yml @@ -54,7 +54,7 @@ jobs: librsvg2-dev \ patchelf \ libfuse2 \ - file webkit2gtk-driver xvfb dbus-x11 + file webkit2gtk-driver xvfb dbus-x11 imagemagick - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/docs/addons/ACCEPTANCE.md b/docs/addons/ACCEPTANCE.md index d50d7afa..fb33d2e2 100644 --- a/docs/addons/ACCEPTANCE.md +++ b/docs/addons/ACCEPTANCE.md @@ -12,7 +12,7 @@ results cannot establish the corresponding desktop behavior. | Core independence | Existing full desktop CI; Settings browser evidence; native UI harness pause/resume | Clean/paused startup, zero host processes before use, terminal/projects/themes/update controls and pane restoration in installed apps | | Functional SDK | Real-host package integrations in `manager.rs`; `sdk-native.mjs`; installed-app `native-ui.mjs` | Both packages' actual UI/draft/HTTPS operations must pass on Linux and Windows | | Author independence | Both examples consume packed public SDK/CLI; Issue Companion built outside the checkout | Reviewed SDK distribution and final documentation against the published version | -| Runtime failure | `addon-host/tests`, native `protocol.rs`, manager fault isolation; packaged five-workload evidence | Installed-app responsiveness for all hostile workloads, including exit and ignored shutdown; the UI harness currently covers a blocking command | +| Runtime failure | `addon-host/tests`, native `protocol.rs`, manager fault isolation; packaged five-workload evidence | Installed-app responsiveness for all hostile workloads, including exit and ignored shutdown; the UI harness targets all five SDK callback workloads | | UI hostility | Protocol `tests/ui.rs` and `src/ui.rs`; renderer/view tests; native validation benchmark | Installed-app adversarial rendering and measured frame timing under load | | Authority | `permissions.rs`, manager broker tests, generation/context checks | Installed-app forged/stale interaction scenarios in combination with real UI transitions | | Context races | `platform.test.ts`, composer adapter/registry tests; real native broker cancellation, Git child reaping and update/uninstall serialization | Delayed request during project/thread/surface changes, uninstall during activation and HTTP/Git disable through the installed desktop | @@ -29,8 +29,8 @@ results cannot establish the corresponding desktop behavior. ## Running the installed-app harness `addon-packaged.yml` builds the normal release installers and independent -packages, verifies their payloads, then runs external `tauri-driver` against the -installed desktop. It does not enable a production test server or change the +packages, verifies their payloads, then runs external WebDriver against the +installed desktop (`tauri-driver` on Linux; Microsoft WebView2 attach on Windows). It does not enable a production test server or change the application binary. The harness refuses local and self-hosted runners. The account API is a synthetic loopback fixture. The single-use native file diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index 1ce28584..db279d2e 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -118,6 +118,19 @@ publication and Settings remain separate deliverables. focus when Escape closes its install dialog. These remain browser-preview checks, not native screen-reader/desktop E2E. Fixtures are removed from the seed. +- Settings configuration controls wait for the stored values before accepting + edits or submission. A disposed release's late response/error is ignored. + TypeScript and three focused Settings tests pass for this guard. + +- Native Git exclude regression failed before the fix and passes afterward for + both ordinary and linked worktrees. All seven focused native Git tests and + `cargo check -j 2` pass. The snapshot copies only bounded regular-file exclude + data; repository programs remain disabled. +- The expanded public-SDK hostile fixture builds and packs independently. Its + five commands terminate real local hosts in 1.7–253.0 ms (blocking loop, throw, + endless promises, recursion, allocation). These standalone timings do not + establish installed-app interactivity. + ## Evidence-backed implementation clarifications - The sample uses `includeFiles`, but chapter 5 requires @@ -195,12 +208,29 @@ and controlled draft insertion use the production app. Windows downloads the driver matching WebView2 and verifies Microsoft's executable signature. Installer and harness revisions are recorded separately so a harness-only retry cannot imply a newer app build was tested. This harness has passed syntax, -workflow-parse and local-refusal checks; **native execution is still pending**. +workflow-parse and local-refusal checks. A [stock Linux run](https://github.com/Zeus-Deus/codemux/actions/runs/35382610101) +passed Settings startup, actual review/import of both independent examples and +the hostile fixture, disable/enable and native configuration persistence. +[Partial evidence](evidence/native-ui-linux-partial.json) records exact installer +and harness revisions. Project Brief rendered real Git data, but the count +assertion exposed missing repository-local exclude rules in the private Git +snapshot. The run failed there; draft, HTTPS and hostile-GUI steps remain +unverified. [Native review](evidence/native-package-review.png) and +[configuration](evidence/native-settings-configuration.png) screenshots use only +synthetic runner data. A later [Linux retry](https://github.com/Zeus-Deus/codemux/actions/runs/35383319034) +passes the Git assertion with a versioned ignore fixture, then stops because +WebKit screenshot capture times out after the terminal canvas appears; text +queries still work. [Windows](https://github.com/Zeus-Deus/codemux/actions/runs/35383388810) +stops at driver session creation. The harness now captures Linux's actual X +display and uses Microsoft's [documented WebView2 attach mode](https://learn.microsoft.com/en-us/microsoft-edge/webview2/how-to/webdriver#step-4b-attaching-microsoft-edge-webdriver-to-a-running-webview2-app) +with a debug port enabled only in the disposable CI child environment. No +production test hook or app configuration change is introduced. Both platforms +require a passing retry. It currently targets import/review, enable/disable, both example views and draft insertion, read-only public GitHub HTTPS, no auto-submit, pause/resume, and an -independently packaged blocking-command fixture while typing in the core draft -and reopening Project Brief. These targets are not evidence until execution -passes, and do not cover the full race matrix. The OS file chooser and account +independently packaged five-workload fixture while typing in the core draft +and reopening Project Brief. Only the completed steps above are evidence; the remaining targets have not +passed and the harness does not cover the full race matrix. The OS file chooser and account service are explicit fixture seams. - Stock built-app E2E for both examples, including real frontend/native IPC, diff --git a/docs/addons/evidence/native-package-review.png b/docs/addons/evidence/native-package-review.png new file mode 100644 index 0000000000000000000000000000000000000000..852fff86db4ce3adb1801f7a35b75cb87693d9a7 GIT binary patch literal 109143 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⁢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
3z^Q&PTBdO+a+0F-aC6b5~p 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 h(^R$aAIEiggoTII!c{i^LFekDE`Oz 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ΠyDvF7oYy|*@f18%qGw`eD#I1UX9-&MK_g(*w!4v#2LC|u z*9qR6%NsQpJ=01VT~Qw?U${r1(aMiVU~TvDQ$94zQ}gXKqodS~)jCye$=HS{aH)Z_ z`)-pb!J1F?TldVu*Voswvzh6L&DMN>o?Rz|guZze$8Qvt6QpFM5~0k3b~`(P3pf$c zaHopt;UU|#Ya9ZlQ(P|^?$0)VCA@w69TRg$=hH<4HcDQH)FFA+XPL8Gj}>6Ss!vH7 znVA6bQ~2Y@xAys(gPomXlkV%+90!&ihy&+EFPqHoV8m%I6>I z+c??9@OplMG7Xw@x(i<{70#M;H{J~`F78j=o_$JOD3Nmi##Qj!P74#qetr}q`5{PT zbK9WqbB}{{s)yvnxarUGm5u~7;0_`w_8axG-cQ7Hyy585+tH7FOZs_Y_-G zJ&#=9O^nVdcxTFK{z3Goz1kiKc>6ltg4FQiJ~~Ra;riDQ(!P1Ew=6-O2Z#IG$D=WR zd4pitqobt&Dfzlalm!xjRLecxK%^BVZ_AMVFan6VmDPG%oAmbct$zRJ**3$SwYXXj zPg2})>GMlpD@hWONZoQl9v*B7y{hcPhta-`jnnUw;>Zv0haJk!eMZ?Q8_p#6u4&Kag%$AP6r#P|%0wI1K9`l{jJ+_u7Tf7*ys`G` z>=aOA>V7AYE2dnmLLio{W)1fKOm6lSFhr^t