diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c25f3219..ac909c79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,7 @@ jobs: - name: Architecture names, release authentication and signed targets run: node --test scripts/test-native-arch.mjs scripts/test-native-release-auth.mjs - name: Debian package assembly, runtime repack and desktop metadata - run: node --test scripts/test-native-deb.mjs scripts/test-llama-runtime.mjs scripts/test-native-desktop-metadata.mjs scripts/test-native-app-run.mjs + run: node --test scripts/test-native-deb.mjs scripts/test-llama-runtime.mjs scripts/test-native-desktop-metadata.mjs scripts/test-native-app-run.mjs scripts/test-native-webkit-paths.mjs - name: Packaging PATH and pinned sources run: node --test scripts/test-native-packaging-env.mjs scripts/test-native-source-fetch.mjs - name: Installer on this architecture diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 486ab143..bf84d906 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -48,7 +48,7 @@ jobs: - name: Native window dependencies run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev libasound2-dev patchelf webkit2gtk-driver xvfb appstream dbus-daemon dbus-bin lintian desktop-file-utils xdg-utils - name: Test release staging and immutable retry behavior - run: node --test scripts/test-native-release.mjs scripts/test-native-release-verification.mjs scripts/test-publisher-auth.mjs scripts/test-native-release-signing.mjs scripts/test-native-test-session.mjs scripts/test-clean-host-mcp-receipt.mjs scripts/test-clean-host-container.mjs scripts/test-native-app-run.mjs scripts/test-llama-build-inputs.mjs scripts/test-native-arch.mjs && node scripts/test-native-process-ownership.mjs + run: node --test scripts/test-native-release.mjs scripts/test-native-release-verification.mjs scripts/test-publisher-auth.mjs scripts/test-native-release-signing.mjs scripts/test-native-test-session.mjs scripts/test-clean-host-mcp-receipt.mjs scripts/test-clean-host-container.mjs scripts/test-native-app-run.mjs scripts/test-native-webkit-paths.mjs scripts/test-llama-build-inputs.mjs scripts/test-native-arch.mjs && node scripts/test-native-process-ownership.mjs - name: Verify publisher authentication and private release snapshots run: node --test scripts/test-native-release-auth.mjs scripts/test-check-secrets.mjs - name: Package version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fe92ce5..59b9a602 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,10 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Native package dependencies run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev libasound2-dev patchelf webkit2gtk-driver xvfb appstream dbus-daemon dbus-bin lintian desktop-file-utils + # Additional mandatory regression coverage keeps the independently approved + # signing receipt command set byte-identical. No existing gate is removed. + - name: Verify WebKit relocation before release qualification + run: node --test --test-reporter=tap scripts/test-native-webkit-paths.mjs - name: Test release staging and immutable retry behavior run: node scripts/native-release-verification.mjs run release-tests - name: Verify publisher authentication and private release snapshots @@ -174,6 +178,10 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev libasound2-dev patchelf webkit2gtk-driver xvfb appstream dbus-daemon dbus-bin lintian desktop-file-utils xdg-utils cmake ninja-build libssl-dev libvulkan-dev glslc spirv-headers gcc-14 g++-14 - name: Install the pinned Rust toolchain run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy + # Additional mandatory regression coverage keeps the independently approved + # signing receipt command set byte-identical. No existing gate is removed. + - name: Verify WebKit relocation before release qualification + run: node --test --test-reporter=tap scripts/test-native-webkit-paths.mjs - name: Run every required release gate in order on aarch64 run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 3585ab33..39318e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 2.3.2: Desktop reliability + +- Relocate bundled WebKit helper paths so AppImages do not depend on the build host's Debian filesystem layout. Verify relocation against the shipped library bytes. +- On compatible non-Debian desktop layouts, select a complete host WebKit/GTK stack to avoid mixing newer Mesa drivers with the bundled Debian graphics stack. Both helper executables and all native dependencies are checked; the bundled fallback remains available. No sandbox is disabled. +- Closing the model picker no longer steals focus back from a composer the user has already focused. This fixes interrupted typing and accidental picker reopening. +- Vendor discovery no longer selects unknown or interactive ACP authentication methods such as Grok browser login. It checks an existing session and leaves interactive sign-in to Connect. Known existing-login methods remain supported. +- Sidebar shortcuts display Command on Apple keyboards and Control on Windows/Linux. +- Add installed-engine endurance qualification for Windows, macOS, and Linux, with platform-specific process measurements and explicit test-scope limits. +- Editor browser tests use the platform's document-navigation and editing shortcuts. + + - About → Website now opens https://shadowcode.org (was shadowfetchlinux.org/shadowcode); the AppStream homepage URL matches. ## 2.3.1: Delegation prerequisites diff --git a/Cargo.lock b/Cargo.lock index 14dcca78..1c8f429b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4512,7 +4512,7 @@ dependencies = [ [[package]] name = "shadowcode-core" -version = "2.3.1" +version = "2.3.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -4575,7 +4575,7 @@ dependencies = [ [[package]] name = "shadowcode-desktop" -version = "2.3.1" +version = "2.3.2" dependencies = [ "anyhow", "gdk", diff --git a/Cargo.toml b/Cargo.toml index 6dd82530..abd0651b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor/glib-0.18.5"] resolver = "2" [workspace.package] -version = "2.3.1" +version = "2.3.2" edition = "2021" rust-version = "1.95" license = "Apache-2.0" diff --git a/docs/PLATFORM_QUALIFICATION.md b/docs/PLATFORM_QUALIFICATION.md new file mode 100644 index 00000000..40cecd41 --- /dev/null +++ b/docs/PLATFORM_QUALIFICATION.md @@ -0,0 +1,96 @@ +# Physical-machine platform qualification + +## 2.3.2 candidate — November 1, 2026 + +This is a qualification record, not a claim that the candidate has been released. +Final package installation, hashes, screenshots, and website deployment receipts +will be added after the release builds. Prior evidence remains version-specific. + +### Problems reproduced + +- On Omarchy/Arch, the public 2.3.1 AppImage's engine CLI started, but its native + window failed when WebKit looked for `WebKitNetworkProcess` at the Debian build + host's absolute path. That path does not exist on Arch. Relocating the compiled + helper paths then exposed an EGL initialization failure with the bundled stack + and the host's newer Mesa drivers. +- The candidate relocates only the known, equal-length WebKit path strings, + checks both helper executables, retains a before/after hash receipt and the + relocation program in the package, and verifies the actual packaged library. +- The launcher can select a complete host WebKit/GTK stack when its helper layout + differs from Debian. It refuses missing native dependencies or helpers. It keeps + AppImage extraction ownership, does not disable the sandbox, and avoids probing + the graphics stack for ordinary CLI commands. `SHADOWCODE_APPIMAGE_STACK` can be + `auto` (default), `bundled`, or `host`; explicitly requesting an incomplete host + stack fails rather than silently selecting it. +- The isolated candidate AppDir on Omarchy reached the native first-window-loaded + marker after this change. This does not yet qualify a final signed 2.3.2 AppImage. +- The model picker could restore focus after model selection had already focused + the composer. Typing a space could then reopen the picker. A deterministic unit + regression failed before the fix. Close now respects focus moved outside the + picker, including focus moved synchronously by the selection callback. +- Editor browser scenarios sent Control shortcuts on macOS; they now use the + platform's Command editing and document-navigation shortcuts. Sidebar hints + follow the viewing keyboard, including remote browsers. + +- Physical desktop testing also reproduced Grok's interactive `authenticate` flow + opening a browser during background discovery. The probe now authenticates only + through known existing-login methods; other advertised methods use `session/new` + to check an existing login and leave interactive authentication to explicit Connect. + A regression covers both an existing session and authentication-required responses. + +### Physical Wayland packaging experiment + +A focused native-window smoke ran on the actual Omarchy Hyprland compositor with +its existing tiling and scale. It passed 12 checks: onboarding, picker and refresh, +local test-model registration/selection, accessibility checks, approved file-write +and command execution, the completed task summary, and application/child shutdown. +The executable was the 2.3.1 baseline in an isolated AppDir with the candidate +launcher and library relocation. A scripted test server supplied model responses; +this is neither real inference nor final 2.3.2 package qualification. + +The unmodified upstream desktop suite first rejected the compositor's tiled size +because it expected a floating window fitted to 90% of the screen. That failure +is retained. The focused smoke explicitly excludes floating-size policy, scripted +resizing, recovery and endurance. Grok was excluded from the successful baseline +smoke after its unsolicited login was reproduced; candidate Grok discovery has +separate regression coverage and still needs final physical-package verification. + +### Installed-engine 2.3.1 baseline + +The new `scripts/test-platform-runtime.mjs` was run on each physical machine's +installed native executable, with disposable profiles/projects and a loopback +scripted provider. Each passed 100 sequential tasks, 80 concurrent tasks, five +provider-stall cancellations, bounded resource growth, and retrieval of completed +and cancelled tasks after stopping and reopening the engine. + +| Machine | Engine RSS, warm to final | Resource counts, warm to final | Result | +| --- | --- | --- | --- | +| Windows 11 x64 laptop | 23,964 → 32,856 KiB | 203 → 215 handles | Passed | +| Omarchy x64 laptop | 69,788 → 78,412 KiB | 18 → 18 file descriptors | Passed | +| Intel iMac, macOS 15 | 17,444 → 23,992 KiB | 15 → 15 file descriptors | Passed | + +Raw versioned binary hashes and measurements are in +[`qa/platform-baseline-2.3.1/`](qa/platform-baseline-2.3.1/). Omarchy's native +executable was extracted from the publisher-authenticated installed AppImage. +The Windows installed runtime also passed the clean-PATH desktop, llama-server, +llama-cli, and isolated async CLI checks. + +These engine results do not certify native rendering, inference-server memory, +real model quality/speed, GPU endurance, or cloud-provider accounts. Unix shutdown +uses SIGTERM; the Windows probe verifies persistence after forced termination and +does not claim graceful Windows shutdown. The original Linux stress suite keeps +its independent, stricter process-resource and SQLite-integrity coverage. + +### Running the portable probe + +Use Node 22 or newer. Set `SHADOW_STRESS_BINARY` to the installed native executable +(not an AppImage extraction wrapper or shell launcher), and optionally set +`SHADOW_STRESS_ARTIFACTS` to the output directory. Run: + +```text +node scripts/test-platform-runtime.mjs +``` + +No API keys or downloaded model weights are required. This probe starts its own +loopback provider and engine and removes its temporary profile/project afterward. +It never runs tasks against the user's normal project or profile. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index d63cbff0..51789b24 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,3 +1,18 @@ +# ShadowCode 2.3.2 — candidate + +This source candidate addresses an AppImage desktop-startup failure reproduced on +Omarchy, a model-picker focus race, unsolicited interactive ACP login during vendor discovery, and misleading Mac keyboard hints. It is not +yet a qualified public release. Final package hashes, physical-machine results, +and publication receipts will be recorded after the release builds. + +The new platform endurance probe exercises the installed native engine against +a loopback scripted provider. It measures 100 sequential tasks, 80 concurrent +tasks, five cancellations, process resource growth, and persisted state after +stopping/reopening. It does not certify desktop rendering, paid-provider +accounts, real-model quality, GPU inference, or inference-server memory. +Windows persistence is tested after forced process termination; graceful +Windows shutdown is not claimed by that probe. + # ShadowCode 2.3.1 Write-subagent guidance now names its Git-history prerequisite and directs diff --git a/docs/qa/platform-baseline-2.3.1/imac.json b/docs/qa/platform-baseline-2.3.1/imac.json new file mode 100644 index 00000000..83b8f469 --- /dev/null +++ b/docs/qa/platform-baseline-2.3.1/imac.json @@ -0,0 +1,58 @@ +{ + "passed": true, + "platform": "darwin", + "osRelease": "24.6.0", + "version": "ShadowCode 2.3.1", + "binarySha256": "0122cfbff84c62f113386db66998d521f64397ae5e62476559df1f374fc72c9a", + "sequentialCompleted": 100, + "concurrentCompleted": 80, + "cancelled": 5, + "requests": 367, + "measurements": [ + { + "round": "warmup", + "rssKiB": 17444, + "threads": 6, + "resources": 15, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-25", + "rssKiB": 21148, + "threads": 6, + "resources": 15, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-50", + "rssKiB": 21704, + "threads": 6, + "resources": 15, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-75", + "rssKiB": 21768, + "threads": 6, + "resources": 15, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-100", + "rssKiB": 21840, + "threads": 7, + "resources": 15, + "resourceKind": "file descriptors" + }, + { + "round": "completed", + "rssKiB": 23992, + "threads": 10, + "resources": 15, + "resourceKind": "file descriptors" + } + ], + "persistenceAfterStop": true, + "shutdown": "SIGTERM graceful shutdown", + "scope": "Installed native engine with loopback scripted provider. Not desktop UI, paid providers, real model inference or inference-server memory." +} \ No newline at end of file diff --git a/docs/qa/platform-baseline-2.3.1/omarchy.json b/docs/qa/platform-baseline-2.3.1/omarchy.json new file mode 100644 index 00000000..a05dd346 --- /dev/null +++ b/docs/qa/platform-baseline-2.3.1/omarchy.json @@ -0,0 +1,58 @@ +{ + "passed": true, + "platform": "linux", + "osRelease": "7.2.8-9-omarchy", + "version": "ShadowCode 2.3.1", + "binarySha256": "86a0808c19ad48a5cac50a4df4805bba1f339c1b15b02d00a6d13e163fc64dce", + "sequentialCompleted": 100, + "concurrentCompleted": 80, + "cancelled": 5, + "requests": 367, + "measurements": [ + { + "round": "warmup", + "rssKiB": 69788, + "threads": 6, + "resources": 18, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-25", + "rssKiB": 75312, + "threads": 6, + "resources": 18, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-50", + "rssKiB": 75968, + "threads": 6, + "resources": 18, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-75", + "rssKiB": 76360, + "threads": 7, + "resources": 18, + "resourceKind": "file descriptors" + }, + { + "round": "sequential-100", + "rssKiB": 76376, + "threads": 7, + "resources": 18, + "resourceKind": "file descriptors" + }, + { + "round": "completed", + "rssKiB": 78412, + "threads": 11, + "resources": 18, + "resourceKind": "file descriptors" + } + ], + "persistenceAfterStop": true, + "shutdown": "SIGTERM graceful shutdown", + "scope": "Installed native engine with loopback scripted provider. Not desktop UI, paid providers, real model inference or inference-server memory." +} \ No newline at end of file diff --git a/docs/qa/platform-baseline-2.3.1/windows.json b/docs/qa/platform-baseline-2.3.1/windows.json new file mode 100644 index 00000000..73a3a443 --- /dev/null +++ b/docs/qa/platform-baseline-2.3.1/windows.json @@ -0,0 +1,58 @@ +{ + "passed": true, + "platform": "win32", + "osRelease": "10.0.26200", + "version": "ShadowCode 2.3.1", + "binarySha256": "62c405b64b18c440eda1300d08f39056dab6d546d4b604eb8c0026095bfb55f2", + "sequentialCompleted": 100, + "concurrentCompleted": 80, + "cancelled": 5, + "requests": 367, + "measurements": [ + { + "round": "warmup", + "rssKiB": 23964, + "threads": 14, + "resources": 203, + "resourceKind": "handles" + }, + { + "round": "sequential-25", + "rssKiB": 27508, + "threads": 14, + "resources": 205, + "resourceKind": "handles" + }, + { + "round": "sequential-50", + "rssKiB": 28144, + "threads": 14, + "resources": 205, + "resourceKind": "handles" + }, + { + "round": "sequential-75", + "rssKiB": 28048, + "threads": 14, + "resources": 205, + "resourceKind": "handles" + }, + { + "round": "sequential-100", + "rssKiB": 28216, + "threads": 14, + "resources": 205, + "resourceKind": "handles" + }, + { + "round": "completed", + "rssKiB": 32856, + "threads": 16, + "resources": 215, + "resourceKind": "handles" + } + ], + "persistenceAfterStop": true, + "shutdown": "forced termination; graceful shutdown not tested", + "scope": "Installed native engine with loopback scripted provider. Not desktop UI, paid providers, real model inference or inference-server memory." +} \ No newline at end of file diff --git a/native/core/src/cli_agent/acp_probe.rs b/native/core/src/cli_agent/acp_probe.rs index bbd1a9b2..55081388 100644 --- a/native/core/src/cli_agent/acp_probe.rs +++ b/native/core/src/cli_agent/acp_probe.rs @@ -1,7 +1,7 @@ //! Read-only Agent Client Protocol probe for ACP vendors (Cursor, Grok). //! -//! Runs the documented handshake — `initialize`, `authenticate` when the agent -//! advertises an auth method, `session/new` — and records what the agent +//! Runs the documented handshake — `initialize`, an existing-login check only +//! for known noninteractive auth methods, `session/new` — and records what the agent //! reports about itself: auth methods, prompt capabilities (whether images are //! accepted on the wire), available modes, and the model catalog. No prompt is //! sent, so no plan allowance is consumed. The agent keeps its own login; the @@ -393,26 +393,24 @@ async fn probe_inner( probe.current_model = current; } } - let next = if probe.auth_methods.is_empty() { - rpc(3, "session/new", json!({"cwd": cwd, "mcpServers": []})) - } else { - // Prefer the vendor's own login over a cached-token - // method so an expired cache is reported, not assumed. - let method = probe - .auth_methods - .iter() - .find(|m| { - m.as_str() == "cursor_login" - || m.as_str() == "cached_token" - || (antigravity - && m.as_str() == super::antigravity_server::AUTH_METHOD) - }) - .cloned() - .unwrap_or_else(|| probe.auth_methods[0].clone()); + // Advertising a method does not make it a status check: + // Grok's `grok.com` authenticate starts browser login. + // Unknown/interactive methods must wait for explicit Connect. + // session/new can still verify an already cached login and + // report authentication required without initiating one. + let existing_login = probe.auth_methods.iter().find(|m| { + m.as_str() == "cursor_login" + || m.as_str() == "cached_token" + || (antigravity && m.as_str() == super::antigravity_server::AUTH_METHOD) + }); + let next = if let Some(method) = existing_login.cloned() { requested_auth_method = Some(method.clone()); + pending.insert(2); rpc(2, "authenticate", json!({"methodId": method})) + } else { + pending.insert(3); + rpc(3, "session/new", json!({"cwd": cwd, "mcpServers": []})) }; - pending.insert(if probe.auth_methods.is_empty() { 3 } else { 2 }); stdin.write_all(next.as_bytes()).await?; stdin.write_all(b"\n").await?; stdin.flush().await?; diff --git a/native/core/tests/vendor_acp_catalog.rs b/native/core/tests/vendor_acp_catalog.rs index 991c6069..bd13d80c 100644 --- a/native/core/tests/vendor_acp_catalog.rs +++ b/native/core/tests/vendor_acp_catalog.rs @@ -43,6 +43,7 @@ for raw in sys.stdin: result = {'sessionId':'fixture-session','models':{'currentModelId':'fixture-model', 'availableModels':[{'modelId':'fixture-model','name':'Fixture model'}]}} if cfg.get('session_error'): error = 'fixture session unavailable' + if cfg.get('session_auth_required'): error = 'authentication required' else: result = {} reply = {'jsonrpc':'2.0','id':frame['id']} @@ -116,6 +117,28 @@ impl Fixture { } } +#[tokio::test] +async fn grok_catalog_does_not_start_interactive_login_during_discovery() { + for (requires_login, expected) in [(false, Availability::Ready), (true, Availability::SignIn)] { + let fake = Fixture::new(json!({ + "version": 1, + "auth_methods": [{"id":"grok.com"}], + "session_auth_required": requires_login, + })); + let catalog = VendorCatalog::new(); + let status = catalog + .refresh(Vendor::Grok, &fake.config(Vendor::Grok), true) + .await; + assert_eq!(status.availability, expected, "{status:?}"); + assert_eq!( + fake.log("methods.jsonl"), + vec![json!("initialize"), json!("session/new")], + "Discovery may check an existing session but must never initiate browser login" + ); + assert!(!fake.root.path().join("selected_auth.json").exists()); + } +} + #[tokio::test] async fn acp_discovery_rejects_unsupported_versions_before_auth_or_session() { for config in [ diff --git a/packaging/com.shadowfetch.shadowcode.appdata.xml b/packaging/com.shadowfetch.shadowcode.appdata.xml index 02bb1336..cb820967 100644 --- a/packaging/com.shadowfetch.shadowcode.appdata.xml +++ b/packaging/com.shadowfetch.shadowcode.appdata.xml @@ -29,6 +29,9 @@ shadowcode + + Improves AppImage startup on rolling Linux desktops, model-picker focus, and platform keyboard hints. + Clarify Git-history requirements for write subagents and guide plain-folder tasks toward direct edits or read-only delegation. diff --git a/packaging/native-app-run.sh b/packaging/native-app-run.sh index 64dc5bc2..7cb9f63d 100644 --- a/packaging/native-app-run.sh +++ b/packaging/native-app-run.sh @@ -4,6 +4,44 @@ set -eo pipefail APPDIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" export APPDIR +# Rolling distributions can have a newer Mesa/WebKit stack whose EGL libraries +# cannot be mixed with the bundled Debian GTK stack. Prefer a complete host stack +# when its helper layout differs, never a half-host set of libraries. Release +# WebKit ignores WEBKIT_EXEC_PATH, so inspect the compiled path in the library. +shadowcode_stack="${SHADOWCODE_APPIMAGE_STACK:-auto}" +case "$shadowcode_stack" in auto|host|bundled) ;; *) echo 'SHADOWCODE_APPIMAGE_STACK must be auto, host or bundled' >&2; exit 1;; esac +shadowcode_gui=0 +[[ $# == 0 ]] && shadowcode_gui=1 +for shadowcode_arg in "$@"; do [[ "$shadowcode_arg" == ui ]] && shadowcode_gui=1; done +shadowcode_host_path="/usr/lib:/usr/lib64:/lib:/lib64:/usr/lib/x86_64-linux-gnu:/lib/x86_64-linux-gnu:/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu" +shadowcode_use_host=0 +if [[ "$shadowcode_gui" == 1 && "$shadowcode_stack" != bundled && -x /usr/bin/ldd && -x /usr/bin/strings ]]; then + shadowcode_deps="$(LC_ALL=C LD_LIBRARY_PATH="$shadowcode_host_path" /usr/bin/ldd "$APPDIR/usr/bin/shadowcode" 2>/dev/null || true)" + shadowcode_complete=1 + [[ -n "$shadowcode_deps" && "$shadowcode_deps" != *"not found"* && "$shadowcode_deps" != *"$APPDIR"* ]] || shadowcode_complete=0 + for shadowcode_lib in libwebkit2gtk-4.1.so.0 libjavascriptcoregtk-4.1.so.0 libgtk-3.so.0 libgdk-3.so.0 libglib-2.0.so.0 libgio-2.0.so.0 libgobject-2.0.so.0 libepoxy.so.0; do + [[ "$shadowcode_deps" == *"$shadowcode_lib => /"* ]] || shadowcode_complete=0 + done + shadowcode_webkit="$(printf '%s\n' "$shadowcode_deps" | awk '$1 == "libwebkit2gtk-4.1.so.0" && $2 == "=>" {print $3; exit}')" + shadowcode_helpers="" + if [[ "$shadowcode_complete" == 1 && -f "$shadowcode_webkit" ]]; then + shadowcode_helpers="$(/usr/bin/strings "$shadowcode_webkit" | grep -m1 -E '^/[^[:space:]]*/webkit2gtk-4\.1/?$' || true)" + fi + if [[ -x "$shadowcode_helpers/WebKitWebProcess" && -x "$shadowcode_helpers/WebKitNetworkProcess" ]]; then + if [[ "$shadowcode_stack" == host || ( "$shadowcode_helpers" != /usr/lib/x86_64-linux-gnu/webkit2gtk-4.1 && "$shadowcode_helpers" != /usr/lib/aarch64-linux-gnu/webkit2gtk-4.1 ) ]]; then + shadowcode_use_host=1 + fi + fi +fi +if [[ "$shadowcode_stack" == host && "$shadowcode_gui" == 1 && "$shadowcode_use_host" != 1 ]]; then + echo 'A complete compatible host WebKitGTK stack and both helpers are required.' >&2 + exit 1 +fi +if [[ "$shadowcode_use_host" == 1 ]]; then + # Keep APPDIR/APPIMAGE so extraction ownership and cleanup remain intact. + export LD_LIBRARY_PATH="$shadowcode_host_path" + exec "$APPDIR/usr/bin/shadowcode" "$@" +fi # The bundled GTK hook defaults to X11. Preserve an explicit caller choice, # including Wayland, while keeping that default when no backend was selected. shadowcode_gdk_backend="${GDK_BACKEND-}" diff --git a/packaging/shadow-agent.desktop b/packaging/shadow-agent.desktop index b0f9aec9..7e4e00cb 100644 --- a/packaging/shadow-agent.desktop +++ b/packaging/shadow-agent.desktop @@ -13,4 +13,4 @@ Keywords=agent;llm;coding;shadowfetch; StartupNotify=true StartupWMClass=shadowcode X-GNOME-UsesNotifications=true -X-ShadowCode-Version=2.3.1 +X-ShadowCode-Version=2.3.2 diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index aac4ef0d..918721ff 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -31,6 +31,7 @@ import { } from "./native-desktop-metadata.mjs"; import { finishDebianPackage } from "./native-deb.mjs"; import { hostArch, packageNames } from "./native-arch.mjs"; +import { relocateWebKit } from "./native-webkit-paths.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const packagingPath = applyPackagingPath(root); console.log(`Using sanitized packaging PATH: ${packagingPath}`); @@ -177,6 +178,9 @@ try { "target/release/bundle/appimage/ShadowCode.AppDir", ); await normalizeDesktopEntry(appdir, true); + // A Debian build host can hide absolute helper paths: they exist on CI, + // but not on Arch/Omarchy. Verify and relocate the actual shipped library. + await relocateWebKit(appdir, arch.multiarch); const runtimeInfo = await exec(appimage, ["--appimage-version"]); const runtimeVersion = runtimeInfo.stderr + runtimeInfo.stdout; if (!runtimeVersion.includes("/commit/75849dc")) diff --git a/scripts/check-native-package.mjs b/scripts/check-native-package.mjs index 336d5c7f..121ebd24 100644 --- a/scripts/check-native-package.mjs +++ b/scripts/check-native-package.mjs @@ -188,6 +188,14 @@ try { assert.match(version, /^ShadowCode \d+\.\d+\.\d+$/); await run(appimagePath, ["--appimage-extract"], { ...options, cwd: scratch }); const appdir = path.join(scratch, "squashfs-root"); + const relocation = JSON.parse(await readFile(path.join(appdir, "usr/share/doc/shadowcode/WEBKIT-RELOCATION.json"), "utf8")); + assert.match(relocation.library, /^usr\/lib\/libwebkit2gtk-4\.1\.so\.0(?:\.\d+)*$/, "WebKit relocation receipt must name the bundled library"); + assert.equal(relocation.oldPrefix, `/usr/lib/${hostArch().multiarch}/webkit2gtk-4.1`); + assert.equal(relocation.newPrefix, `././/lib/${hostArch().multiarch}/webkit2gtk-4.1`); + assert.equal(await digest(path.join(appdir, relocation.library)), relocation.afterSha256, "WebKit bytes must match the relocation receipt"); + const webkitBytes = await readFile(path.join(appdir, relocation.library)); + assert.ok(!webkitBytes.includes(Buffer.from(relocation.oldPrefix)), "Bundled WebKit must not search the build host's absolute helper path"); + assert.ok(webkitBytes.includes(Buffer.from(relocation.newPrefix)), "Bundled WebKit must retain the relative helper lookup"); const packaged = version.replace(/^ShadowCode /, ""); await verifyMetainfo(appdir, packaged); assert.equal( diff --git a/scripts/native-webkit-paths.mjs b/scripts/native-webkit-paths.mjs new file mode 100644 index 00000000..bc656257 --- /dev/null +++ b/scripts/native-webkit-paths.mjs @@ -0,0 +1,106 @@ +// Release WebKitGTK ignores WEBKIT_EXEC_PATH. Relocate its compiled helper and +// injected-bundle paths without changing ELF offsets. main.rs selects AppDir/usr +// as the GUI working directory before GTK starts. CLI working directories stay put. +import assert from "node:assert/strict"; +import { + access, + readFile, + realpath, + writeFile, + mkdir, + copyFile, +} from "node:fs/promises"; +import { constants } from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const digest = (b) => createHash("sha256").update(b).digest("hex"); +export async function relocateWebKit(appdir, multiarch) { + assert.ok( + ["x86_64-linux-gnu", "aarch64-linux-gnu"].includes(multiarch), + "Unknown WebKit architecture", + ); + const root = await realpath(appdir); + const lib = await realpath(path.join(root, "usr/lib/libwebkit2gtk-4.1.so.0")); + assert.ok( + lib.startsWith(root + path.sep), + "WebKit library must remain inside AppDir", + ); + const helper = path.join(root, "usr/lib", multiarch, "webkit2gtk-4.1"); + for (const name of ["WebKitWebProcess", "WebKitNetworkProcess"]) { + const file = await realpath(path.join(helper, name)); + assert.ok( + file.startsWith(root + path.sep), + "WebKit helper must remain inside AppDir", + ); + await access(file, constants.X_OK); + } + const original = await readFile(lib), + bytes = Buffer.from(original); + const before = Buffer.from(`/usr/lib/${multiarch}/webkit2gtk-4.1`); + const after = Buffer.from(`././/lib/${multiarch}/webkit2gtk-4.1`); + assert.equal( + before.length, + after.length, + "Relocation must preserve ELF layout", + ); + let replaced = 0; + for ( + let offset = bytes.indexOf(before); + offset >= 0; + offset = bytes.indexOf(before, offset + after.length) + ) { + assert.ok( + offset === 0 || bytes[offset - 1] === 0, + "Unexpected embedded WebKit path", + ); + assert.ok( + [0, 47].includes(bytes[offset + before.length]), + "Unexpected WebKit path suffix", + ); + after.copy(bytes, offset); + replaced++; + } + assert.ok( + bytes.includes(Buffer.concat([after, Buffer.from([0])])), + "Missing known helper lookup path; review this WebKit build", + ); + assert.ok(!bytes.includes(before), "Unrelocated WebKit paths remain"); + if (replaced) await writeFile(lib, bytes); + const receipt = { + library: path.relative(root, lib), + multiarch, + replaced, + beforeSha256: digest(original), + afterSha256: digest(bytes), + oldPrefix: before.toString(), + newPrefix: after.toString(), + workingDirectory: "AppDir/usr", + scope: + "Equal-length relocation of compiled helper and injected-bundle paths; no sandbox changes.", + }; + const docs = path.join(root, "usr/share/doc/shadowcode"); + await mkdir(docs, { recursive: true }); + await writeFile( + path.join(docs, "WEBKIT-RELOCATION.json"), + JSON.stringify(receipt, null, 2) + "\n", + ); + await copyFile( + fileURLToPath(import.meta.url), + path.join(docs, "native-webkit-paths.mjs"), + ); + return receipt; +} +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + console.log( + JSON.stringify( + await relocateWebKit(process.argv[2], process.argv[3]), + null, + 2, + ), + ); +} diff --git a/scripts/test-native-app-run.mjs b/scripts/test-native-app-run.mjs index 0ae4afe1..a86915be 100644 --- a/scripts/test-native-app-run.mjs +++ b/scripts/test-native-app-run.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { chmod, copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, copyFile, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -26,9 +26,29 @@ for (const backend of [undefined, "", "x11", "wayland"]) { if (backend !== undefined) env.GDK_BACKEND = backend; const args = ["ui", "a path with spaces", "$(must remain literal)"]; const { stdout } = await run("bash", [path.join(appdir, "AppRun"), ...args], { cwd, env, timeout: 5000 }); - assert.deepEqual(stdout.split("\0"), [backend || "x11", cwd, ...args, ""]); + assert.deepEqual(stdout.split("\0"), [backend || "x11", await realpath(cwd), ...args, ""]); } finally { await rm(root, { recursive: true, force: true }); } }); } + +for (const mode of ["invalid-mode", "host"]) { + test(`AppRun refuses ${mode} without silently weakening the stack check`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "shadowcode-stack-refusal-")); + try { + await mkdir(path.join(root, "usr/bin"), { recursive: true }); + await mkdir(path.join(root, "apprun-hooks")); + await writeFile(path.join(root, "apprun-hooks/linuxdeploy-plugin-gtk.sh"), "export GDK_BACKEND=x11\n"); + await copyFile(new URL("../packaging/native-app-run.sh", import.meta.url), path.join(root, "AppRun")); + await writeFile(path.join(root, "usr/bin/shadowcode"), "#!/bin/sh\necho must-not-run\n"); + await chmod(path.join(root, "usr/bin/shadowcode"), 0o755); + await assert.rejects(run("bash", [path.join(root, "AppRun"), "ui"], { env: { ...process.env, SHADOWCODE_APPIMAGE_STACK: mode }, timeout: 5000 }), error => { + assert.equal(error.code, 1); + assert.ok(!error.stdout.includes("must-not-run")); + assert.match(error.stderr, mode === "host" ? /complete compatible host WebKitGTK/ : /must be auto, host or bundled/); + return true; + }); + } finally { await rm(root, { recursive: true, force: true }); } + }); +} diff --git a/scripts/test-native-webkit-paths.mjs b/scripts/test-native-webkit-paths.mjs new file mode 100644 index 00000000..003b6675 --- /dev/null +++ b/scripts/test-native-webkit-paths.mjs @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + chmod, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { relocateWebKit } from "./native-webkit-paths.mjs"; + +async function fixture(arch, run) { + const root = await mkdtemp(path.join(tmpdir(), "webkit relocation ")); + try { + const dir = path.join(root, "usr/lib", arch, "webkit2gtk-4.1"); + await mkdir(dir, { recursive: true }); + for (const name of ["WebKitWebProcess", "WebKitNetworkProcess"]) { + const p = path.join(dir, name); + await writeFile(p, "helper"); + await chmod(p, 0o755); + } + const library = path.join(root, "usr/lib/libwebkit2gtk-4.1.so.0"); + const absolute = `/usr/lib/${arch}/webkit2gtk-4.1`; + const bytes = Buffer.from( + `ELF fixture\0${absolute}\0${absolute}/injected-bundle/\0unrelated /usr/lib/ library metadata\0`, + ); + await writeFile(library, bytes); + await run({ root, library, absolute, bytes, dir }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} +for (const arch of ["x86_64-linux-gnu", "aarch64-linux-gnu"]) { + test(`${arch}: helper paths relocate, unrelated bytes and ELF length stay intact`, () => + fixture(arch, async ({ root, library, bytes, absolute }) => { + const receipt = await relocateWebKit(root, arch), + patched = await readFile(library); + assert.equal(receipt.replaced, 2); + assert.equal(patched.length, bytes.length); + assert.deepEqual( + patched, + Buffer.from( + bytes + .toString() + .split(absolute) + .join(absolute.replace("/usr/lib/", "././/lib/")), + ), + ); + assert.ok( + patched.includes(Buffer.from("unrelated /usr/lib/ library metadata")), + ); + assert.notEqual(receipt.beforeSha256, receipt.afterSha256); + assert.equal((await relocateWebKit(root, arch)).replaced, 0); + })); +} +test("refuses an unknown helper layout before modifying the library", () => + fixture("x86_64-linux-gnu", async ({ root, library }) => { + await writeFile(library, "unknown WebKit layout"); + await assert.rejects( + relocateWebKit(root, "x86_64-linux-gnu"), + /Missing known/, + ); + assert.equal(await readFile(library, "utf8"), "unknown WebKit layout"); + })); +test("refuses an incomplete bundle", () => + fixture("x86_64-linux-gnu", async ({ root, library, bytes, dir }) => { + await rm(path.join(dir, "WebKitNetworkProcess")); + await assert.rejects(relocateWebKit(root, "x86_64-linux-gnu"), /ENOENT/); + assert.deepEqual(await readFile(library), bytes); + })); +test("refuses a library symlink outside the bundle", () => + fixture("x86_64-linux-gnu", async ({ root, library, dir }) => { + const outside = path.join(root, "..", path.basename(root) + "-outside"); + await writeFile(outside, "untouched"); + try { + await rm(library); + await symlink(outside, library); + await assert.rejects( + relocateWebKit(root, "x86_64-linux-gnu"), + /inside AppDir/, + ); + assert.equal(await readFile(outside, "utf8"), "untouched"); + } finally { + await rm(outside); + } + })); diff --git a/scripts/test-platform-runtime.mjs b/scripts/test-platform-runtime.mjs new file mode 100644 index 00000000..c6937b4b --- /dev/null +++ b/scripts/test-platform-runtime.mjs @@ -0,0 +1,341 @@ +// Installed-binary endurance qualification on Windows, macOS and Linux. +// Uses a private profile/project and a loopback scripted provider, never credentials. +// Desktop rendering and inference-server memory require separate qualification. +import assert from "node:assert/strict"; +import { spawn, execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { createServer } from "node:http"; +import { createHash } from "node:crypto"; +import { + readFile, + writeFile, + mkdir, + mkdtemp, + rm, + readdir, +} from "node:fs/promises"; +import { tmpdir, platform, release } from "node:os"; +import path from "node:path"; + +const exec = promisify(execFile); +const binary = process.env.SHADOW_STRESS_BINARY; +assert.ok( + binary && path.isAbsolute(binary), + "Set SHADOW_STRESS_BINARY to the installed native executable, not a wrapper", +); +const artifacts = path.resolve( + process.env.SHADOW_STRESS_ARTIFACTS || "artifacts/platform-runtime", +); +await mkdir(artifacts, { recursive: true }); +const scratch = await mkdtemp(path.join(tmpdir(), "shadowcode-platform-")); +const profile = path.join(scratch, "profile"); +const projects = Array.from({ length: 4 }, (_, i) => + path.join(scratch, "project-" + i), +); +const children = new Set(), + sockets = new Set(), + measurements = []; +let owner, + fixtureError, + requests = 0, + stalled = 0; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +async function until(label, fn, timeout = 30000) { + const end = Date.now() + timeout; + while (Date.now() < end) { + if (fixtureError) throw fixtureError; + const v = await fn(); + if (v) return v; + await sleep(50); + } + throw Error(label + " timed out"); +} +function launch(args, workspace = projects[0]) { + const env = { ...process.env }; + delete env.DISPLAY; + delete env.WAYLAND_DISPLAY; + const child = spawn( + binary, + ["--profile", profile, "--workspace", workspace, "--json", ...args], + { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, + ); + children.add(child); + const output = { stdout: "", stderr: "", closed: false, code: null }; + for (const stream of ["stdout", "stderr"]) + child[stream].on("data", (b) => { + output[stream] += b; + if (output[stream].length > 4000000) child.kill(); + }); + child.on("error", (e) => { + output.error = e; + output.closed = true; + }); + child.on("close", (code) => { + output.code = code; + output.closed = true; + children.delete(child); + }); + return { child, output }; +} +async function finish(run, expected = 0) { + await until("CLI completion", () => run.output.closed, 60000); + if (run.output.error) throw run.output.error; + assert.equal(run.output.code, expected, run.output.stderr); + return JSON.parse(run.output.stdout); +} +const cli = (args, workspace) => finish(launch(args, workspace)); +const provider = createServer(async (req, res) => { + try { + let text = ""; + for await (const b of req) { + text += b; + assert.ok(text.length < 2000000); + } + const payload = JSON.parse(text); + requests++; + const index = payload.messages.findLastIndex((m) => m.role === "user"); + const prompt = payload.messages[index].content; + if (prompt.includes("STALL")) { + stalled++; + res.writeHead(200, { "Content-Type": "application/json" }); + res.flushHeaders(); + return; + } + const tool = payload.messages + .slice(index + 1) + .find((m) => m.role === "tool"); + if (tool) assert.ok(tool.content.includes("platform-fixture-evidence")); + const message = tool + ? { + role: "assistant", + content: prompt + " verified. " + "Bounded evidence. ".repeat(100), + } + : { + role: "assistant", + content: "Inspect the fixture.", + tool_calls: [ + { + id: "read-" + requests, + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "README.md" }), + }, + }, + ], + }; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + choices: [{ message, finish_reason: tool ? "stop" : "tool_calls" }], + usage: { + prompt_tokens: 400, + completion_tokens: 800, + total_tokens: 1200, + }, + }), + ); + } catch (e) { + fixtureError = e; + res.writeHead(500); + res.end("Fixture failed"); + } +}); +provider.on("connection", (s) => { + sockets.add(s); + s.on("close", () => sockets.delete(s)); +}); +async function metrics(round) { + const pid = owner.child.pid; + let value; + if (process.platform === "linux") { + const s = await readFile(`/proc/${pid}/status`, "utf8"); + value = { + rssKiB: Number(s.match(/^VmRSS:\s+(\d+)/m)?.[1]), + threads: Number(s.match(/^Threads:\s+(\d+)/m)?.[1]), + resources: (await readdir(`/proc/${pid}/fd`)).length, + resourceKind: "file descriptors", + }; + } else if (process.platform === "darwin") { + const { stdout } = await exec("/bin/ps", ["-p", String(pid), "-o", "rss="]); + const threads = await exec("/bin/ps", ["-M", "-p", String(pid)]); + const files = await exec("/usr/sbin/lsof", [ + "-a", + "-p", + String(pid), + "-Ff", + ]); + value = { + rssKiB: Number(stdout.trim()), + threads: threads.stdout.trim().split("\n").length - 1, + resources: files.stdout.split("\n").filter((l) => /^f\d/.test(l)).length, + resourceKind: "file descriptors", + }; + } else if (process.platform === "win32") { + const script = `$p=Get-Process -Id ${pid} -ErrorAction Stop; @{rssKiB=[math]::Ceiling($p.WorkingSet64/1024);threads=$p.Threads.Count;resources=$p.HandleCount;resourceKind='handles'} | ConvertTo-Json -Compress`; + const { stdout } = await exec( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + { timeout: 15000, windowsHide: true }, + ); + value = JSON.parse(stdout); + } else throw Error("Unsupported qualification platform " + process.platform); + assert.ok( + value.rssKiB > 0 && value.threads > 0 && value.resources > 0, + "Process measurements must be available", + ); + measurements.push({ round, ...value }); + return value; +} +try { + const hash = createHash("sha256") + .update(await readFile(binary)) + .digest("hex"); + const version = await exec(binary, ["--version"], { + timeout: 30000, + windowsHide: true, + }); + assert.match(version.stdout, /ShadowCode \d+\.\d+\.\d+/); + await new Promise((r) => provider.listen(0, "127.0.0.1", r)); + for (const p of projects) { + await mkdir(p); + await writeFile( + path.join(p, "README.md"), + "platform-fixture-evidence\n" + "Evidence line.\n".repeat(1000), + ); + } + await mkdir(path.join(profile, "config"), { recursive: true }); + await writeFile( + path.join(profile, "config/config.yaml"), + JSON.stringify({ + model: { + default: "platform-fixture", + name: "platform-fixture", + provider: "local", + endpoint: `http://127.0.0.1:${provider.address().port}/v1`, + context_limit: 32768, + }, + trusted_workspaces: projects, + onboarding: { completed: true, workspace: projects[0] }, + ui: { notify: false }, + }), + ); + owner = launch(["serve"]); + await until("persistent engine startup", () => + owner.output.stderr.includes("serving"), + ); + const ids = []; + const warmup = await cli(["run", "WARMUP"]); + assert.equal(warmup.status, "completed"); + const baseline = await metrics("warmup"); + for (let i = 0; i < 100; i++) { + const task = await cli(["run", "SEQUENTIAL " + i]); + assert.equal(task.status, "completed"); + ids.push(task.id); + if ((i + 1) % 25 === 0) { + await metrics("sequential-" + (i + 1)); + console.log(`Sequential ${i + 1}/100`); + } + } + for (let round = 0; round < 20; round++) { + const tasks = await Promise.all( + projects.map((p, i) => cli(["run", `CONCURRENT ${round} ${i}`], p)), + ); + for (const t of tasks) { + assert.equal(t.status, "completed"); + ids.push(t.id); + } + } + assert.equal(ids.length, 180); + assert.equal(new Set(ids).size, 180); + const cancelled = []; + for (let i = 0; i < 5; i++) { + const before = stalled, + task = await cli(["run", "STALL " + i, "--detach"]); + await until("provider stall", () => stalled > before); + await cli(["jobs", task.id, "--cancel"]); + await until( + "cancelled task", + async () => (await cli(["jobs", task.id])).status === "cancelled", + ); + cancelled.push(task.id); + } + const last = await metrics("completed"); + assert.ok( + Math.max(...measurements.map((s) => s.rssKiB)) < 768 * 1024, + "Engine RSS exceeds 768 MiB", + ); + assert.ok( + last.rssKiB - baseline.rssKiB < 128 * 1024, + "Engine RSS grew by 128 MiB", + ); + assert.ok( + last.resources <= baseline.resources + 32, + "Process resources grew beyond allowance", + ); + assert.ok( + last.threads <= baseline.threads + 8, + "Process threads grew beyond allowance", + ); + // Node terminates Windows processes rather than delivering a console signal. + // Explicitly qualify persisted completed work after forced termination there; + // this is not a claim of graceful Windows service shutdown. + owner.child.kill("SIGTERM"); + await until("engine exit", () => owner.output.closed); + if (process.platform !== "win32") + assert.equal(owner.output.code, 0, owner.output.stderr); + for (const id of [ids[0], ids[99], ids[179]]) + assert.equal((await cli(["jobs", id])).status, "completed"); + for (const id of cancelled) + assert.equal((await cli(["jobs", id])).status, "cancelled"); + await writeFile( + path.join(artifacts, "result.json"), + JSON.stringify( + { + passed: true, + platform: platform(), + osRelease: release(), + version: version.stdout.trim(), + binarySha256: hash, + sequentialCompleted: 100, + concurrentCompleted: 80, + cancelled: 5, + requests, + measurements, + persistenceAfterStop: true, + shutdown: + process.platform === "win32" + ? "forced termination; graceful shutdown not tested" + : "SIGTERM graceful shutdown", + scope: + "Installed native engine with loopback scripted provider. Not desktop UI, paid providers, real model inference or inference-server memory.", + }, + null, + 2, + ), + ); + console.log("Installed platform endurance passed."); +} catch (e) { + await writeFile( + path.join(artifacts, "failure.txt"), + `${e.stack}\n${JSON.stringify(measurements)}\n${owner?.output.stderr || ""}`, + ); + throw e; +} finally { + for (const c of children) c.kill(); + await sleep(200); + for (const c of children) c.kill("SIGKILL"); + for (const s of sockets) s.destroy(); + await new Promise((r) => provider.close(r)); + await rm(scratch, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e2ea7f84..b6d09aaf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShadowCode", - "version": "2.3.1", + "version": "2.3.2", "identifier": "com.shadowfetch.shadowcode", "build": { "beforeDevCommand": { diff --git a/ui/e2e/editor.spec.ts b/ui/e2e/editor.spec.ts index c153cfa0..1bc4983e 100644 --- a/ui/e2e/editor.spec.ts +++ b/ui/e2e/editor.spec.ts @@ -113,7 +113,7 @@ test("renders syntax, searches and replaces, then undoes and saves through the l } await page.evaluate(() => (document.documentElement.dataset.theme = "light")); expect(await fileWrites(page)).toEqual([]); - await editor.press("Control+f"); + await editor.press("ControlOrMeta+f"); const search = drawer.getByRole("textbox", { name: "Find", exact: true }); await expect(search).toBeFocused(); await search.fill("token"); @@ -127,17 +127,17 @@ test("renders syntax, searches and replaces, then undoes and saves through the l await expectEditorText(editor, replaced); await editor.press("Escape"); await expect(search).toHaveCount(0); - await editor.press("Control+z"); + await editor.press("ControlOrMeta+z"); await expectEditorText(editor, original); - await editor.press("Control+Shift+z"); + await editor.press("ControlOrMeta+Shift+z"); await expectEditorText(editor, replaced); if (process.platform === "win32") { - await editor.press("Control+z"); + await editor.press("ControlOrMeta+z"); await expectEditorText(editor, original); await editor.press("Control+y"); await expectEditorText(editor, replaced); } - await editor.press("Control+s"); + await editor.press("ControlOrMeta+s"); await expect.poll(() => savedFile(page, "sample.ts")).toBe(replaced); await expect(drawer.getByText("Saved", { exact: true })).toBeVisible(); const writes = await fileWrites(page); @@ -149,7 +149,9 @@ test("typing a question mark edits the file without opening Help", async ({ page, }) => { const { drawer, editor } = await openFile(page, "README.md"); - await editor.press("Control+Home"); + await editor.press( + process.platform === "darwin" ? "Meta+ArrowUp" : "Control+Home", + ); await editor.press("End"); // Generate a real key event; insertText/fill would not exercise the app's // global question-mark shortcut against a contenteditable editor. @@ -172,22 +174,22 @@ test("keeps per-file undo separate and forgets history when a clean buffer close const { editor: second } = await openFile(page, "other.py"); await second.fill('print("new")\n'); await expectEditorText(second, 'print("new")\n'); - await second.press("Control+z"); + await second.press("ControlOrMeta+z"); await expectEditorText(second, 'print("old")\n'); await drawer .getByRole("button", { name: "Open README.md, unsaved", exact: true }) .click(); await expectEditorText(first, "# First file edit\n"); - await first.press("Control+z"); + await first.press("ControlOrMeta+z"); await expectEditorText(first, "# Demo\n"); - await first.press("Control+Shift+z"); + await first.press("ControlOrMeta+Shift+z"); await expectEditorText(first, "# First file edit\n"); - await first.press("Control+s"); + await first.press("ControlOrMeta+s"); await expect(drawer.getByText("Saved", { exact: true })).toBeVisible(); await drawer.getByRole("button", { name: "Close file", exact: true }).click(); await expect(first).toHaveCount(0); await openFile(page, "README.md"); - await first.press("Control+z"); + await first.press("ControlOrMeta+z"); await expectEditorText(first, "# First file edit\n"); await drawer .getByRole("button", { name: "Open other.py", exact: true }) @@ -200,7 +202,7 @@ test("a clean external replacement clears undo instead of reviving older file by }) => { const { drawer, editor } = await openFile(page, "README.md"); await editor.fill("# Saved edit\n"); - await editor.press("Control+s"); + await editor.press("ControlOrMeta+s"); await expect(drawer.getByText("Saved", { exact: true })).toBeVisible(); await page.evaluate(() => { (window as any).__SHADOW_FAKE__.state.files["README.md"] = @@ -211,7 +213,7 @@ test("a clean external replacement clears undo instead of reviving older file by await expect( drawer.getByRole("group", { name: "File conflict" }), ).toHaveCount(0); - await editor.press("Control+z"); + await editor.press("ControlOrMeta+z"); await expectEditorText(editor, "# New disk revision\n"); await expect( drawer.getByRole("button", { name: "Save", exact: true }), @@ -232,7 +234,9 @@ test("keeps ordinary file undo while a mixed-newline file uses the plain editor" const { editor: plain } = await openFile(page, "mixed.txt"); expect(await plain.evaluate((element) => element.tagName)).toBe("TEXTAREA"); await expect(drawer.locator(".cm-content")).toHaveCount(0); - await plain.press("Control+Home"); + await plain.press( + process.platform === "darwin" ? "Meta+ArrowUp" : "Control+Home", + ); await plain.press("End"); await page.keyboard.insertText("!"); await expectEditorText(plain, "first!\nsecond\nthird\n"); @@ -241,16 +245,16 @@ test("keeps ordinary file undo while a mixed-newline file uses the plain editor" .getByRole("button", { name: "Open README.md, unsaved", exact: true }) .click(); await expectEditorText(ordinary, "# Ordinary draft\n"); - await ordinary.press("Control+z"); + await ordinary.press("ControlOrMeta+z"); await expectEditorText(ordinary, "# Demo\n"); - await ordinary.press("Control+Shift+z"); + await ordinary.press("ControlOrMeta+Shift+z"); await expectEditorText(ordinary, "# Ordinary draft\n"); await drawer .getByRole("button", { name: "Open mixed.txt, unsaved", exact: true }) .click(); await expectEditorText(plain, "first!\nsecond\nthird\n"); - await plain.press("Control+s"); + await plain.press("ControlOrMeta+s"); await expect .poll(() => savedFile(page, "mixed.txt")) .toBe("first!\r\nsecond\rthird\n"); @@ -272,17 +276,19 @@ for (const [name, separator] of [ await seedFiles(page, { "newlines.txt": original }); const { drawer, editor } = await openFile(page, "newlines.txt"); await expectEditorText(editor, "first\nsecond\n"); - await editor.press("Control+s"); + await editor.press("ControlOrMeta+s"); await expect( drawer.getByRole("button", { name: "Save", exact: true }), ).toBeDisabled(); expect(await fileWrites(page)).toEqual([]); expect(await savedFile(page, "newlines.txt")).toBe(original); - await editor.press("Control+End"); + await editor.press( + process.platform === "darwin" ? "Meta+ArrowDown" : "Control+End", + ); await page.keyboard.insertText("third"); await editor.press("Enter"); await expectEditorText(editor, "first\nsecond\nthird\n"); - await editor.press("Control+s"); + await editor.press("ControlOrMeta+s"); await expect.poll(() => savedFile(page, "newlines.txt")).toBe(expected); const writes = await fileWrites(page); expect(writes).toHaveLength(1); @@ -301,17 +307,19 @@ test("keeps mixed separators in the plain fallback without incidental writes", a ); expect(await editor.evaluate((element) => element.tagName)).toBe("TEXTAREA"); await expectEditorText(editor, "first\nsecond\nthird\n"); - await editor.press("Control+s"); + await editor.press("ControlOrMeta+s"); expect(await fileWrites(page)).toEqual([]); expect(await savedFile(page, "mixed.txt")).toBe(original); await expect( drawer.getByRole("button", { name: "Save", exact: true }), ).toBeDisabled(); - await editor.press("Control+Home"); + await editor.press( + process.platform === "darwin" ? "Meta+ArrowUp" : "Control+Home", + ); await editor.press("End"); await page.keyboard.insertText("!"); await expectEditorText(editor, "first!\nsecond\nthird\n"); - await editor.press("Control+s"); + await editor.press("ControlOrMeta+s"); await expect .poll(() => savedFile(page, "mixed.txt")) .toBe("first!\r\nsecond\rthird\n"); diff --git a/ui/e2e/workspace.spec.ts b/ui/e2e/workspace.spec.ts index 5f9a9293..94009145 100644 --- a/ui/e2e/workspace.spec.ts +++ b/ui/e2e/workspace.spec.ts @@ -86,7 +86,8 @@ test("picks a subscription row with the keyboard", async ({ page }) => { await search.press("Enter"); await expect(trigger(page)).toContainText("Codex · GPT-6-Astra"); await expect(trigger(page)).toContainText("Cloud"); - await expect(trigger(page)).toBeFocused(); + // Selecting a model makes the composer ready for the next keystroke. + await expect(prompt(page)).toBeFocused(); await expect(send(page)).toBeEnabled(); const log = await fakeLog(page); expect( @@ -332,7 +333,9 @@ test("indents and outdents selected file lines in the rendered editor", async ({ await editor.fill("one\ntwo\nthree\n"); // Select from column 1 of the first line through the start of line 3 // using the real editor keymap, rather than a textarea-only DOM API. - await editor.press("Control+Home"); + await editor.press( + process.platform === "darwin" ? "Meta+ArrowUp" : "Control+Home", + ); await editor.press("ArrowRight"); await editor.press("Shift+ArrowDown"); await editor.press("Shift+ArrowDown"); diff --git a/ui/package-lock.json b/ui/package-lock.json index 2b8c6e76..f5dc2931 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "shadow-agent-ui", - "version": "2.3.1", + "version": "2.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shadow-agent-ui", - "version": "2.3.1", + "version": "2.3.2", "license": "Apache-2.0", "dependencies": { "@codemirror/commands": "6.11.1", diff --git a/ui/package.json b/ui/package.json index f686beec..7cc3d372 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "shadow-agent-ui", - "version": "2.3.1", + "version": "2.3.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index cce61b56..6ae8e030 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { import { api, type Job, type Project, type Session } from "../api"; import { BADGE_LABELS, projectOf, type Badge } from "../lib/badges"; import { readPins, writePins } from "../lib/storage"; +import { shortcutHint } from "../lib/shortcuts"; import { ConversationMenu, type ConversationAction } from "./ConversationMenu"; import { SearchSnippet } from "./SearchSnippet"; @@ -241,7 +242,7 @@ export function Sidebar({ @@ -251,7 +252,7 @@ export function Sidebar({ New task - Ctrl N + {shortcutHint("N")} @@ -268,7 +269,7 @@ export function Sidebar({ type="button" className="icon-btn" aria-label="Open project" - title="Open project (Ctrl+P)" + title={`Open project (${shortcutHint("P")})`} onClick={() => onProject()} > @@ -359,7 +360,7 @@ export function Sidebar({ Settings - Ctrl , + {shortcutHint(",")} diff --git a/ui/src/components/UnifiedPicker.test.tsx b/ui/src/components/UnifiedPicker.test.tsx index fb7372e3..74afd683 100644 --- a/ui/src/components/UnifiedPicker.test.tsx +++ b/ui/src/components/UnifiedPicker.test.tsx @@ -313,6 +313,66 @@ it("filters by typing and closes with Escape", async () => { await waitFor(() => expect(document.activeElement).toBe(trigger())); }); +it("does not steal focus when typing moves to the composer before close animation", () => { + const frames: FrameRequestCallback[] = []; + const raf = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + try { + render( + <> + + + >, + ); + fireEvent.click(trigger()); + frames.splice(0).forEach((callback) => callback(performance.now())); + expect(document.activeElement).toBe(search()); + key("Escape"); + const composer = screen.getByRole("textbox", { name: "Next task" }); + composer.focus(); + frames.splice(0).forEach((callback) => callback(performance.now())); + expect(document.activeElement).toBe(composer); + } finally { + raf.mockRestore(); + } +}); + +it("keeps the composer focused when choosing a model moves focus before close", () => { + const frames: FrameRequestCallback[] = []; + const raf = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + try { + render( + <> + + screen.getByRole("textbox", { name: "Next task" }).focus() + } + /> + + >, + ); + fireEvent.click(trigger()); + frames.splice(0).forEach((callback) => callback(performance.now())); + fireEvent.change(search(), { target: { value: "qwen" } }); + key("Enter"); + frames.splice(0).forEach((callback) => callback(performance.now())); + expect(document.activeElement).toBe( + screen.getByRole("textbox", { name: "Next task" }), + ); + } finally { + raf.mockRestore(); + } +}); + it("routes non-ready rows to their fix instead of disabling them", () => { const onConnect = vi.fn(); const onSetup = vi.fn(); diff --git a/ui/src/components/UnifiedPicker.tsx b/ui/src/components/UnifiedPicker.tsx index fcc51d61..ca8f8b32 100644 --- a/ui/src/components/UnifiedPicker.tsx +++ b/ui/src/components/UnifiedPicker.tsx @@ -242,10 +242,26 @@ export function UnifiedPicker({ }, [active, open]); function close(restoreFocus = true) { + const focusWhenClosing = document.activeElement; + const shouldRestore = + restoreFocus && + (focusWhenClosing === document.body || + root.current?.contains(focusWhenClosing)); onOpenChange(false); setQuery(""); setDetails(null); - if (restoreFocus) requestAnimationFrame(() => trigger.current?.focus()); + if (shouldRestore) + requestAnimationFrame(() => { + // Closing removes the search input. Restore keyboard navigation only + // if the user has not already focused the composer or another control. + // A delayed unconditional focus can turn the next typed space into a + // trigger click and reopen the picker while the user is typing. + if ( + document.activeElement === document.body || + document.activeElement === focusWhenClosing + ) + trigger.current?.focus(); + }); } function activate(item: Item | undefined) { diff --git a/ui/src/lib/shortcuts.test.ts b/ui/src/lib/shortcuts.test.ts new file mode 100644 index 00000000..ba12e7a4 --- /dev/null +++ b/ui/src/lib/shortcuts.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { shortcutHint, shortcutModifier } from "./shortcuts"; + +describe("keyboard shortcut hints", () => { + it("uses Command for Mac and Apple external keyboards", () => { + for (const platform of ["MacIntel", "MacPPC", "iPad", "iPhone"]) + expect(shortcutHint("Shift+Z", platform)).toBe("⌘+Shift+Z"); + }); + it("uses Control for Windows, Linux, and an unknown browser", () => { + for (const platform of ["Win32", "Linux x86_64", "Linux aarch64", ""]) + expect(shortcutHint("S", platform)).toBe("Ctrl+S"); + }); + it("uses the viewing browser's keyboard instead of a remote engine platform", () => { + expect(shortcutModifier(navigator.platform)).toBe(shortcutModifier()); + }); +}); diff --git a/ui/src/lib/shortcuts.ts b/ui/src/lib/shortcuts.ts new file mode 100644 index 00000000..39c41669 --- /dev/null +++ b/ui/src/lib/shortcuts.ts @@ -0,0 +1,11 @@ +/** Match shortcut hints to the keyboard of the computer displaying the UI. + * Remote access uses the browser's keyboard, not the engine host's platform. */ +export function shortcutModifier( + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): string { + return /Mac|iPhone|iPad|iPod/i.test(platform) ? "⌘" : "Ctrl"; +} + +export function shortcutHint(keys: string, platform?: string): string { + return `${shortcutModifier(platform)}+${keys}`; +}
Improves AppImage startup on rolling Linux desktops, model-picker focus, and platform keyboard hints.
Clarify Git-history requirements for write subagents and guide plain-folder tasks toward direct edits or read-only delegation.