diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f8852e08 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Keep source and checksum-protected LICENSE/NOTICE bytes identical on Windows. +# Binary assets retain their bytes through Git's automatic text detection. +* text=auto eol=lf diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..a26b418e --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,175 @@ +name: Windows desktop + +on: + push: + branches: + - main + - feat/windows-desktop + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-desktop-${{ github.ref }} + cancel-in-progress: true + +jobs: + compiler: + name: Windows compiler preflight + runs-on: windows-2022 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + architecture: x64 + - name: Verify native compiler without npm dependencies + run: node --experimental-strip-types scripts/release/probe-windows-compiler.mjs + + build: + name: Windows x64 NSIS installer + needs: compiler + runs-on: windows-2022 + timeout-minutes: 60 + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + architecture: x64 + cache: npm + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 + with: + workspaces: | + native/gajae-core -> target + src-tauri -> target + cache-on-failure: true + + - name: Install dependencies + run: npm ci + + - name: Fetch pinned Bun runtime + run: node scripts/fetch-bun.mjs + + - name: Audit dependencies + run: npm run audit + + - name: Check source + id: source + run: | + npm run typecheck + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run lint + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run check:identity + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run check:licenses + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Test Windows build tooling + id: packaging + run: npm run test:windows -- --scripts-only + + - name: Test Windows runtime + id: runtime + if: ${{ !cancelled() && steps.source.outcome == 'success' }} + run: | + npm run build:core:dev + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run test:windows -- --server-only 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'gajae-runtime-windows.log') + exit $LASTEXITCODE + + - name: Upload failed runtime diagnostics + if: ${{ !cancelled() && steps.runtime.outcome == 'failure' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-runtime-diagnostics + path: ${{ runner.temp }}/gajae-runtime-windows.log + retention-days: 7 + + - name: Verify Rust core + id: core + if: ${{ !cancelled() && steps.source.outcome == 'success' }} + run: | + npm run check:core 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'gajae-core-windows.log') + exit $LASTEXITCODE + + - name: Upload failed core diagnostics + if: ${{ !cancelled() && steps.core.outcome == 'failure' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-core-diagnostics + path: ${{ runner.temp }}/gajae-core-windows.log + retention-days: 7 + + - name: Build payload and installer + id: build + # Gather independent Windows failures in one run. A failed core check + # still fails the job and prevents the final artifact upload. + if: ${{ !cancelled() && steps.source.outcome == 'success' && steps.packaging.outcome == 'success' }} + run: npm run desktop:build:windows + + - name: Test desktop lifecycle + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --release --locked --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc + + - name: Stage installer and checksum + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + id: installer + run: | + $ErrorActionPreference = 'Stop' + $installers = @(Get-ChildItem 'src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe') + if ($installers.Count -ne 1) { throw "Expected exactly one NSIS installer, found $($installers.Count)." } + $version = (Get-Content package.json -Raw | ConvertFrom-Json).version + $assetName = "gajae-app-desktop-$version-windows-x64-setup.exe" + New-Item -ItemType Directory -Path release/desktop -Force | Out-Null + Copy-Item $installers[0].FullName "release/desktop/$assetName" + $digest = (Get-FileHash "release/desktop/$assetName" -Algorithm SHA256).Hash.ToLowerInvariant() + [System.IO.File]::WriteAllText("$PWD/release/desktop/$assetName.sha256", "$digest $assetName`n", [System.Text.UTF8Encoding]::new($false)) + "WINDOWS_INSTALLER=$($installers[0].FullName)" >> $env:GITHUB_ENV + + - name: Verify installed payload + if: ${{ !cancelled() && steps.installer.outcome == 'success' }} + run: | + $ErrorActionPreference = 'Stop' + $installDir = Join-Path $env:RUNNER_TEMP 'Gajae Windows QA 가재' + $installer = Start-Process -FilePath $env:WINDOWS_INSTALLER -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru + if ($installer.ExitCode -ne 0) { throw "NSIS install failed: $($installer.ExitCode)" } + $sidecar = Join-Path $installDir 'gajae-app-server.exe' + $payload = Join-Path $installDir 'resources/server-payload' + if (!(Test-Path $sidecar)) { throw 'Installed Node sidecar is missing.' } + if (!(Test-Path $payload)) { $payload = Join-Path $installDir 'server-payload' } + node scripts/release/smoke-windows-server.mjs --payload $payload --node $sidecar + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload Windows preview installer + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gajae-app-desktop-windows-x64 + path: | + release/desktop/*-windows-x64-setup.exe + release/desktop/*-windows-x64-setup.exe.sha256 + if-no-files-found: error + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index ee6ea02c..5ec7cb53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,8 @@ coding agent. MIT. Four runtime layers: WebSocket, node-pty terminals. TypeScript + JS mixed, run through `tsx`. - `native/gajae-core/` — Rust core, built to `dist-native/` by `scripts/build-rust-core.mjs`. - `src-tauri/` — Tauri 2 desktop shell (Rust: `supervisor.rs`, `lifecycle.rs`, - `navigation.rs`); packages the server as a payload and supervises it. + `navigation.rs`, Windows Job Object owner in `windows_process.rs`); packages + the server as a payload and supervises it. `shared/` is code shared between client and server (product identity, network hosts, job projection protocol). `scripts/` holds build/release/verify tooling. @@ -28,7 +29,12 @@ job projection protocol). `scripts/` holds build/release/verify tooling. `node scripts/fetch-bun.mjs`. - Server binds loopback by default (fail-closed; it can run shell commands). `SERVER_PORT` defaults to 3001, Vite dev on 5173. Do not export `SERVER_PORT=0`. -- Tauri builds choke on `CI=1`: use `env -u CI npm run tauri -- build`. +- The Tauri wrapper normalizes `CI=1`/`0` to `true`/`false` for the CLI. +- Windows desktop packaging runs natively on x64 with MSVC, a Windows SDK, + and WebView2. See `docs/WINDOWS-DESKTOP.md`. Bundled Windows executables use + `.exe`; native-manifest paths always use forward slashes on every host. +- Direct Tauri CLI invocations reject `CI=1`; the repository wrapper normalizes + it for the CLI, and Linux desktop packaging clears `CI` before its build. - Linux desktop packaging targets native `x86_64-unknown-linux-gnu`, bundles Node **22.22.2** and Bun **1.4.0**, and needs GTK 3/WebKitGTK 4.1 plus the prerequisites in `docs/DESKTOP-LINUX.md`. CI builds on Ubuntu 22.04/glibc 2.35; @@ -45,6 +51,8 @@ npm run check:core # cargo fmt --check + clippy -D warnings + cargo test npm run verify # FULL GATE: audit + typecheck + check:core + test + lint + check:identity + build npm run test:e2e:gjc # 7 GJC wire/browser e2e tests (separate from npm test) npm run desktop:dev # Tauri dev shell +npm run desktop:build:windows # Windows x64 payload + NSIS installer (on Windows) +npm run test:windows # focused runtime/packaging tests; build the core first npm run server:payload:linux # Linux x64 payload + pinned runtimes env -u CI npm run desktop:build:linux # payload + Tauri deb/AppImage + release/desktop staging npm run smoke:packaged-server -- --linux-root # extracted deb or squashfs-root @@ -192,6 +200,7 @@ is `.ts`/`.tsx`. Routing is react-router-dom 7. - `server/GJC-LIVE-SPEC.md` — GJC provider/worker contract. - `docs/DESKTOP-TAURI-VERIFICATION.md` — desktop packaging/verification (incl. the human-gated notarization step). +- `docs/WINDOWS-DESKTOP.md` — Windows preview build, CI and desktop acceptance. - `docs/DESKTOP-LINUX.md` — Linux x64 desktop prerequisites, package builds, installation, compatibility floor, and validation procedure. - `docs/SELF-HOST.md`, `CONTRIBUTING.md` — install/update lifecycle and PR rules. diff --git a/README.md b/README.md index f2d00f21..4faf4d29 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@

CI License: MIT - Version 2.0.0-beta.7 - Platforms: macOS arm64 | Linux server | Linux desktop source - Runtime: Gajae Code SDK 0.15.6 + Version 2.0.0-beta.9 + Platforms: macOS arm64 | Linux server | Linux desktop source | Windows x64 preview + Runtime: Gajae Code SDK 0.16.4

@@ -25,7 +25,7 @@ A two-turn Gajae Code App session that writes greet.py and adds a --shout flag, with the Changes tab open on the diff and a review comment waiting to be sent

-

Two turns on v2.0.0-beta.7: each turn's tool calls folded into a work block, the Changes tab showing the working tree as a diff, and a line comment waiting to become the next message.

+

Two turns in Gajae Code App: each turn's tool calls folded into a work block, the Changes tab showing the working tree as a diff, and a line comment waiting to become the next message.

Gajae Code App is a self-hosted web and desktop interface for [Gajae Code](https://github.com/devswha/gajae-code). It drives the agent through the runtime's own SDK in an isolated worker, shows every turn as it happens, and puts a review loop between the agent's edits and your next message — on a machine you control, with credentials that never leave it. @@ -39,21 +39,23 @@ Gajae Code App is a self-hosted web and desktop interface for [Gajae Code](https ## Quick Start -**macOS (Apple Silicon, macOS 11+) — the desktop app.** Download the DMG from [Releases](https://github.com/devswha/gajae-code-app/releases/latest), verify it, drag it to Applications, open it. Since v2.0.0-beta.7 the image is signed with a Developer ID and notarized by Apple; Gatekeeper opens it like any other app. +**macOS (Apple Silicon, macOS 11+) — the desktop app.** Download the DMG from [Releases](https://github.com/devswha/gajae-code-app/releases/latest), verify the matching checksum, drag it to Applications, and open it. The macOS release lane documents Developer ID signing and notarization. ```bash cd ~/Downloads -shasum -a 256 -c gajae-app-desktop-2.0.0-beta.7-macos-arm64.dmg.sha256 +shasum -a 256 -c gajae-app-desktop-2.0.0-beta.9-macos-arm64.dmg.sha256 ``` **Linux server (x86_64, glibc 2.35+, Node.js 22) — self-host the web UI.** Unpack the server archive, run it as a per-user systemd service, reach it through a browser over an SSH tunnel or VPN. ```bash -sha256sum --check gajae-app-server-2.0.0-beta.7-linux-x64-node22.tar.gz.sha256 +sha256sum --check gajae-app-server-2.0.0-beta.9-linux-x64-node22.tar.gz.sha256 ``` Install, upgrade and rollback steps: [docs/INSTALL.md](docs/INSTALL.md) · [docs/SELF-HOST.md](docs/SELF-HOST.md). +**Windows x64 preview — build from source.** The Windows branch adds an NSIS installer with bundled runtimes. See [Windows setup, build and verification](docs/WINDOWS-DESKTOP.md). Preview installers are produced by the Windows desktop Actions workflow. + **Linux desktop (x86_64) — build from source.** Native Tauri `.deb` and `.AppImage` packages bundle Node.js **22.22.2** and Bun **1.4.0**. See the [Linux desktop guide](docs/DESKTOP-LINUX.md) for prerequisites, local builds, @@ -74,7 +76,7 @@ npm run dev # server :3001, client :5173 npm run desktop:dev # the same, inside the Tauri desktop shell ``` -The app uses the models, presets, skills and credentials of the Gajae Code installation in `~/.gjc`, and you can sign in to providers from inside the app. Linux desktop development requires the system libraries listed in the Linux guide. Intel Mac, Windows and Linux arm64 desktop builds are not available yet. +The app uses the models, presets, skills and credentials of the Gajae Code installation in `~/.gjc`, and you can sign in to providers from inside the app. Linux desktop development requires the system libraries listed in the Linux guide, and Windows x64 is available through the preview build path above. Intel Mac and Linux arm64 desktop builds are not available yet. ## Permission Modes @@ -92,7 +94,7 @@ A card answered in one tab closes in every other viewer. Always deny is offered | | | |---|---| -| **Runtime** | Gajae Code SDK 0.15.6 on Bun 1.4.0, bundled, driven in an isolated worker; prompts pass through an owner-readable temp file, never a process argument | +| **Runtime** | Gajae Code SDK 0.16.4 on Bun 1.4.0, bundled, driven in an isolated worker; prompts pass through an owner-readable temp file, never a process argument | | **Where things live** | Database, assets and cache under `~/.gajae-app`; transcripts stay in the runtime's own session files and are never copied into the app's database | | **Network** | Loopback by default and fail-closed (it can run shell commands); cross-origin callers are rejected on HTTP and WebSocket | | **Stack** | React 19 · Vite 7 · Tailwind 4 · Express · SQLite · a Rust core · Tauri 2 for the desktop shell | @@ -104,6 +106,7 @@ A card answered in one tab closes in every other viewer. Always deny is offered - [Self-hosting](docs/SELF-HOST.md) · [Install the server release](docs/INSTALL.md) · [Changelog](CHANGELOG.md) - [Desktop packaging, signing and notarization](docs/DESKTOP-TAURI-VERIFICATION.md) - [Linux desktop builds, installation and validation](docs/DESKTOP-LINUX.md) +- [Windows desktop preview](docs/WINDOWS-DESKTOP.md) - [GJC provider and worker contract](server/GJC-LIVE-SPEC.md) · [Worker protocol](docs/GJC-WORKER-PROTOCOL.md) - [Design system](DESIGN.md) · [Repository guide for agents](AGENTS.md) - [Licensing](docs/LICENSING.md) · [Relicensing record](docs/RELICENSING.md) · [Upstream intake](docs/UPSTREAM.md) diff --git a/docs/DESKTOP-TAURI-VERIFICATION.md b/docs/DESKTOP-TAURI-VERIFICATION.md index 2f80d0f9..ed41558c 100644 --- a/docs/DESKTOP-TAURI-VERIFICATION.md +++ b/docs/DESKTOP-TAURI-VERIFICATION.md @@ -1,9 +1,17 @@ # Tauri Desktop (macOS arm64) — Verification Record +Windows x64 has a separate [preview build and verification guide](WINDOWS-DESKTOP.md). +The acceptance records on this page apply to macOS. Linux x86_64 `.deb`/`.AppImage` builds and their separate validation procedure are documented in [DESKTOP-LINUX.md](DESKTOP-LINUX.md). The macOS results below do not establish Linux package or GUI compatibility. +This page is a historical macOS verification record. The merged source currently +targets package `2.0.0-beta.9`, desktop version `0.2.3`, GJC SDK `0.16.4`, and +Bun `1.4.0`; no macOS package or interactive acceptance result for that source +tree is claimed here. Each result below remains scoped to the commit and +versions named in its own record. + > **Status (2026-07-22): beta.3 rename and reinstall QA passed; C7 > complete, C8 void, C9 complete.** The beta.3 installed-app smoke covered the > visible rename, project/session navigation, preset and skill-command UI, diff --git a/docs/V2-SESSION-HANDOFF.md b/docs/V2-SESSION-HANDOFF.md index 7667e960..1dcbc317 100644 --- a/docs/V2-SESSION-HANDOFF.md +++ b/docs/V2-SESSION-HANDOFF.md @@ -2,6 +2,119 @@ Last updated: 2026-09-06 (post-#39 app and release acceptance). Supersedes the 2026-07-18 handoff. +## Windows branch handoff — September 6, 2026 + +**The owner explicitly stopped implementation and requested this handoff.** +The proposal to expand into SDK source/dependency changes was **not approved**. +Do not treat this document as authorization to resume implementation. This +section governs `feat/windows-desktop`; the main/macOS records below remain +historical context, not Windows acceptance. + +### Checkout and delivery + +- Checkout: `C:/tmp/gajae-code-app`, remote + `https://github.com/devswha/gajae-code-app.git`. +- Branch: `feat/windows-desktop`; latest implementation/test evidence commit: + `3a9506f6cf02215b747c80afaee92b45a46978c3`. The handoff commit is documentation + only. All preceding changes were committed and pushed; the worktree was clean. +- Draft PR: . + Main was merged normally (`9f490f4`), not rebased. No main merge, force push, + release or SDK dependency modification is authorized. +- Pins remain app `2.0.0-beta.9`, desktop `0.2.3`, SDK/native `0.16.4`, + Bun `1.4.0`, bundled Windows Node `22.22.2`. +- Preserve user credentials/configuration and the running psmux session. + The PageUp copy-mode binding in `C:/Users/devsw/.psmux.conf` is accepted; + do not reopen terminal troubleshooting. + +### Completed changes and constraints + +- Main integration preserves Windows suspended spawn/Job ownership and proven + tree shutdown, plus Linux/macOS origin, launcher, single-instance and QA + contracts. Unix graceful timeout does not gain forced escalation. +- `b2e134c` owns embedded SDK Settings/control endpoints: public + `sdkHostModeSupported: false`, runtime-only `overrideModelRoles`, strict + clone `flushOrThrow()`, and exact caller-owned SessionManager cleanup on + construction failure. A successful SDK session owns its manager. +- `6edef4a` declares fixture support in the engine manifest. The Windows lane + includes the full SDK/delegation suites and pinned Bun in child PATH. +- `11bee00` adds the separate-process public native/SDK file-lock probe. +- `3a9506f` retains fixture stores/root after unconfirmed session disposal, + preserving the original failure instead of racing live teardown. +- Do not add cleanup retries, indefinite `awaitDisposeCompletion()` waits, + private SDK imports, disabled persistence, antivirus exclusions, weakened + assertions or Windows skips. Production keeps bounded disposal followed by + `worker_cleanup_unconfirmed` and proven Job-tree reaping before reuse. +- Pre-fix fixture brokers were reaped by exact argv, creation time and retained + process handles. Do not reuse historical PIDs. The new failure below is not + evidence of that old broker defect. + +### Current blocker: pinned native SDK filesystem release + +Windows run +at `11bee00` reproduces the failure **without any AgentSession**: + +- Windows Server 2022, build `10.0.20348`, Bun `1.4.0`, natives `0.16.4`. +- `snapshotDirectoryTree` succeeds. `exactRemoveDirectoryTree` returns + `{ok:false, code:"sharing_violation", detachedPath:}`. +- Native removal, public SDK `withFileLock` release and concurrent SDK release + all fail on both C: temporary and D: checkout paths. One final root removal + also reports `EBUSY`; the probe preserves both failures. +- The same six cases pass on Windows 11 build `10.0.26200`. A physical D: + checkout with C: TEMP and two CPUs also passed the first two real delegation + tests locally; baseline and modern native variants passed standalone probes. +- The handle holder and native implementation defect are not established. + Do not attribute this to antivirus or increase disposal deadlines. +- Both SDK and natives still publish `0.16.4` as their latest version. + Repository: . + No dependency patch or upgrade was attempted. + +The full suite's `SessionDisposalIncompleteError` waits for coordinator +persistence under retained workflow locks. SDK `config/file-lock.ts:831` +reports `EACCES` / `sharing_violation`. This is downstream of a native primitive +failure, not something fixture deletion or a longer wait can repair. + +### Evidence and files + +- Local final delegation suite: **32 passed / 0 failed**. Focused ownership + regressions, typecheck, ESLint and diff checks passed. Native probe: six + cases passed; Windows script suite: 53 passed. +- At `6edef4a`, Windows PR `34054572083` and push `34054569808` attempt 2 + passed compiler/source/tooling, Rust core, NSIS build, desktop lifecycle, + staging, silent Unicode-path installation and installed-payload smoke. + Node runtime: **126 passed / 3 existing skips**. Both full Bun lanes: + **93 passed / 1 existing skip / 25 failed**. Installer upload stayed blocked. +- At `6edef4a`, Linux Node 22/24 verify `34054572060`, archive `34054572042`, + and desktop `34054572039` passed, including packaged server/GUI on Ubuntu + 22.04 and 24.04. These are commit-scoped, not a claim that later CI passed. +- At handoff, `3a9506f` CI was still running: general `34057790426`, Linux + desktop `34057790455`, archive `34057790429`, Windows PR `34057790428`, + Windows push `34057788096`. Re-query: the handoff-only commit may supersede + these runs. No current all-green result is claimed. +- Local native verification remains unavailable without + `dist-native/gajae-core.exe`; do not suppress the resulting `ENOENT` tests. +- Key source: `server/gjc-bun-sdk-adapter.ts`, + `server/gjc-delegation-executor.ts`, their Bun contract tests, + `server/gjc-sdk-fixture-cleanup.ts`, `scripts/run-windows-tests.mjs`, + `scripts/probe-windows-sdk-locks.mjs`. +- Full acceptance record: `docs/WINDOWS-DESKTOP.md`. Local diagnostic: + `artifacts/ci-34056951959/runtime/gajae-runtime-windows.log` (probe starts + at line 828). Re-download the `windows-runtime-diagnostics` artifact from + that run when needed. Local diagnostic artifacts are not committed. + +### Remaining work after a new owner instruction + +1. Inspect branch/worktree, PR44 and current CI; preserve all unrelated work. +2. Resolve the SDK source/dependency scope decision before modifying SDK code, + packages or pins. No app-side bypass is approved. +3. After an authorized source fix, retain native and complete delegation + coverage; verify exact ownership and bounded shutdown before reuse. + The parent runs gates/formatters, not parallel editing agents. +4. Obtain a current gated installer and complete isolated Windows GUI/login, + real provider turn, deep-link, shutdown/persistence, reinstall/uninstall + checks without touching the operator profile. Signing remains incomplete. +5. Update commit-specific evidence and PR44. Keep beta.8 preview evidence + historical; build/silent payload smoke does not establish GUI acceptance. + ## Current task scope PRs #30, #35, #38 and #39 are merged. The owner has excluded OMG skill testing; diff --git a/docs/WINDOWS-DESKTOP.md b/docs/WINDOWS-DESKTOP.md new file mode 100644 index 00000000..9193e12f --- /dev/null +++ b/docs/WINDOWS-DESKTOP.md @@ -0,0 +1,190 @@ +# Windows desktop preview + +The Windows port builds an x64 NSIS installer from this branch. It includes +Node 22.22.2, Bun 1.4.0, GJC SDK 0.16.4, the Rust core, the server, and the +web UI. Windows ARM64 and 32-bit builds are not supported by this payload. + +This is a preview build path. It is not part of the signed macOS release lane. +The Windows workflow uploads an unsigned installer and SHA-256 file as Actions +artifacts; it does not create a GitHub Release. Windows may show an unknown +publisher warning until a Windows signing certificate is configured. + +The merged source targets package `2.0.0-beta.9` and desktop version `0.2.3`. +The integration record below separates passing build/payload checks from the +unresolved Windows SDK runtime gate. Neither that record nor the historical +beta.8 record establishes interactive acceptance for the merged source. + +## Build on Windows + +Use Windows 10 version 1809 or later (Bun's minimum), or Windows 11, on x64. +Install these development prerequisites: + +- Node.js 22.22.2+ (22.x) or 24.15.0+ (24.x), with npm. +- Git for Windows, available on PATH; the agent's shell tools also need its Bash. +- Visual Studio 2022 Build Tools, including Desktop development with C++, an + MSVC x64 toolchain and a Windows SDK. Python 3 is needed if a native npm module + must build from source. +- Rust through rustup. `rust-toolchain.toml` selects the project's Rust version. +- Microsoft Edge WebView2 Runtime for the desktop window. + +Follow the official [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) +for the C++ toolchain and WebView2. The +[Bun installation requirements](https://bun.com/docs/installation) define the +runtime's Windows minimum. The application's interactive acceptance checks +below must still be run on the intended Windows version. + +Windows PowerShell 5.1's legacy compiler needs ASCII temporary filenames. When +the temporary directory contains Unicode, the worker uses a verified Windows +8.3 alias of the same protected directory and restores its environment after +compilation. If that volume has no usable short names, the app reports an error; +use an ASCII, writable `TEMP` and `TMP` for the launch/build session. Profiles, +project paths and installed app paths can still contain Unicode. + +In PowerShell, from the repository root: + +```powershell +npm ci +npm run desktop:build:windows +``` + +The build fetches checksum-pinned Windows runtimes, compiles the application, +installs production dependencies into the payload, verifies native modules and +worker initialization from a copy outside the checkout, then creates an NSIS +installer. The build must run natively on Windows x64: Linux/macOS dependency +installations cannot supply the Windows native modules. + +Installer output: + +```text +src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe +``` + +For the development shell, stage the payload first: + +```powershell +npm run server:payload:windows +npm run desktop:dev +``` + +Rebuild the payload after changing the server or frontend: the desktop shell +loads the staged production payload. To develop with Vite's hot reload instead, +run `node scripts/fetch-bun.mjs`, then `npm run dev`, and open the client in a +browser. + +## Automated checks + +The `Windows desktop` workflow in `.github/workflows/windows.yml` runs on +`windows-2022` for this branch, main and pull requests to main. It checks source, +Rust core tests, a Windows runtime regression suite, and desktop lifecycle tests. +An initial compiler job probes both ordinary and isolated Unicode temporary +paths before the build job installs npm dependencies. +The runtime lane probes the pinned SDK's public file-lock primitives in a +separate Bun process on both temporary and checkout paths, then runs the full +SDK and delegation contract suites even if that probe fails. Native refusals, +NTSTATUS values and retained paths remain failures; no deletion retries or +extended session-disposal deadlines hide them. +It builds the NSIS installer, installs it into a temporary directory containing +spaces and Korean text, then verifies the installed server payload before +uploading the installer and checksum. + +The focused Windows regression suite can also run locally after building: + +```powershell +npm run test:windows +``` + +The existing complete `npm run verify` suite remains the Linux regression gate. +A successful focused Windows check does not imply every legacy test fixture is +portable to Windows. + +To compare a downloaded beta.9 installer against its companion checksum: + +```powershell +Get-FileHash .\gajae-app-desktop-2.0.0-beta.9-windows-x64-setup.exe -Algorithm SHA256 +Get-Content .\gajae-app-desktop-2.0.0-beta.9-windows-x64-setup.exe.sha256 +``` + +## Interactive acceptance before release + +A native runner can verify compilation and installed backend behavior. Record +these additional checks on a Windows desktop before calling the preview a +validated public release: + +1. Install as a regular user and open the app through the Start menu. Confirm + that the window renders and reaches the supervised loopback server. +2. Create a project under a path containing spaces and Korean text; authenticate + a provider and run a real agent turn that edits and reads a file. +3. Exercise the terminal, open a file in an editor, and check approval prompts. +4. Stop an active turn; close the app and confirm its server/worker/terminal + descendants exit. Relaunch and check that sessions and settings survive. +5. Open a `gajae-app://` link with the app closed and with it already running. +6. Reinstall and uninstall, checking user-data preservation and removal of app + shortcuts and protocol registration. + +Native macOS computer-control integration is separate from the browser and +terminal tools; this port does not add a Windows native computer-control driver. + +## Integration verification — beta.9 / commit `6edef4a` — September 6, 2026 + +- Windows runs `34054572083` and `34054569808` (attempt 2) passed compiler + preflight, source/build-tool checks, Rust core tests, NSIS construction, + desktop lifecycle tests, staging, silent installation under a Unicode path, + and installed-server payload smoke. +- The Node Windows runtime tests passed: 126 passed, three existing skips. + The complete Bun SDK/delegation lane failed identically in both runs: + 93 passed, one skipped, 25 failed. SDK file-lock release reports + `sharing_violation` / `EACCES`; session teardown exceeds its bounded deadline + waiting for coordinator persistence. The retained workflow/configuration + lock trees are not evidence of the earlier detached-broker defect. +- These failures block the preview-installer upload. They must not be hidden + by fixture deletion retries, indefinite disposal waits, disabled persistence, + antivirus exclusions, or reduced Windows test coverage. +- The isolated probe at `11bee00`, Windows run `34056951959`, reproduced the + failure without creating any `AgentSession`: on Windows build `10.0.20348`, + native `snapshotDirectoryTree` succeeds but `exactRemoveDirectoryTree` + returns `ok: false`, `code: sharing_violation`, with `detachedPath` still + equal to the original lock path. Native removal, SDK release, and concurrent + SDK release fail on both C: temporary and D: checkout paths. The six cases + pass locally on Windows 11 build `10.0.26200`. This isolates a pinned native + SDK filesystem failure; it does not identify the handle holder or justify + changing antivirus settings. No dependency has been patched or upgraded. +- Linux CI `34054572060` passed the Node 22/24 verification gate; Linux archive + `34054572042` passed. Linux desktop `34054572039` passed deb/AppImage builds + and packaged server/GUI checks on Ubuntu 22.04 and 24.04. +- Local Windows 11 passes do not establish Windows Server 2022 correctness. + Interactive Windows GUI, provider sign-in, a real agent turn, deep-link and + reinstall/uninstall acceptance, and signing remain unverified. + +## HISTORICAL verification record — beta.8 / commit `2889326` — September 5, 2026 + +> **Historical evidence only.** The record below applies to package +> `2.0.0-beta.8`, desktop version `0.2.2`, and the Windows source at commit +> `2889326`. It does not verify the merged package `2.0.0-beta.9`, desktop +> version `0.2.3`, or GJC SDK `0.16.4`; no current Windows CI or interactive +> acceptance result is claimed here. + +- Linux x64, Node 24.18.0, code commit `21ac3b6`: `npm run verify` passed, + including 1,440 JavaScript and Bun tests and 59 Rust unit tests plus four + Rust process tests. +- Native Windows CI run `33958813323`, code commit `2889326`, passed on + `windows-2022`: 49 build-tool tests, 108 runtime tests, 60 Rust core unit tests, + four Rust process tests, and 19 Tauri desktop tests. One Rust fixture is + intentionally excluded from direct execution and is launched by its owning + process-tree test. +- The NSIS installer was built, installed under a path containing spaces and + Korean text, and the installed payload passed SQLite, ConPTY, native core, + ripgrep, Bun worker, supervised model catalog/Job ownership, desktop + authentication, frontend delivery and graceful shutdown checks. +- The installer remains unsigned. Interactive GUI, provider sign-in, a real + agent turn, and reinstall/uninstall acceptance remain in the checklist above. + +Verified preview artifact from that run: + +```text +gajae-app-desktop-2.0.0-beta.8-windows-x64-setup.exe +SHA-256: 3e5431de5c9a372f971a5643e9cda3a3352fb52e2efb75d2949906eac5b74eef +``` + +The downloaded installer matches the companion checksum. The CI artifact is +named `gajae-app-desktop-windows-x64` and is retained for 14 days; source builds +remain available after it expires. diff --git a/native/gajae-core/src/git.rs b/native/gajae-core/src/git.rs index 9e63de9e..25f021f8 100644 --- a/native/gajae-core/src/git.rs +++ b/native/gajae-core/src/git.rs @@ -238,17 +238,10 @@ fn create(workdir: &Path, params: &Value) -> Result { let root = managed_root(workdir)?; std::fs::create_dir_all(&root).map_err(|_| GitError::InvalidPath)?; let base = git_text(workdir, ["rev-parse", "HEAD^{commit}"])?; + let git_path = git_path_argument(&path)?; let status = git_status( workdir, - [ - "worktree", - "add", - "-b", - &branch, - "--", - path.to_str().ok_or(GitError::UnsupportedEncoding)?, - &base, - ], + ["worktree", "add", "-b", &branch, "--", &git_path, &base], ); if !status { return Err(GitError::GitFailed); @@ -451,15 +444,8 @@ fn prune(workdir: &Path, params: &Value) -> Result { { return Err(GitError::DirtyWorktree); } - if !git_status( - workdir, - [ - "worktree", - "remove", - "--", - path.to_str().ok_or(GitError::UnsupportedEncoding)?, - ], - ) { + let git_path = git_path_argument(&path)?; + if !git_status(workdir, ["worktree", "remove", "--", &git_path]) { return Err(GitError::GitFailed); } Ok(json!({"pruned":true,"branchRetained":true})) @@ -499,6 +485,38 @@ fn valid_id(value: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':')) } +fn git_path_argument(path: &Path) -> Result { + let value = path.to_str().ok_or(GitError::UnsupportedEncoding)?; + // Keep canonical/verbatim paths for filesystem authorization, but Git's + // worktree arguments use its ordinary drive/UNC spelling. Passing \\?\ to + // Git for Windows can reject worktree creation despite a valid cwd. + Ok(if cfg!(windows) { + windows_git_path(value) + } else { + value.to_owned() + }) +} + +fn windows_git_path(value: &str) -> String { + if let Some(unc) = value.strip_prefix(r"\\?\UNC\") { + format!("//{}", unc.replace('\\', "/")) + } else if let Some(drive) = value.strip_prefix(r"\\?\") { + if drive + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + && drive.as_bytes().get(1) == Some(&b':') + { + drive.replace('\\', "/") + } else { + // Never turn a device/volume namespace into a relative Git path. + value.to_owned() + } + } else { + value.replace('\\', "/") + } +} + fn validate_workdir(workdir: &Path) -> Result { if !workdir.is_absolute() || std::fs::symlink_metadata(workdir) @@ -511,7 +529,10 @@ fn validate_workdir(workdir: &Path) -> Result { let canonical = std::fs::canonicalize(workdir).map_err(|_| GitError::InvalidPath)?; let top = git_text(&canonical, ["rev-parse", "--show-toplevel"]) .map_err(|_| GitError::NotRepository)?; - let top = PathBuf::from(top); + // Git for Windows returns ordinary drive/UNC paths, while Rust returns + // verbatim paths (\\?\...) from canonicalize. Compare filesystem identities + // instead of rejecting a repository because of its path spelling. + let top = canonicalize_git_path(&canonical, top).map_err(|_| GitError::InvalidPath)?; if canonical != top { return Err(GitError::InvalidPath); } @@ -556,7 +577,11 @@ fn canonicalize_existing_prefix(path: &Path) -> Result { { return Err(GitError::InvalidPath); } - Ok(canonical_ancestor.join(tail)) + if tail.as_os_str().is_empty() { + Ok(canonical_ancestor) + } else { + Ok(canonical_ancestor.join(tail)) + } } fn nearest_existing(path: &Path) -> Result { @@ -573,7 +598,7 @@ fn worktrees(workdir: &Path) -> Result, GitError> { let root = managed_root(workdir)?; Ok(parse_nul_worktrees(output.stdout)? .into_iter() - .filter(|item| is_managed_worktree_path(&root, &item.path)) + .filter_map(|item| canonical_managed_worktree(&root, item)) .collect()) } else { parse_registered_newline_worktrees( @@ -591,13 +616,28 @@ fn parse_registered_newline_worktrees( let common_git_dir = common_git_dir(workdir)?; Ok(parse_newline_worktrees(bytes)? .into_iter() - .filter(|item| { - is_managed_worktree_path(&root, &item.path) - && is_registered_with_common_git_dir(&common_git_dir, &item.path) - }) + .filter_map(|item| canonical_managed_worktree(&root, item)) + .filter(|item| is_registered_with_common_git_dir(&common_git_dir, &item.path)) .collect()) } +fn canonical_managed_worktree(root: &Path, mut item: Worktree) -> Option { + if !item.path.is_absolute() + || item + .path + .as_os_str() + .to_string_lossy() + .chars() + .any(char::is_control) + { + return None; + } + // Resolve the existing prefix so missing/prunable worktrees remain visible. + // Containment is checked after resolution, including symlink targets. + item.path = canonicalize_existing_prefix(&item.path).ok()?; + is_managed_worktree_path(root, &item.path).then_some(item) +} + fn common_git_dir(workdir: &Path) -> Result { let path = git_text(workdir, ["rev-parse", "--git-common-dir"])?; canonicalize_git_path(workdir, path) @@ -770,6 +810,14 @@ where if too_large { return Err(GitError::OutputTooLarge); } + #[cfg(test)] + if !status.success() && !stderr.is_empty() { + eprintln!( + "Git test command {:?} failed: {}", + command.get_args().collect::>(), + String::from_utf8_lossy(&stderr) + ); + } Ok(Output { status, stdout, @@ -781,7 +829,14 @@ fn git_environment() -> Vec<(String, String)> { ["PATH", "HOME"] .into_iter() .chain(if cfg!(windows) { - vec!["SystemRoot", "TEMP", "TMP"] + vec![ + "SystemRoot", + "TEMP", + "TMP", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + ] } else { Vec::new() }) @@ -882,7 +937,7 @@ mod tests { fn new() -> Self { static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let path = std::env::temp_dir().join(format!( - "gajae-core-git-test-{}-{}-{}", + "gajae core git 한글 test-{}-{}-{}", std::process::id(), COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed), SystemTime::now() @@ -902,6 +957,14 @@ mod tests { .unwrap() .success() ); + assert!( + Command::new("git") + .args(["config", "core.autocrlf", "false"]) + .current_dir(&path) + .status() + .unwrap() + .success() + ); std::fs::write(path.join("tracked.txt"), "before\n").unwrap(); assert!( Command::new("git") @@ -953,6 +1016,116 @@ mod tests { frame } + #[test] + fn git_arguments_use_ordinary_windows_drive_and_unc_paths() { + assert_eq!( + windows_git_path(r"\\?\C:\Users\가재 dev\.gjc-worktrees\job-1"), + "C:/Users/가재 dev/.gjc-worktrees/job-1" + ); + assert_eq!( + windows_git_path(r"\\?\UNC\server\share\가재 dev\job-1"), + "//server/share/가재 dev/job-1" + ); + assert_eq!(windows_git_path(r"C:\work\job-1"), "C:/work/job-1"); + assert_eq!( + windows_git_path(r"\\?\Volume{example}\work"), + r"\\?\Volume{example}\work" + ); + } + + #[test] + fn starts_git_protocol_from_repository_root_but_rejects_subdirectories() { + let repo = TestRepo::new(); + let mut output = Vec::new(); + assert!(run(&repo.path, Cursor::new(Vec::new()), &mut output)); + assert_eq!( + serde_json::from_slice::(&output).unwrap(), + json!({"protocolVersion": 1, "kind": "ready"}) + ); + let nested = repo.path.join("nested"); + std::fs::create_dir(&nested).unwrap(); + assert!(matches!( + validate_workdir(&nested), + Err(GitError::InvalidPath) + )); + } + + #[test] + fn missing_registered_worktrees_remain_listed_and_unmanaged_paths_are_rejected() { + let repo = TestRepo::new(); + let path = repo.path.join(".gjc-worktrees/missing"); + create( + &repo.path, + &json!({"jobId": "missing", "branch": "job/missing", "path": path}), + ) + .unwrap(); + std::fs::remove_dir_all(&path).unwrap(); + let entries = worktrees(&repo.path).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, path); + assert!(entries[0].prunable); + + let root = managed_root(&repo.path).unwrap(); + for invalid in [repo.path.join("unmanaged"), root.join("..\\outside")] { + let item = Worktree { + path: invalid, + head: String::new(), + branch: None, + locked: false, + prunable: true, + }; + assert!(canonical_managed_worktree(&root, item).is_none()); + } + } + + #[cfg(unix)] + #[test] + fn resolved_worktree_paths_cannot_escape_through_symlinks() { + let repo = TestRepo::new(); + let root = managed_root(&repo.path).unwrap(); + std::fs::create_dir(&root).unwrap(); + let outside = repo.path.join("outside"); + std::fs::create_dir(&outside).unwrap(); + let linked = root.join("linked"); + std::os::unix::fs::symlink(outside, &linked).unwrap(); + let item = Worktree { + path: linked, + head: String::new(), + branch: None, + locked: false, + prunable: false, + }; + assert!(canonical_managed_worktree(&root, item).is_none()); + } + + #[cfg(windows)] + #[test] + fn windows_git_paths_resolve_to_verbatim_worktree_identity() { + let repo = TestRepo::new(); + let git_root = git_text(&repo.path, ["rev-parse", "--show-toplevel"]).unwrap(); + assert_ne!(PathBuf::from(&git_root), repo.path); + assert_eq!(validate_workdir(Path::new(&git_root)).unwrap(), repo.path); + let requested = PathBuf::from(git_root).join(".gjc-worktrees/job-1"); + let params = json!({"jobId": "job-1", "branch": "job/job-1", "path": requested}); + let first = create(&repo.path, ¶ms).unwrap(); + assert_eq!(first["created"], true); + assert_eq!(create(&repo.path, ¶ms).unwrap()["created"], false); + assert_eq!( + registered(&repo.path, "job-1", "job/job-1", &requested).unwrap(), + std::fs::canonicalize(&requested).unwrap() + ); + let fallback = parse_registered_newline_worktrees( + &repo.path, + git_bytes(&repo.path, ["worktree", "list", "--porcelain"]).unwrap(), + ) + .unwrap(); + assert_eq!(fallback.len(), 1); + assert_eq!(fallback[0].path, std::fs::canonicalize(requested).unwrap()); + let mut prune_params = params; + prune_params["confirmed"] = json!(true); + assert_eq!(prune(&repo.path, &prune_params).unwrap()["pruned"], true); + } + #[test] fn diff_returns_patch_chunks_for_a_managed_worktree() { let repo = TestRepo::new(); @@ -1159,7 +1332,14 @@ mod tests { let environment = git_environment(); assert!(environment.iter().all(|(key, _)| matches!( key.as_str(), - "PATH" | "HOME" | "SystemRoot" | "TEMP" | "TMP" + "PATH" + | "HOME" + | "SystemRoot" + | "TEMP" + | "TMP" + | "USERPROFILE" + | "HOMEDRIVE" + | "HOMEPATH" ))); } diff --git a/native/gajae-core/src/jobs.rs b/native/gajae-core/src/jobs.rs index 02f5fa93..213e39c4 100644 --- a/native/gajae-core/src/jobs.rs +++ b/native/gajae-core/src/jobs.rs @@ -1975,6 +1975,7 @@ mod tests { .execute("INSERT INTO job_events VALUES('j',2,'e','{}')", []) .is_err() ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -1986,10 +1987,10 @@ mod tests { a.prepare( "job", &lease, - "/tmp/job-worktree", + d.join("job-worktree").to_str().unwrap(), "job/job", "base", - "/tmp/repository", + d.join("repository").to_str().unwrap(), ) .unwrap(); a.admit("job", &lease, "run-1", "session").unwrap(); @@ -2018,6 +2019,7 @@ mod tests { .unwrap(), event ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2037,6 +2039,7 @@ mod tests { ); assert_eq!(a.snapshot(id).unwrap().last_sequence, 0); } + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2073,6 +2076,7 @@ mod tests { .events .is_empty() ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2110,6 +2114,7 @@ mod tests { .unwrap(); assert_eq!(snapshot.state, JobState::Succeeded); assert_eq!(snapshot.lease, None); + drop(authority); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2148,6 +2153,7 @@ mod tests { Err(AuthorityError::StaleLease) ); assert_eq!(a.snapshot("j").unwrap().lease, Some(new_lease)); + drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -2163,6 +2169,7 @@ mod tests { let r = a.replay("j", 0, 1, "test").unwrap(); assert_eq!(r.events.len(), 1); assert_eq!(r.next_cursor, Some(1)); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2280,6 +2287,7 @@ mod tests { a.append_event("run", &l, "stale", json!(1)), Err(AuthorityError::StaleLease) ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2380,6 +2388,7 @@ mod tests { .len(), 5 ); + drop(authority); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2486,6 +2495,7 @@ mod tests { a.reserve_start("empty", "p", "app", "owner", Some(" "), 1), Err(AuthorityError::InvalidIdentifier) ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2509,6 +2519,7 @@ mod tests { .prompt, None ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2610,6 +2621,7 @@ mod tests { }) .unwrap(); assert_eq!(archived_at, None); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2688,6 +2700,7 @@ mod tests { c.query_row("SELECT archived_at FROM jobs LIMIT 1", [], |_| Ok(())) .is_err() ); + drop(c); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2749,6 +2762,7 @@ mod tests { ) .unwrap(); assert_eq!(normalized_count, 0); + drop(c); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2773,6 +2787,7 @@ mod tests { .get::<_, String>(0)) .is_ok() ); + drop(c); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2809,6 +2824,7 @@ mod tests { ) .is_err() ); + drop(c); std::fs::remove_dir_all(d).unwrap(); } } @@ -2859,6 +2875,7 @@ mod tests { c.query_row("SELECT base_commit FROM jobs LIMIT 1", [], |_| Ok(())) .is_err() ); + drop(c); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2878,12 +2895,33 @@ mod tests { assert_eq!(reserved.state, JobState::Reserved); let lease = reserved.lease.unwrap(); - a.prepare("wait", &lease, "/tmp/tree", "job/wait", "base", "/tmp") - .unwrap(); - a.prepare("wait", &lease, "/tmp/tree", "job/wait", "base", "/tmp") - .unwrap(); + a.prepare( + "wait", + &lease, + d.join("tree").to_str().unwrap(), + "job/wait", + "base", + d.to_str().unwrap(), + ) + .unwrap(); + a.prepare( + "wait", + &lease, + d.join("tree").to_str().unwrap(), + "job/wait", + "base", + d.to_str().unwrap(), + ) + .unwrap(); assert_eq!( - a.prepare("wait", &lease, "/tmp/other", "job/wait", "base", "/tmp"), + a.prepare( + "wait", + &lease, + d.join("other").to_str().unwrap(), + "job/wait", + "base", + d.to_str().unwrap() + ), Err(AuthorityError::WorktreeConflict) ); let admitted = a.admit("wait", &lease, "run", "app").unwrap(); @@ -2919,10 +2957,10 @@ mod tests { a.prepare( "prepared", &prepared_lease, - "/tmp/prepared-tree", + d.join("prepared-tree").to_str().unwrap(), "job/prepared", "base", - "/tmp", + d.to_str().unwrap(), ) .unwrap(); a.reserve("queued", "p", "o", 64).unwrap(); @@ -2930,10 +2968,10 @@ mod tests { a.prepare( "queued", &queued_lease, - "/tmp/queued-tree", + d.join("queued-tree").to_str().unwrap(), "job/queued", "base", - "/tmp", + d.to_str().unwrap(), ) .unwrap(); a.admit("queued", &queued_lease, "queued-run", "queued-app") @@ -2944,9 +2982,12 @@ mod tests { assert_eq!(a.snapshot("bare").unwrap().state, JobState::Interrupted); let prepared = a.snapshot("prepared").unwrap(); assert_eq!(prepared.state, JobState::Interrupted); - assert_eq!(prepared.worktree_id.as_deref(), Some("/tmp/prepared-tree")); + assert_eq!( + prepared.worktree_id.as_deref(), + d.join("prepared-tree").to_str() + ); assert_eq!(prepared.base_commit.as_deref(), Some("base")); - assert_eq!(prepared.repository_root.as_deref(), Some("/tmp")); + assert_eq!(prepared.repository_root.as_deref(), d.to_str()); assert_eq!(a.snapshot("queued").unwrap().state, JobState::Interrupted); let readmitted = a @@ -2960,6 +3001,7 @@ mod tests { .unwrap(), 2 ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -3017,10 +3059,10 @@ mod tests { a.prepare( "replacement", &queued_lease, - "/canonical/worktree", + d.join("worktree").to_str().unwrap(), "job/replacement", "base", - "/canonical", + d.to_str().unwrap(), ) .unwrap(); a.admit("replacement", &queued_lease, "run", "app").unwrap(); @@ -3037,6 +3079,7 @@ mod tests { .state, JobState::Failed ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -3049,10 +3092,10 @@ mod tests { a.prepare( "j", &lease, - "/canonical/worktree", + d.join("worktree").to_str().unwrap(), "job/j", "base", - "/canonical", + d.to_str().unwrap(), ) .unwrap(); a.admit("j", &lease, "run", "app").unwrap(); @@ -3067,6 +3110,7 @@ mod tests { a.cancel_admission("j", &lease, "cancel-terminal", json!(null), None), Err(AuthorityError::StaleLease) ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -3076,16 +3120,23 @@ mod tests { a.reserve("j", "gjc", "o", 4).unwrap(); let lease = a.snapshot("j").unwrap().lease.unwrap(); assert_eq!( - a.prepare("j", &lease, "relative", "job/j", "base", "/canonical"), + a.prepare( + "j", + &lease, + "relative", + "job/j", + "base", + d.to_str().unwrap() + ), Err(AuthorityError::InvalidIdentifier) ); a.prepare( "j", &lease, - "/canonical/worktree", + d.join("worktree").to_str().unwrap(), "job/j", "base", - "/canonical", + d.to_str().unwrap(), ) .unwrap(); let admitted = a.admit("j", &lease, "r", "app").unwrap(); @@ -3115,6 +3166,7 @@ mod tests { .provider_session_id, Some("provider".to_owned()) ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -3146,6 +3198,7 @@ mod tests { .len(), 0 ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -3163,10 +3216,10 @@ mod tests { a.prepare( "j", &lease, - "/canonical/worktree", + d.join("worktree").to_str().unwrap(), "job/j", "base", - "/canonical", + d.to_str().unwrap(), ) .unwrap(); a.admit("j", &lease, "r1", "app").unwrap(); @@ -3196,17 +3249,25 @@ mod tests { ); a.release_binding("j").unwrap(); assert_eq!(a.resolve_binding("p", "app"), Err(AuthorityError::NotFound)); + drop(a); std::fs::remove_dir_all(d).unwrap(); } - fn admit_test_run(authority: &mut PersistentAuthority) -> Lease { + fn admit_test_run(authority: &mut PersistentAuthority, root: &Path) -> Lease { let lease = authority .reserve_start("j", "p", "app", "owner", None, 4) .unwrap() .lease .unwrap(); authority - .prepare("j", &lease, "/tmp/tree", "job/j", "base", "/tmp") + .prepare( + "j", + &lease, + root.join("tree").to_str().unwrap(), + "job/j", + "base", + root.to_str().unwrap(), + ) .unwrap(); authority.admit("j", &lease, "r1", "app").unwrap(); authority @@ -3219,7 +3280,7 @@ mod tests { fn a_readmitted_lease_cannot_mutate_the_previous_run() { let (d, p) = db(); let mut a = PersistentAuthority::open(&p).unwrap(); - let old_lease = admit_test_run(&mut a); + let old_lease = admit_test_run(&mut a, &d); a.transition("j", &old_lease, JobState::Interrupted) .unwrap(); let current = a.readmit("j", "next-owner", "r2", "app", 4).unwrap(); @@ -3278,7 +3339,7 @@ mod tests { fn event_retries_cannot_reassign_history_to_another_run() { let (d, p) = db(); let mut a = PersistentAuthority::open(&p).unwrap(); - let lease = admit_test_run(&mut a); + let lease = admit_test_run(&mut a, &d); let event = a .append_event_for_run("j", &lease, "r1", "shared-event", json!(1)) .unwrap(); diff --git a/native/gajae-core/src/lib.rs b/native/gajae-core/src/lib.rs index 19747ae3..5461d518 100644 --- a/native/gajae-core/src/lib.rs +++ b/native/gajae-core/src/lib.rs @@ -209,11 +209,10 @@ mod tests { } #[test] fn parses_absolute_git_workdir_only() { + let workdir = std::env::temp_dir().join("repository"); assert_eq!( - parse_args([os("git"), os("--workdir"), os("/tmp/repository")]), - Ok(Command::Git { - workdir: std::path::PathBuf::from("/tmp/repository") - }) + parse_args([os("git"), os("--workdir"), workdir.clone().into_os_string()]), + Ok(Command::Git { workdir }) ); assert_eq!( parse_args([os("git"), os("--workdir"), os("relative")]), @@ -222,6 +221,46 @@ mod tests { assert_eq!(parse_args([os("git")]), Err(ParseError)); } + #[test] + fn parses_absolute_jobs_database_only() { + let database = std::env::temp_dir().join("jobs.sqlite"); + assert_eq!( + parse_args([ + os("jobs"), + os("--database"), + database.clone().into_os_string() + ]), + Ok(Command::Jobs { database }) + ); + assert_eq!( + parse_args([os("jobs"), os("--database"), os("relative.sqlite")]), + Err(ParseError) + ); + } + + #[cfg(windows)] + #[test] + fn parses_windows_absolute_paths_and_rejects_drive_relative_paths() { + for path in [ + r"C:\work space\한글", + r"\\server\share\repo", + r"\\?\C:\repo", + ] { + assert_eq!( + parse_args([os("git"), os("--workdir"), os(path)]), + Ok(Command::Git { + workdir: path.into() + }) + ); + } + for path in [r"C:repo", r"\repo", "/tmp/repository"] { + assert_eq!( + parse_args([os("git"), os("--workdir"), os(path)]), + Err(ParseError) + ); + } + } + #[test] fn rejects_malformed_invocations() { assert_eq!(parse_args(std::iter::empty()), Err(ParseError)); diff --git a/native/gajae-core/src/pty.rs b/native/gajae-core/src/pty.rs index ae69bb0a..5d377e6b 100644 --- a/native/gajae-core/src/pty.rs +++ b/native/gajae-core/src/pty.rs @@ -23,6 +23,10 @@ struct Request { } pub fn run(program: OsString, args: Vec) -> bool { + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(_) => return false, + }; let pair = match native_pty_system().openpty(PtySize { rows: 24, cols: 80, @@ -34,6 +38,8 @@ pub fn run(program: OsString, args: Vec) -> bool { }; let mut command = CommandBuilder::new(program); command.args(args); + // portable-pty defaults to HOME/USERPROFILE, not the host's project cwd. + command.cwd(cwd); let mut child = match pair.slave.spawn_command(command) { Ok(child) => child, Err(_) => return false, @@ -60,6 +66,13 @@ pub fn run(program: OsString, args: Vec) -> bool { let output_lock = Arc::new(Mutex::new(())); let failed = Arc::new(AtomicBool::new(false)); + // A fast child must not publish output/exit before the protocol handshake. + if !write_frame(&output_lock, json!({"protocolVersion": 1, "kind": "ready"})) { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + let reader_output = Arc::clone(&output_lock); let reader_failed = Arc::clone(&failed); let reader_thread = thread::Builder::new() @@ -121,12 +134,6 @@ pub fn run(program: OsString, args: Vec) -> bool { } }; - if !write_frame(&output_lock, json!({"protocolVersion": 1, "kind": "ready"})) { - let _ = killer.kill(); - let _ = wait_thread.join(); - return false; - } - let stdin = std::io::stdin(); let mut input = stdin.lock(); let mut frame = Vec::new(); diff --git a/native/gajae-core/tests/process_protocol.rs b/native/gajae-core/tests/process_protocol.rs new file mode 100644 index 00000000..6265af8b --- /dev/null +++ b/native/gajae-core/tests/process_protocol.rs @@ -0,0 +1,340 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "gajae core 한글 {label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + // Windows may briefly retain the copied fixture executable while + // ConPTY exits. Never double-panic during a timeout's stack unwind. + for _ in 0..20 { + match std::fs::remove_dir_all(&self.0) { + Ok(()) => return, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(_) => std::thread::sleep(Duration::from_millis(50)), + } + } + let _ = writeln!(std::io::stderr(), "fixture cleanup failed: {:?}", self.0); + } +} + +struct CoreChild(Child); + +impl CoreChild { + fn wait(&mut self) -> ExitStatus { + let deadline = Instant::now() + TIMEOUT; + loop { + if let Some(status) = self.0.try_wait().unwrap() { + return status; + } + assert!( + Instant::now() < deadline, + "core did not exit within {TIMEOUT:?}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for CoreChild { + fn drop(&mut self) { + // Closing input lets the core kill/reap its own PTY child and close + // ConPTY before the executable's directory is removed. + self.0.stdin.take(); + for _ in 0..100 { + if matches!(self.0.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn spawn_fixture(mode: &str, directory: &TestDirectory) -> CoreChild { + // Re-execute this Rust test binary, so the tests do not depend on a Unix + // shell, Node, or an executable script. Exercise spaces/Unicode in argv[0]. + let fixture = directory + .0 + .join(format!("child fixture{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(std::env::current_exe().unwrap(), &fixture).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_gajae-core")); + if mode == "pty" { + command.arg("pty"); + } + CoreChild( + command + .arg("--") + .arg(fixture) + .args(["--exact", "child_fixture", "--nocapture"]) + .env("GAJAE_CORE_CHILD_FIXTURE", mode) + .current_dir(&directory.0) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ) +} + +fn expected_cwd(directory: &TestDirectory) -> String { + format!( + "fixture-cwd={}", + json!(std::fs::canonicalize(&directory.0).unwrap()) + ) +} + +#[test] +fn child_fixture() { + let Ok(mode) = std::env::var("GAJAE_CORE_CHILD_FIXTURE") else { + return; + }; + if mode == "proxy" { + println!( + "fixture-cwd={}", + json!(std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap()) + ); + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + println!("fixture-input={}", STANDARD.encode(bytes)); + std::io::stdout().flush().unwrap(); + std::process::exit(23); + } + // The protocol's ready frame means the PTY exists, not that its child has + // finished console initialization. On ConPTY a cursor query can precede it. + writeln!(std::io::stdout().lock(), "fixture-ready").unwrap(); + std::io::stdout().flush().unwrap(); + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + // Input follows resize, so ConPTY cannot wrap the long path at its + // initial 80 columns and split the marker we assert below. + println!( + "fixture-cwd={}", + json!(std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap()) + ); + println!("fixture-input={}", line.unwrap()); + std::io::stdout().flush().unwrap(); + } +} + +#[derive(Default)] +struct TerminalOutput { + bytes: Vec, + answered_cursor_queries: usize, +} + +impl TerminalOutput { + fn push(&mut self, bytes: &[u8]) -> usize { + self.bytes.extend_from_slice(bytes); + // portable-pty uses PSEUDOCONSOLE_INHERIT_CURSOR. A real terminal + // answers CSI 6 n; ignoring it can deadlock ResizePseudoConsole. + // Count over the accumulated bytes to handle split output frames. + let queries = self + .bytes + .windows(4) + .filter(|part| *part == b"\x1b[6n") + .count(); + let pending = queries - self.answered_cursor_queries; + self.answered_cursor_queries = queries; + pending + } + + fn contains(&self, text: &str) -> bool { + self.bytes + .windows(text.len()) + .any(|part| part == text.as_bytes()) + } +} + +fn write_request(input: &mut impl Write, request: Value) { + writeln!(input, "{request}").unwrap(); + input.flush().unwrap(); +} + +fn receive_frame( + receiver: &mpsc::Receiver>, + deadline: Instant, + phase: &str, + output: &TerminalOutput, + diagnostics: &Mutex>, +) -> Value { + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Ok(frame)) => frame, + failure => { + let message = format!( + "PTY {phase} failed: {failure:?}; output={:?}; stderr={:?}", + String::from_utf8_lossy(&output.bytes), + String::from_utf8_lossy(&diagnostics.lock().unwrap()), + ); + // Bypass libtest capture so timeout diagnostics survive even if + // another Windows cleanup failure aborts the harness. + let _ = writeln!(std::io::stderr().lock(), "{message}"); + panic!("{message}"); + } + } +} + +#[test] +fn terminal_answers_cursor_queries_split_across_output_frames_once() { + let mut terminal = TerminalOutput::default(); + assert_eq!(terminal.push(b"\x1b["), 0); + assert_eq!(terminal.push(b"6nfixture-ready"), 1); + assert_eq!(terminal.push(b"\r\n"), 0); + assert_eq!(terminal.push(b"\x1b[6n"), 1); + assert!(terminal.contains("fixture-ready")); +} + +#[test] +fn proxy_preserves_project_cwd_binary_stdin_and_child_exit_code() { + let directory = TestDirectory::new("proxy"); + let mut core = spawn_fixture("proxy", &directory); + let bytes = b"native\0agent\xff\r\n"; + core.0.stdin.take().unwrap().write_all(bytes).unwrap(); + assert_eq!(core.wait().code(), Some(23)); + let mut output = String::new(); + core.0 + .stdout + .take() + .unwrap() + .read_to_string(&mut output) + .unwrap(); + assert!(output.contains(&expected_cwd(&directory)), "{output}"); + assert!( + output.contains(&format!("fixture-input={}", STANDARD.encode(bytes))), + "{output}" + ); + let mut stderr = String::new(); + core.0 + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .unwrap(); + assert!(stderr.is_empty(), "{stderr}"); +} + +#[test] +fn pty_starts_in_project_directory_and_supports_resize_input_and_shutdown() { + let directory = TestDirectory::new("pty"); + let mut core = spawn_fixture("pty", &directory); + let stdout = core.0.stdout.take().unwrap(); + let mut stderr = core.0.stderr.take().unwrap(); + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let stderr_capture = Arc::clone(&diagnostics); + let stderr_reader = std::thread::spawn(move || { + let mut buffer = [0_u8; 4096]; + while let Ok(count) = stderr.read(&mut buffer) { + if count == 0 { + break; + } + stderr_capture + .lock() + .unwrap() + .extend_from_slice(&buffer[..count]); + } + }); + let (sender, receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let frame = line.map_err(|error| error.to_string()).and_then(|line| { + serde_json::from_str::(&line).map_err(|error| format!("{error}: {line:?}")) + }); + if sender.send(frame).is_err() { + break; + } + } + }); + let mut output = TerminalOutput::default(); + let first = receive_frame( + &receiver, + Instant::now() + TIMEOUT, + "host readiness", + &output, + &diagnostics, + ); + assert_eq!(first, json!({"protocolVersion": 1, "kind": "ready"})); + let mut stdin = core.0.stdin.take().unwrap(); + // Answer terminal queries before resize: ResizePseudoConsole may block + // while ConPTY waits for the cursor reply on its input pipe. + let deadline = Instant::now() + TIMEOUT; + while !output.contains("fixture-ready") { + let frame = receive_frame( + &receiver, + deadline, + "child readiness", + &output, + &diagnostics, + ); + assert_eq!(frame["kind"], "output", "unexpected frame: {frame}"); + let bytes = STANDARD.decode(frame["data"].as_str().unwrap()).unwrap(); + for _ in 0..output.push(&bytes) { + write_request( + &mut stdin, + json!({"protocolVersion": 1, "method": "pty.write", "data": STANDARD.encode(b"\x1b[1;1R")}), + ); + } + } + for request in [ + json!({"protocolVersion": 1, "method": "pty.resize", "cols": 1000, "rows": 30}), + json!({"protocolVersion": 1, "method": "pty.write", "data": STANDARD.encode(b"native-pty-token\r")}), + ] { + write_request(&mut stdin, request); + } + let deadline = Instant::now() + TIMEOUT; + loop { + let frame = receive_frame(&receiver, deadline, "input echo", &output, &diagnostics); + assert_eq!(frame["kind"], "output", "unexpected frame: {frame}"); + let bytes = STANDARD.decode(frame["data"].as_str().unwrap()).unwrap(); + for _ in 0..output.push(&bytes) { + write_request( + &mut stdin, + json!({"protocolVersion": 1, "method": "pty.write", "data": STANDARD.encode(b"\x1b[1;1R")}), + ); + } + if output.contains(&expected_cwd(&directory)) + && output.contains("fixture-input=native-pty-token") + { + break; + } + } + writeln!( + stdin, + "{}", + json!({"protocolVersion": 1, "method": "pty.shutdown"}) + ) + .unwrap(); + drop(stdin); + assert!(core.wait().success()); + reader.join().unwrap(); + stderr_reader.join().unwrap(); + assert!( + receiver + .try_iter() + .any(|frame| frame.unwrap()["kind"] == "exit") + ); + assert!(diagnostics.lock().unwrap().is_empty()); +} diff --git a/package.json b/package.json index af1622fa..b572af97 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "desktop:dev": "node src-tauri/scripts/tauri.mjs dev", "server:bundle": "npm run build && node scripts/release/build-server-bundle.js", "server:payload:macos": "node scripts/fetch-bun.mjs && npm run build && node scripts/release/build-macos-server-payload.mjs", + "server:payload:windows": "node scripts/fetch-bun.mjs && npm run build && node scripts/release/build-windows-server-payload.mjs", + "desktop:build:windows": "npm run server:payload:windows && npm run tauri -- build --target x86_64-pc-windows-msvc --bundles nsis", "server:payload:linux": "node scripts/fetch-bun.mjs && npm run build && node scripts/release/build-linux-server-payload.mjs", "desktop:build:linux": "node scripts/release/build-linux-desktop.mjs", "tauri": "node src-tauri/scripts/tauri.mjs", @@ -62,6 +64,7 @@ "icon:generate": "node scripts/generate-app-icon.mjs --write", "icon:preview": "node scripts/generate-app-icon.mjs --preview", "test": "node scripts/run-tests.mjs", + "test:windows": "node scripts/run-windows-tests.mjs", "smoke:packaged-server": "node scripts/release/smoke-packaged-server.mjs", "pretest": "npm run build:core:dev", "test:e2e:gjc": "TSX_TSCONFIG_PATH=server/tsconfig.json node --import tsx --test --test-concurrency=1 server/e2e/gjc-slice4.browser.e2e.ts server/e2e/gjc-slice4.wire.e2e.ts", diff --git a/scripts/check-audit.mjs b/scripts/check-audit.mjs index fd99b774..0c1478a7 100644 --- a/scripts/check-audit.mjs +++ b/scripts/check-audit.mjs @@ -17,6 +17,8 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { npmInvocation } from './lib/npm-cli.mjs'; + const execFile = promisify(execFileCallback); const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const BLOCKING_SEVERITIES = new Set(['high', 'critical']); @@ -45,7 +47,8 @@ function advisoryIdOf(via) { async function auditReport() { try { - const { stdout } = await execFile('npm', ['audit', '--json'], { + const npm = npmInvocation(['audit', '--json']); + const { stdout } = await execFile(npm.command, npm.args, { cwd: REPOSITORY_ROOT, maxBuffer: 32 * 1024 * 1024, }); diff --git a/scripts/fetch-bun.mjs b/scripts/fetch-bun.mjs index 9d159533..d9d2f8da 100644 --- a/scripts/fetch-bun.mjs +++ b/scripts/fetch-bun.mjs @@ -1,5 +1,4 @@ #!/usr/bin/env node -import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import { createWriteStream } from 'node:fs'; import fs from 'node:fs/promises'; @@ -8,9 +7,11 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { pipeline } from 'node:stream/promises'; -const BUN_VERSION = '1.4.0'; +import { downloadVerifiedArchive, extractWindowsZip } from './runtime-archive.mjs'; + +export const BUN_VERSION = '1.4.0'; const RELEASE_BASE_URL = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}`; -const PLATFORMS = { +export const PLATFORMS = { 'linux-x64': { archive: 'bun-linux-x64.zip', archiveSha256: '2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452', @@ -21,61 +22,63 @@ const PLATFORMS = { archiveSha256: 'c669e97f6164e1c96e0701748db98dfa77492908cbd8394c7557134a735de381', binary: 'bun-darwin-aarch64/bun', }, + 'win32-x64': { + archive: 'bun-windows-x64.zip', + // https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/SHASUMS256.txt + archiveSha256: 'e6f093d39da486b20262ca8cdd5ed6a9e8bc9c2f275b78e6d3a0c5b28cc95901', + binary: 'bun-windows-x64/bun.exe', + }, }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); -const destination = path.join(rootDir, 'dist-native', 'bun'); -const platformKey = `${process.platform}-${process.arch}`; -const platform = PLATFORMS[platformKey]; +const WINDOWS_FILE_OPERATION_MAX_ATTEMPTS = 4; +const WINDOWS_FILE_OPERATION_RETRY_DELAY_MS = 100; +const WINDOWS_TRANSIENT_FILE_ERRORS = new Set(['EBUSY', 'EPERM', 'EACCES']); -async function versionOf(binary) { +export async function versionOf(binary) { return new Promise((resolve) => { - const child = spawn(binary, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }); + const child = spawn(binary, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 15_000 }); let output = ''; child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk) => { output += chunk; }); child.once('error', () => resolve(null)); - child.once('exit', (code) => resolve(code === 0 ? output.trim() : null)); + child.once('close', (code) => resolve(code === 0 ? output.trim() : null)); }); } -async function sha256(filePath) { - const hash = crypto.createHash('sha256'); - const handle = await fs.open(filePath, 'r'); - try { - const buffer = Buffer.alloc(1024 * 1024); - let position = 0; - while (true) { - const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); - if (bytesRead === 0) break; - hash.update(buffer.subarray(0, bytesRead)); - position += bytesRead; - } - } finally { - await handle.close(); - } - return hash.digest('hex'); +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +function isTransientWindowsFileError(error) { + return WINDOWS_TRANSIENT_FILE_ERRORS.has(error?.code); } -async function download(url, destinationPath) { - const response = await fetch(url, { redirect: 'follow' }); - if (!response.ok || !response.body) { - throw new Error(`Bun download failed with HTTP ${response.status}.`); - } - const handle = await fs.open(destinationPath, 'w', 0o600); - try { - for await (const chunk of response.body) { - await handle.write(chunk); +async function runFileOperation(operation, windows, retrySleep) { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (!windows || !isTransientWindowsFileError(error) || attempt >= WINDOWS_FILE_OPERATION_MAX_ATTEMPTS) { + throw error; + } + await retrySleep(WINDOWS_FILE_OPERATION_RETRY_DELAY_MS); } - } finally { - await handle.close(); } } -async function extractBinary(archivePath, archiveBinaryPath, destinationPath) { +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function extractBinary(archivePath, archiveBinaryPath, destinationPath, platformKey) { + if (platformKey.startsWith('win32-')) { + const directory = path.join(path.dirname(archivePath), 'extracted'); + await extractWindowsZip(archivePath, directory); + await fs.copyFile(path.join(directory, ...archiveBinaryPath.split('/')), destinationPath); + return; + } const output = createWriteStream(destinationPath, { mode: 0o700 }); const child = spawn('unzip', ['-p', archivePath, archiveBinaryPath], { stdio: ['ignore', 'pipe', 'inherit'], @@ -90,36 +93,72 @@ async function extractBinary(archivePath, archiveBinaryPath, destinationPath) { await Promise.all([pipeline(child.stdout, output), exited]); } -if (!platform) { - throw new Error(`Bun ${BUN_VERSION} is only bundled for linux-x64 and darwin-arm64; received ${platformKey}.`); -} - -if (await versionOf(destination) === BUN_VERSION) { - console.log(`Bun ${BUN_VERSION} is already available at dist-native/bun.`); - process.exit(0); -} +export async function fetchBun({ + root = rootDir, + platformKey = `${process.platform}-${process.arch}`, + download = downloadVerifiedArchive, + extract = extractBinary, + probe = versionOf, + fsModule = fs, + retrySleep = wait, +} = {}) { + const platform = PLATFORMS[platformKey]; + if (!platform) { + throw new Error(`Bun ${BUN_VERSION} is only bundled for ${Object.keys(PLATFORMS).join(', ')}; received ${platformKey}.`); + } + const windows = platformKey.startsWith('win32-'); + const destination = path.join(root, 'dist-native', windows ? 'bun.exe' : 'bun'); + if (await probe(destination) === BUN_VERSION) { + console.log(`Bun ${BUN_VERSION} is already available at ${destination}.`); + return destination; + } + await fsModule.mkdir(path.dirname(destination), { recursive: true }); + const temporaryDir = await fsModule.mkdtemp(path.join(os.tmpdir(), 'gajae-bun-')); + const archivePath = path.join(temporaryDir, platform.archive); + // Windows CreateProcess needs the executable suffix even before installation. + const temporaryBinary = path.join(path.dirname(destination), `.bun-${path.basename(temporaryDir)}.tmp${windows ? '.exe' : ''}`); -await fs.mkdir(path.dirname(destination), { recursive: true }); -const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gajae-bun-')); -const archivePath = path.join(temporaryDir, platform.archive); -const temporaryBinary = path.join(path.dirname(destination), `.bun-${process.pid}.tmp`); + let failure; + try { + console.log(`Downloading Bun ${BUN_VERSION} for ${platformKey}...`); + await download(`${RELEASE_BASE_URL}/${platform.archive}`, archivePath, platform.archiveSha256); + await extract(archivePath, platform.binary, temporaryBinary, platformKey); + if (!windows) await fsModule.chmod(temporaryBinary, 0o755); + if (await probe(temporaryBinary) !== BUN_VERSION) { + throw new Error('Extracted Bun binary did not report the requested version.'); + } + await runFileOperation(() => fsModule.rename(temporaryBinary, destination), windows, retrySleep); + } catch (error) { + failure = error; + } -try { - console.log(`Downloading Bun ${BUN_VERSION} for ${platformKey}...`); - await download(`${RELEASE_BASE_URL}/${platform.archive}`, archivePath); - const digest = await sha256(archivePath); - if (digest !== platform.archiveSha256) { - throw new Error('Downloaded Bun archive failed SHA-256 verification.'); + const cleanupErrors = []; + try { + await runFileOperation(() => fsModule.rm(temporaryBinary, { force: true }), windows, retrySleep); + } catch (error) { + cleanupErrors.push(error); + } + try { + await runFileOperation(() => fsModule.rm(temporaryDir, { recursive: true, force: true }), windows, retrySleep); + } catch (error) { + cleanupErrors.push(error); } - await extractBinary(archivePath, platform.binary, temporaryBinary); - await fs.chmod(temporaryBinary, 0o755); - if (await versionOf(temporaryBinary) !== BUN_VERSION) { - throw new Error('Extracted Bun binary did not report the requested version.'); + if (failure) { + if (cleanupErrors.length > 0) { + throw new AggregateError( + [failure, ...cleanupErrors], + `${errorMessage(failure)}\nBun temporary cleanup also failed: ${cleanupErrors.map(errorMessage).join('\n')}`, + ); + } + throw failure; + } + if (cleanupErrors.length > 0) { + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + throw new AggregateError(cleanupErrors, `Bun temporary cleanup failed: ${cleanupErrors.map(errorMessage).join('\n')}`); } - await fs.rename(temporaryBinary, destination); - console.log(`Installed Bun ${BUN_VERSION} at dist-native/bun.`); -} finally { - await fs.rm(temporaryBinary, { force: true }); - await fs.rm(temporaryDir, { recursive: true, force: true }); + console.log(`Installed Bun ${BUN_VERSION} at ${destination}.`); + return destination; } + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await fetchBun(); diff --git a/scripts/fetch-bun.test.mjs b/scripts/fetch-bun.test.mjs new file mode 100644 index 00000000..9a1b9743 --- /dev/null +++ b/scripts/fetch-bun.test.mjs @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { BUN_VERSION, fetchBun } from './fetch-bun.mjs'; +import { downloadVerifiedArchive, extractWindowsZip } from './runtime-archive.mjs'; + +async function fixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gajae bun 가재-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} + +test('Windows Bun is installed through an executable .exe staging path with the official pin', async t => { + const root = await fixture(t); + let archive; + let temporaryBinary; + const installed = await fetchBun({ + root, platformKey: 'win32-x64', + download: async (url, target, digest) => { + assert.equal(url, 'https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-windows-x64.zip'); + assert.equal(digest, 'e6f093d39da486b20262ca8cdd5ed6a9e8bc9c2f275b78e6d3a0c5b28cc95901'); + archive = target; + await fs.writeFile(target, 'downloaded'); + }, + extract: async (source, member, target, platform) => { + assert.equal(source, archive); + assert.equal(member, 'bun-windows-x64/bun.exe'); + assert.equal(platform, 'win32-x64'); + assert.match(target, /\.tmp\.exe$/); + temporaryBinary = target; + await fs.writeFile(target, 'verified Bun'); + }, + probe: async target => target === temporaryBinary ? BUN_VERSION : null, + }); + assert.equal(installed, path.join(root, 'dist-native', 'bun.exe')); + assert.equal(await fs.readFile(installed, 'utf8'), 'verified Bun'); + assert.deepEqual(await fs.readdir(path.dirname(installed)), ['bun.exe']); + await assert.rejects(fs.access(path.dirname(archive)), { code: 'ENOENT' }); +}); + +test('Windows retries transient rename and staging cleanup failures within a bound', async t => { + const root = await fixture(t); + const realFs = fs; + const fsModule = Object.create(realFs); + let renameAttempts = 0; + let cleanupAttempts = 0; + fsModule.rename = async (...args) => { + renameAttempts += 1; + if (renameAttempts < 3) { + const error = new Error('executable is still locked'); + error.code = 'EBUSY'; + throw error; + } + return realFs.rename(...args); + }; + fsModule.rm = async (target, options) => { + if (target.includes('.tmp.exe')) { + cleanupAttempts += 1; + if (cleanupAttempts < 3) { + const error = new Error('staging executable is still locked'); + error.code = 'EPERM'; + throw error; + } + } + return realFs.rm(target, options); + }; + + const installed = await fetchBun({ + root, platformKey: 'win32-x64', fsModule, retrySleep: async () => {}, + download: async (_url, target) => fs.writeFile(target, 'zip'), + extract: async (_archive, _member, target) => fs.writeFile(target, 'verified Bun'), + probe: async target => target.endsWith('.tmp.exe') ? BUN_VERSION : null, + }); + assert.equal(installed, path.join(root, 'dist-native', 'bun.exe')); + assert.equal(renameAttempts, 3); + assert.equal(cleanupAttempts, 3); + assert.deepEqual(await fs.readdir(path.dirname(installed)), ['bun.exe']); +}); + +test('a permanent Windows rename failure is reported and preserves the previous executable', async t => { + const root = await fixture(t); + const nativeDir = path.join(root, 'dist-native'); + await fs.mkdir(nativeDir); + const installed = path.join(nativeDir, 'bun.exe'); + await fs.writeFile(installed, 'previous Bun'); + const realFs = fs; + const fsModule = Object.create(realFs); + const renameFailure = Object.assign(new Error('rename denied permanently'), { code: 'EIO' }); + let renameAttempts = 0; + fsModule.rename = async () => { + renameAttempts += 1; + throw renameFailure; + }; + let archive; + + await assert.rejects(fetchBun({ + root, platformKey: 'win32-x64', fsModule, retrySleep: async () => {}, + download: async (_url, target) => { archive = target; await fs.writeFile(target, 'zip'); }, + extract: async (_archive, _member, target) => fs.writeFile(target, 'verified Bun'), + probe: async target => target.endsWith('.tmp.exe') ? BUN_VERSION : null, + }), error => { + assert.equal(error, renameFailure); + return true; + }); + assert.equal(renameAttempts, 1); + assert.equal(await fs.readFile(installed, 'utf8'), 'previous Bun'); + assert.deepEqual(await fs.readdir(nativeDir), ['bun.exe']); + await assert.rejects(fs.access(path.dirname(archive)), { code: 'ENOENT' }); +}); + +test('a permanent staging cleanup failure does not mask version validation and still cleans the archive', async t => { + const root = await fixture(t); + const realFs = fs; + const fsModule = Object.create(realFs); + const cleanupFailure = Object.assign(new Error('staging unlink failed permanently'), { code: 'EIO' }); + let archive; + let binaryCleanupAttempts = 0; + let archiveCleanupAttempted = false; + fsModule.rm = async (target, options) => { + if (target.includes('.tmp.exe')) { + binaryCleanupAttempts += 1; + throw cleanupFailure; + } + archiveCleanupAttempted = true; + return realFs.rm(target, options); + }; + + let failure; + await assert.rejects(fetchBun({ + root, platformKey: 'win32-x64', fsModule, retrySleep: async () => {}, + download: async (_url, target) => { archive = target; await fs.writeFile(target, 'zip'); }, + extract: async (_archive, _member, target) => fs.writeFile(target, 'incorrect Bun'), + probe: async () => '0.0.0', + }), error => { + failure = error; + return true; + }); + assert.ok(failure instanceof AggregateError); + assert.match(failure.message, /did not report the requested version/); + assert.equal(failure.errors[1], cleanupFailure); + assert.equal(binaryCleanupAttempts, 1); + assert.equal(archiveCleanupAttempted, true); + await assert.rejects(fs.access(path.dirname(archive)), { code: 'ENOENT' }); +}); + +test('a wrong extracted version preserves the previous executable and cleans staging', async t => { + const root = await fixture(t); + const nativeDir = path.join(root, 'dist-native'); + await fs.mkdir(nativeDir); + await fs.writeFile(path.join(nativeDir, 'bun.exe'), 'previous Bun'); + let archive; + await assert.rejects(fetchBun({ + root, platformKey: 'win32-x64', + download: async (_url, target) => { archive = target; await fs.writeFile(target, 'zip'); }, + extract: async (_archive, _member, target) => fs.writeFile(target, 'incorrect Bun'), + probe: async () => '0.0.0', + }), /did not report the requested version/); + assert.equal(await fs.readFile(path.join(nativeDir, 'bun.exe'), 'utf8'), 'previous Bun'); + assert.deepEqual(await fs.readdir(nativeDir), ['bun.exe']); + await assert.rejects(fs.access(path.dirname(archive)), { code: 'ENOENT' }); +}); + +test('checksum failure prevents extraction and leaves the installed executable intact', async t => { + const root = await fixture(t); + const nativeDir = path.join(root, 'dist-native'); + await fs.mkdir(nativeDir); + await fs.writeFile(path.join(nativeDir, 'bun.exe'), 'previous Bun'); + let extracted = false; + await assert.rejects(fetchBun({ + root, platformKey: 'win32-x64', probe: async () => null, + download: (url, target, digest) => downloadVerifiedArchive(url, target, digest, { + fetchImpl: async () => new Response('tampered archive'), + }), + extract: async () => { extracted = true; }, + }), /SHA-256/); + assert.equal(extracted, false); + assert.deepEqual(await fs.readdir(nativeDir), ['bun.exe']); + assert.equal(await fs.readFile(path.join(nativeDir, 'bun.exe'), 'utf8'), 'previous Bun'); +}); + +test('an exact cached Bun avoids a download, and unsupported hosts fail before writes', async t => { + const root = await fixture(t); + const installed = await fetchBun({ + root, platformKey: 'win32-x64', probe: async () => BUN_VERSION, + download: async () => assert.fail('cached Bun must not download'), + }); + assert.equal(path.basename(installed), 'bun.exe'); + await assert.rejects(fetchBun({ root, platformKey: 'win32-arm64' }), /received win32-arm64/); + assert.deepEqual(await fs.readdir(root), []); +}); + +test('runtime archive downloads verify content and remove failed or incomplete downloads', async t => { + const root = await fixture(t); + const archive = path.join(root, 'runtime.zip'); + const data = Buffer.from('a trusted runtime archive'); + const digest = createHash('sha256').update(data).digest('hex'); + await downloadVerifiedArchive('https://example.invalid/runtime.zip', archive, digest, { fetchImpl: async () => new Response(data) }); + assert.deepEqual(await fs.readFile(archive), data); + await assert.rejects(downloadVerifiedArchive('https://example.invalid/runtime.zip', archive, digest, { + fetchImpl: async () => new Response('not found', { status: 404 }), + }), /HTTP 404/); + await assert.rejects(fs.access(archive), { code: 'ENOENT' }); +}); + +test('PowerShell ZIP extraction treats spaces, Unicode and metacharacters as literal data', async () => { + const archive = String.raw`C:\Users\가재 name\archive [x] ' & $(noop).zip`; + const destination = String.raw`C:\build output\압축 [y] ' & $(noop)`; + let calls = 0; + await extractWindowsZip(archive, destination, { + env: { SYSTEMROOT: 'C:\\Windows', Path: 'C:\\tools' }, + execute: async (command, args, options) => { + calls += 1; + assert.equal(command, String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`); + assert.equal(options.shell, false); + assert.equal(options.windowsHide, true); + assert.equal(options.env.GAJAE_RUNTIME_ARCHIVE, archive); + assert.equal(options.env.GAJAE_RUNTIME_EXTRACT, destination); + assert.ok(args.includes('-NonInteractive')); + assert.match(args.at(-1), /Expand-Archive -LiteralPath \$env:GAJAE_RUNTIME_ARCHIVE/); + assert.ok(args.every(arg => !arg.includes(archive) && !arg.includes(destination))); + }, + }); + assert.equal(calls, 1); + await assert.rejects(extractWindowsZip(archive, destination, { execute: async () => { throw new Error('bad zip'); } }), /bad zip/); +}); + +test('Windows PowerShell extracts a real ZIP through paths containing spaces and Unicode', { skip: process.platform !== 'win32' }, async t => { + const root = await fixture(t); + const archive = path.join(root, "archive [가재] ' & $(noop).zip"); + const destination = path.join(root, "extracted [가재] ' & $(noop)"); + // A tiny deflated ZIP containing bun-windows-x64/bun.exe. The fixture is + // deliberately not executable; this tests the actual OS extraction path. + const zip = Buffer.from('UEsDBBQAAAAIAE6BJV2G5tSJFQAAABMAAAAXAAAAYnVuLXdpbmRvd3MteDY0L2J1bi5leGUrSS0uUXAqzVNIrUhNLi1JTMpJBQBQSwECFAMUAAAACABOgSVdhubUiRUAAAATAAAAFwAAAAAAAAAAAAAAgAEAAAAAYnVuLXdpbmRvd3MteDY0L2J1bi5leGVQSwUGAAAAAAEAAQBFAAAASgAAAAAA', 'base64'); + await fs.writeFile(archive, zip); + await extractWindowsZip(archive, destination); + assert.equal(await fs.readFile(path.join(destination, 'bun-windows-x64', 'bun.exe'), 'utf8'), 'test Bun executable'); +}); diff --git a/scripts/fill-runtime-manifest.mjs b/scripts/fill-runtime-manifest.mjs index 7cc39601..45d3e2ba 100644 --- a/scripts/fill-runtime-manifest.mjs +++ b/scripts/fill-runtime-manifest.mjs @@ -7,6 +7,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { npmInvocation } from './lib/npm-cli.mjs'; + const execFile = promisify(execFileCallback); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -15,8 +17,7 @@ const manifestPath = path.join(rootDir, 'server', 'gjc-runtime-manifest.json'); const resolverFrom = path.join(rootDir, 'server'); const argv = process.argv.slice(2); const update = argv.includes('--update'); -// Runtime v2 supports Linux x64 and macOS arm64 only; Windows remains intentionally frozen out. -const SUPPORTED_PLATFORMS = new Set(['linux-x64', 'darwin-arm64']); +const SUPPORTED_PLATFORMS = new Set(['linux-x64', 'darwin-arm64', 'win32-x64']); /** * Fill the closure for a platform this machine is not. @@ -85,9 +86,10 @@ async function closureFiles(packageName, packageRoot, filenames) { async function fetchPlatformRoot(platform, version) { const platformPackage = `@gajae-code/natives-${platform}`; const destination = await fs.mkdtemp(path.join(os.tmpdir(), `gjc-natives-${platform}-`)); - const { stdout } = await execFile('npm', [ + const npm = npmInvocation([ 'pack', `${platformPackage}@${version}`, '--pack-destination', destination, '--silent', - ], { cwd: rootDir }); + ]); + const { stdout } = await execFile(npm.command, npm.args, { cwd: rootDir }); const tarball = stdout.trim().split('\n').pop(); if (!tarball) throw new Error(`npm pack produced no tarball for ${platformPackage}@${version}.`); await execFile('tar', ['-xzf', path.join(destination, tarball), '-C', destination]); @@ -102,10 +104,10 @@ async function platformClosure(nativesRoot, platform, foreignRoot) { const loaderFiles = (await fs.readdir(path.join(nativesRoot, 'native'))) .filter((filename) => filename.endsWith('.js')) - .map((filename) => path.join('native', filename)); + .map((filename) => path.posix.join('native', filename)); const addonFiles = (await fs.readdir(path.join(platformRoot, 'native'))) .filter((filename) => filename.endsWith('.node')) - .map((filename) => path.join('native', filename)); + .map((filename) => path.posix.join('native', filename)); if (addonFiles.length === 0) throw new Error(`${platformPackage} has no native addons.`); const files = [ diff --git a/scripts/lib/npm-cli.mjs b/scripts/lib/npm-cli.mjs new file mode 100644 index 00000000..ace0f0ec --- /dev/null +++ b/scripts/lib/npm-cli.mjs @@ -0,0 +1,23 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +// Execute npm's JS entrypoint with Node. Windows cannot execFile/spawn a +// .cmd file without cmd.exe, which also changes quoting and argument handling. +export function npmInvocation(args, { + env = process.env, + platform = process.platform, + execPath = process.execPath, + exists = existsSync, +} = {}) { + const paths = platform === 'win32' ? path.win32 : path.posix; + const candidates = [ + env.npm_execpath, + paths.join(paths.dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + paths.resolve(paths.dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ]; + const cli = candidates.find(candidate => candidate + && paths.basename(candidate) === 'npm-cli.js' && exists(candidate)); + if (cli) return { command: execPath, args: [cli, ...args] }; + if (platform !== 'win32') return { command: 'npm', args }; + throw new Error('Could not locate npm-cli.js. Install Node.js with npm, or run this command through npm run.'); +} diff --git a/scripts/lib/npm-cli.test.mjs b/scripts/lib/npm-cli.test.mjs new file mode 100644 index 00000000..2c310150 --- /dev/null +++ b/scripts/lib/npm-cli.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { npmInvocation } from './npm-cli.mjs'; + +test('Windows npm uses Node with literal paths and arguments', () => { + const cli = String.raw`C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js`; + const execPath = String.raw`C:\Program Files\nodejs\node.exe`; + const args = ['pack', '--pack-destination', String.raw`C:\Users\가재 & dev\build %temp%`]; + assert.deepEqual(npmInvocation(args, { + env: {}, platform: 'win32', execPath, exists: candidate => candidate === cli, + }), { command: execPath, args: [cli, ...args] }); +}); + +test('npm run entrypoint takes precedence over adjacent installations', () => { + const cli = String.raw`D:\tools\npm\bin\npm-cli.js`; + const invocation = npmInvocation(['audit', '--json'], { + env: { npm_execpath: cli }, platform: 'win32', + execPath: String.raw`C:\node\node.exe`, exists: () => true, + }); + assert.equal(invocation.args[0], cli); +}); + +test('missing npm on Windows gives an actionable error without invoking a shell', () => { + assert.throws(() => npmInvocation(['ci'], { + env: {}, platform: 'win32', execPath: String.raw`C:\node\node.exe`, exists: () => false, + }), /Could not locate npm-cli.js/); +}); + +test('npm invocation runs the installed CLI', () => { + const npm = npmInvocation(['--version']); + const result = spawnSync(npm.command, npm.args, { encoding: 'utf8', timeout: 30_000 }); + assert.equal(result.status, 0, result.error?.message ?? result.stderr); + assert.match(result.stdout.trim(), /^\d+\.\d+\.\d+/); +}); diff --git a/scripts/probe-windows-sdk-locks.mjs b/scripts/probe-windows-sdk-locks.mjs new file mode 100644 index 00000000..9a205ac3 --- /dev/null +++ b/scripts/probe-windows-sdk-locks.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, statfs, writeFile } from 'node:fs/promises'; +import { release, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { withFileLock } from '@gajae-code/coding-agent/config/file-lock'; +import { exactRemoveDirectoryTree, nativeBuildInfo, snapshotDirectoryTree } from '@gajae-code/natives'; + +// Run in a separate Bun process: no AgentSession, database, or broker owns these +// paths. A refusal stays fatal; neither retry it nor change the SDK's budgets. +console.log(JSON.stringify({ + probe: 'sdk-file-locks', + platform: process.platform, + release: release(), + bun: process.versions.bun, + native: nativeBuildInfo(), + image: process.env.ImageOS ?? null, +})); + +const scratch = join(process.cwd(), '.tmp'); +await mkdir(scratch, { recursive: true }); +const bases = new Set([await realpath(tmpdir()), await realpath(scratch)]); +const failures = []; + +for (const base of bases) { + const filesystem = await statfs(base); + console.log(JSON.stringify({ base, filesystem: { type: filesystem.type, bsize: filesystem.bsize } })); + for (const operation of ['native-exact-remove', 'sdk-release', 'sdk-contended-release']) { + const root = await realpath(await mkdtemp(join(base, 'gjc-native-lock-probe-'))); + console.log(JSON.stringify({ operation, root, phase: 'start' })); + try { + const file = join(root, 'config.yml'); + const lock = `${file}.lock`; + if (operation === 'native-exact-remove') { + await mkdir(lock); + await writeFile(join(lock, 'info'), JSON.stringify({ pid: process.pid, timestamp: Date.now() })); + const captured = snapshotDirectoryTree(lock); + console.log(JSON.stringify({ operation, root, capture: { ok: captured.ok, code: captured.code } })); + assert.ok(captured.ok && captured.snapshot, 'Native lock snapshot must succeed.'); + const removed = exactRemoveDirectoryTree(lock, captured.snapshot); + // Preserve NTSTATUS and retained/quarantine paths discarded by the + // SDK's higher-level EACCES exception. Do not mutate returned paths. + console.log(JSON.stringify({ operation, root, removed })); + assert.equal(removed.ok, true, 'Native lock removal must succeed without another owner.'); + } else { + let active = 0; + const values = operation === 'sdk-release' ? ['first'] : ['first', 'second']; + const outcomes = await Promise.allSettled(values.map(value => withFileLock(file, async () => { + active += 1; + try { + assert.equal(active, 1, 'File-lock callbacks must be exclusive.'); + const staging = `${file}.tmp`; + await writeFile(staging, value); + await rename(staging, file); + return value; + } finally { + active -= 1; + } + }))); + const errors = outcomes.filter(outcome => outcome.status === 'rejected').map(outcome => outcome.reason); + if (errors.length) throw new AggregateError(errors, 'Public SDK file-lock transaction failed.'); + assert.deepEqual(outcomes.map(outcome => outcome.value), values); + assert.ok(values.includes(await readFile(file, 'utf8')), 'The committed payload must remain readable.'); + } + await assert.rejects(lstat(lock), { code: 'ENOENT' }); + console.log(JSON.stringify({ operation, root, phase: 'passed' })); + } catch (error) { + failures.push(error); + console.error(JSON.stringify({ operation, root, phase: 'failed' })); + console.error(error); + } finally { + // All lock callers settled above. One removal attempt only; preserve + // cleanup errors independently instead of replacing the original refusal. + try { + await rm(root, { recursive: true, force: true }); + } catch (error) { + failures.push(error); + console.error(JSON.stringify({ operation, retainedRoot: root, phase: 'cleanup-failed' })); + console.error(error); + } + } + } +} + +if (failures.length) throw new AggregateError(failures, 'Pinned SDK filesystem conformance failed.'); diff --git a/scripts/release/build-windows-server-payload.mjs b/scripts/release/build-windows-server-payload.mjs new file mode 100644 index 00000000..2a76da5a --- /dev/null +++ b/scripts/release/build-windows-server-payload.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { BUN_VERSION, versionOf } from '../fetch-bun.mjs'; +import { downloadVerifiedArchive, extractWindowsZip } from '../runtime-archive.mjs'; + +import { describeDistributionExclusions, removeExcludedDistributionPackages } from './distribution-exclusions.mjs'; +import { smokeWindowsServer } from './smoke-windows-server.mjs'; +import { + assertWindowsHost, assertWindowsX64Executable, NODE_ARCHIVE, NODE_ARCHIVE_SHA256, NODE_VERSION, + pruneNonRuntimeMetadata, restrictRuntimeDependencies, SIDECAR_NAME, verifyManifest, verifyNode, windowsBuildEnvironment, +} from './windows-payload.mjs'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const INPUTS = [ + 'dist', 'dist-server', 'shared', 'public', 'server/gjc-runtime-manifest.json', + 'scripts/gajae-app-runtime.mjs', 'package.json', 'package-lock.json', + 'dist-native/gajae-core.exe', 'dist-native/bun.exe', 'LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.md', +]; + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, shell: false, windowsHide: true, stdio: 'inherit' }); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve() : reject(new Error(`${path.basename(command)} exited with code ${code}.`))); + }); +} + +export async function buildWindowsServerPayload() { + // This must run before inspecting or removing the shared macOS payload path. + assertWindowsHost(); + const payloadDir = path.join(rootDir, 'src-tauri', 'resources', 'server-payload'); + const sidecarPath = path.join(rootDir, 'src-tauri', 'binaries', SIDECAR_NAME); + for (const input of INPUTS) { + try { await fs.access(path.join(rootDir, input)); } + catch { throw new Error(`Missing Windows payload input ${input}. Run scripts/fetch-bun.mjs and npm run build on Windows x64 first.`); } + } + const coreSource = path.join(rootDir, 'dist-native', 'gajae-core.exe'); + const coreCargo = await fs.readFile(path.join(rootDir, 'native', 'gajae-core', 'Cargo.toml'), 'utf8'); + const coreVersion = /^version\s*=\s*"([^"]+)"/m.exec(coreCargo)?.[1]; + await assertWindowsX64Executable(coreSource); + await assertWindowsX64Executable(path.join(rootDir, 'dist-native', 'bun.exe')); + if (await versionOf(coreSource) !== `gajae-core ${coreVersion}`) throw new Error('Bundled gajae-core version mismatch.'); + if (await versionOf(path.join(rootDir, 'dist-native', 'bun.exe')) !== BUN_VERSION) throw new Error(`Bundled Bun must be ${BUN_VERSION}.`); + const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gajae-windows-node-')); + try { + await fs.rm(payloadDir, { recursive: true, force: true }); + await fs.mkdir(payloadDir, { recursive: true }); + for (const input of INPUTS) { + const destination = path.join(payloadDir, input); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.cp(path.join(rootDir, input), destination, { recursive: true }); + } + const archive = path.join(temporaryDir, NODE_ARCHIVE); + await downloadVerifiedArchive(`https://nodejs.org/dist/v${NODE_VERSION}/${NODE_ARCHIVE}`, archive, NODE_ARCHIVE_SHA256); + await extractWindowsZip(archive, temporaryDir); + const nodeDirectory = path.join(temporaryDir, `node-v${NODE_VERSION}-win-x64`); + const payloadNode = path.join(nodeDirectory, 'node.exe'); + const env = windowsBuildEnvironment(nodeDirectory); + await verifyNode(payloadNode, { env }); + const npmCli = path.join(nodeDirectory, 'node_modules', 'npm', 'bin', 'npm-cli.js'); + await restrictRuntimeDependencies(payloadDir); + for (const args of [ + ['install', '--package-lock-only', '--ignore-scripts', '--omit=dev'], + ['ci', '--omit=dev'], + ['rebuild', '--omit=dev', '--build-from-source', 'better-sqlite3', 'node-pty'], + ]) await run(payloadNode, [npmCli, ...args], { cwd: payloadDir, env }); + await verifyManifest(payloadDir); + console.log(describeDistributionExclusions(await removeExcludedDistributionPackages(fs, path, path.join(payloadDir, 'node_modules')))); + const pruned = await pruneNonRuntimeMetadata(path.join(payloadDir, 'node_modules')) + + await pruneNonRuntimeMetadata(path.join(payloadDir, 'dist-server')); + await fs.rm(path.join(payloadDir, 'package-lock.json')); + // Preserve Node's upstream license without shipping the build-only npm distribution. + await fs.copyFile(path.join(nodeDirectory, 'LICENSE'), path.join(payloadDir, 'NODE-LICENSE')); + await fs.mkdir(path.dirname(sidecarPath), { recursive: true }); + await fs.copyFile(payloadNode, sidecarPath); + await verifyNode(sidecarPath, { env }); + await smokeWindowsServer({ payloadDir, nodePath: sidecarPath }); + console.log(`Built and verified Windows x64 server payload at ${payloadDir}; sidecar ${sidecarPath}; pruned ${pruned} metadata files.`); + } catch (error) { + await fs.rm(payloadDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + await fs.rm(sidecarPath, { force: true, maxRetries: 5, retryDelay: 200 }); + throw error; + } finally { + await fs.rm(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await buildWindowsServerPayload(); diff --git a/scripts/release/probe-windows-compiler.mjs b/scripts/release/probe-windows-compiler.mjs new file mode 100644 index 00000000..b447a07e --- /dev/null +++ b/scripts/release/probe-windows-compiler.mjs @@ -0,0 +1,39 @@ +// A fast native check that needs only Node, before installing npm dependencies. +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { encodeWindowsPowerShellCommand, windowsCodeDomCompileScript } from '../../server/gjc-windows-job.ts'; + +import { assertWindowsHost, windowsSmokeEnvironment } from './windows-payload.mjs'; + +assertWindowsHost(); +const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gajae compiler probe 가재 ')); +try { + const env = windowsSmokeEnvironment(path.dirname(process.execPath), path.join(root, 'profile 사용자')); + for (const directory of new Set([ + env.HOME, env.APPDATA, env.LOCALAPPDATA, env.XDG_CONFIG_HOME, env.XDG_DATA_HOME, + env.XDG_CACHE_HOME, env.TEMP, env.WORKSPACES_ROOT, env.GJC_WORKER_AGENT_DIR, + ])) await fs.mkdir(directory, { recursive: true }); + const source = [ + "$ErrorActionPreference = 'Stop'", + "$ProgressPreference = 'SilentlyContinue'", + '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)', + windowsCodeDomCompileScript('public static class GajaeCompilerProbe { public static int Value() { return 42; } }', true), + "if ([GajaeCompilerProbe]::Value() -ne 42) { throw 'Compiled probe returned an invalid result.' }", + "[Console]::Out.WriteLine('Compiler probe passed.')", + ].join('\n'); + let failed = false; + for (const [label, environment] of [['baseline', process.env], ['isolated Unicode', env]]) { + console.log(`Windows compiler probe: ${label}`); + const result = spawnSync(path.join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodeWindowsPowerShellCommand(source), + ], { cwd: root, env: environment, windowsHide: true, stdio: 'inherit', timeout: 60_000 }); + if (result.error) console.error(result.error.message); + if (result.status !== 0) failed = true; + } + if (failed) process.exitCode = 1; +} finally { + await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); +} diff --git a/scripts/release/smoke-windows-server.mjs b/scripts/release/smoke-windows-server.mjs new file mode 100644 index 00000000..3315781a --- /dev/null +++ b/scripts/release/smoke-windows-server.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { BUN_VERSION } from '../fetch-bun.mjs'; + +import { assertOutOfTree } from './out-of-tree.mjs'; +import { assertWindowsHost, assertWindowsX64Executable, NODE_VERSION, verifyManifest, verifyWindowsSmokeEnvironment, windowsSmokeEnvironment } from './windows-payload.mjs'; + +export async function runGuardedSmoke({ nodePath, args, cwd, env, jobRuntime, timeoutMs = 120_000, stdout = process.stdout, stderr = process.stderr }) { + const { createWindowsJobLaunch, killWindowsJobGuard, GJC_WINDOWS_JOB_GUARD_READY, GJC_WINDOWS_JOB_GUARD_ACK } = jobRuntime; + const launch = createWindowsJobLaunch(nodePath, args, env, cwd); + const child = spawn(launch.command, launch.args, { + cwd, env: launch.env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + }); + let timer; + let ready = false; + let buffered = Buffer.alloc(0); + let diagnostics = ''; + let failure; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', chunk => { + diagnostics = (diagnostics + chunk).slice(-16_384); + stderr.write(chunk); + }); + try { + await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('Windows payload smoke timed out.')), timeoutMs); + child.once('error', reject); + child.stdin.on('error', reject); + child.stdout.on('data', chunk => { + if (ready) { stdout.write(chunk); return; } + buffered = Buffer.concat([buffered, chunk]); + const newline = buffered.indexOf(0x0a); + if (newline < 0 && buffered.length <= 128) return; + if (newline < 0 || newline > 128 || buffered.subarray(0, newline).toString('utf8').replace(/\r$/, '') !== GJC_WINDOWS_JOB_GUARD_READY) { + reject(new Error('Windows smoke Job guard did not acknowledge ownership.')); + return; + } + ready = true; + child.stdin.write(`${GJC_WINDOWS_JOB_GUARD_ACK}\n`); + stdout.write(buffered.subarray(newline + 1)); + buffered = Buffer.alloc(0); + }); + child.once('close', code => code === 0 && ready + ? resolve() + : reject(new Error(`Windows payload smoke failed (exit ${code}, Job guard ready=${ready}).`))); + }); + } catch (error) { + failure = new Error(`${error.message}${diagnostics.trim() ? `\nJob guard diagnostics:\n${diagnostics}` : ''}`); + } finally { + clearTimeout(timer); + // Always reap the named Job, even if its direct child has exited: an early + // checker exit must not leave a detached server, core, or Bun descendant. + try { await killWindowsJobGuard(child, launch); } + catch (error) { + // execFile errors retain the entire encoded guard command. Report the + // cleanup reason and native stderr without dumping that command or losing + // the original startup error underneath it. + const cause = error.cause; + const cleanup = new Error(`${error.message}${cause?.killed ? ' (reaper timed out)' : ''}${cause?.stderr ? `\n${String(cause.stderr).slice(-16_384)}` : ''}`); + failure = failure + ? new AggregateError([failure, cleanup], `${failure.message}\nJob cleanup also failed: ${cleanup.message}`) + : cleanup; + } + } + if (failure) throw failure; +} + +export async function smokeWindowsServer({ payloadDir, nodePath }) { + assertWindowsHost(); + if (!payloadDir || !nodePath) throw new Error('Both payloadDir and nodePath are required.'); + const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gajae-windows smoke 가재-')); + let failure; + try { + await assertOutOfTree(temporaryDir, 'Windows server smoke'); + const payloadCopy = path.join(temporaryDir, 'server payload 가재'); + const runtimeDir = path.join(temporaryDir, 'runtime space 가재'); + const stateDir = path.join(temporaryDir, 'user profile 가재'); + await fs.mkdir(runtimeDir, { recursive: true }); + await fs.mkdir(payloadCopy, { recursive: true }); + const env = windowsSmokeEnvironment(runtimeDir, stateDir); + for (const directory of [stateDir, env.APPDATA, env.LOCALAPPDATA, env.XDG_CONFIG_HOME, env.XDG_DATA_HOME, env.XDG_CACHE_HOME, env.TEMP, env.WORKSPACES_ROOT, env.GJC_WORKER_AGENT_DIR]) { + await fs.mkdir(directory, { recursive: true }); + } + const environment = await verifyWindowsSmokeEnvironment(env, payloadCopy); + console.log(`Windows smoke environment verified: ${JSON.stringify(environment)}`); + await fs.cp(path.resolve(payloadDir), payloadCopy, { recursive: true, dereference: false, verbatimSymlinks: true }); + const nodeCopy = path.join(runtimeDir, 'gajae-app-server.exe'); + await fs.copyFile(path.resolve(nodePath), nodeCopy); + await assertWindowsX64Executable(nodeCopy); + for (const binary of ['bun.exe', 'gajae-core.exe']) await assertWindowsX64Executable(path.join(payloadCopy, 'dist-native', binary)); + await verifyManifest(payloadCopy); + const checks = path.join(payloadCopy, '.gajae-windows-smoke.mjs'); + await fs.copyFile(fileURLToPath(new URL('./windows-server-smoke-checks.mjs', import.meta.url)), checks); + await fs.copyFile(fileURLToPath(new URL('../../src-tauri/src/windows-server-bootstrap.cjs', import.meta.url)), + path.join(payloadCopy, '.gajae-windows-server-bootstrap.cjs')); + console.log(`Smoking Windows payload outside the checkout at ${payloadCopy}.`); + const jobRuntime = await import(pathToFileURL(path.join(payloadCopy, 'dist-server', 'server', 'gjc-windows-job.js')).href); + await runGuardedSmoke({ + nodePath: nodeCopy, args: [checks, NODE_VERSION, BUN_VERSION], cwd: payloadCopy, env, jobRuntime, + }); + } catch (error) { + failure = error; + } finally { + try { await fs.rm(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } + catch (error) { + failure = failure + ? new AggregateError([failure, error], `${failure.message}\nSmoke directory cleanup also failed: ${error.message}`) + : error; + } + } + if (failure) throw failure; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { payload: { type: 'string' }, node: { type: 'string' } } }); + if (!values.payload || !values.node) throw new Error('Usage: node scripts/release/smoke-windows-server.mjs --payload --node '); + await smokeWindowsServer({ payloadDir: path.resolve(values.payload), nodePath: path.resolve(values.node) }); +} diff --git a/scripts/release/windows-payload.mjs b/scripts/release/windows-payload.mjs new file mode 100644 index 00000000..9b991a9d --- /dev/null +++ b/scripts/release/windows-payload.mjs @@ -0,0 +1,216 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { BUN_VERSION } from '../fetch-bun.mjs'; +import { sha256 } from '../runtime-archive.mjs'; + +export const NODE_VERSION = '22.22.2'; +// https://nodejs.org/dist/v22.22.2/SHASUMS256.txt +export const NODE_ARCHIVE_SHA256 = '7c93e9d92bf68c07182b471aa187e35ee6cd08ef0f24ab060dfff605fcc1c57c'; +export const NODE_ARCHIVE = `node-v${NODE_VERSION}-win-x64.zip`; +export const SIDECAR_NAME = 'gajae-app-server-x86_64-pc-windows-msvc.exe'; +export const RUNTIME_DEPENDENCIES = [ + '@gajae-code/coding-agent', '@puppeteer/browsers', '@octokit/rest', '@vscode/ripgrep', + 'better-sqlite3', 'cors', 'cross-spawn', 'express', 'gray-matter', 'mime-types', + 'multer', 'node-pty', 'puppeteer-core', 'shell-quote', 'ws', 'zod', +]; + +export function assertWindowsHost(platform = process.platform, arch = process.arch) { + if (platform !== 'win32' || arch !== 'x64') { + throw new Error(`Windows payload requires win32-x64; received ${platform}-${arch}.`); + } +} + +/** Keep the lockfile's exact runtime versions, including transitive runtime imports. */ +export async function restrictRuntimeDependencies(payloadDir) { + const manifestPath = path.join(payloadDir, 'package.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + const lock = JSON.parse(await fs.readFile(path.join(payloadDir, 'package-lock.json'), 'utf8')); + const dependencies = {}; + for (const name of RUNTIME_DEPENDENCIES) { + const version = lock.packages?.[`node_modules/${name}`]?.version; + if (!version) throw new Error(`Runtime dependency is missing from package-lock.json: ${name}`); + dependencies[name] = version; + } + manifest.dependencies = dependencies; + delete manifest.devDependencies; + delete manifest.optionalDependencies; + manifest.scripts = {}; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +export async function pruneNonRuntimeMetadata(directory) { + let removed = 0; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) removed += await pruneNonRuntimeMetadata(target); + else if (entry.isFile() && /(?:\.map|\.d\.(?:c|m)?ts)$/.test(entry.name)) { + await fs.rm(target); + removed += 1; + } + } + return removed; +} + +export async function assertWindowsX64Executable(filePath) { + const handle = await fs.open(filePath, 'r'); + try { + const dos = Buffer.alloc(64); + const { bytesRead } = await handle.read(dos, 0, dos.length, 0); + if (bytesRead !== dos.length || dos.toString('ascii', 0, 2) !== 'MZ') throw new Error('missing DOS header'); + const offset = dos.readUInt32LE(60); + const pe = Buffer.alloc(6); + if (offset < 64 || (await handle.read(pe, 0, pe.length, offset)).bytesRead !== pe.length + || pe.readUInt32LE(0) !== 0x00004550 || pe.readUInt16LE(4) !== 0x8664) { + throw new Error('missing x64 PE header'); + } + } catch (error) { + throw new Error(`Expected a Windows x64 executable at ${filePath}: ${error.message}`); + } finally { + await handle.close(); + } +} + +export async function verifyManifest(payloadDir) { + const manifest = JSON.parse(await fs.readFile(path.join(payloadDir, 'server', 'gjc-runtime-manifest.json'), 'utf8')); + const compiled = JSON.parse(await fs.readFile(path.join(payloadDir, 'dist-server', 'server', 'gjc-runtime-manifest.json'), 'utf8')); + if (JSON.stringify(manifest) !== JSON.stringify(compiled)) throw new Error('Compiled runtime manifest is stale; run npm run build.'); + const files = manifest.platforms?.['win32-x64']?.files; + if (manifest.bun !== BUN_VERSION || !Array.isArray(files) || !files.some(entry => entry.path?.endsWith('.node'))) { + throw new Error('gjc-runtime-manifest is missing the pinned win32-x64 native closure.'); + } + const versions = { + '@gajae-code/coding-agent': manifest.gjcSdk, + '@gajae-code/natives': manifest.natives, + '@gajae-code/natives-win32-x64': manifest.natives, + }; + for (const [name, expected] of Object.entries(versions)) { + const installed = JSON.parse(await fs.readFile(path.join(payloadDir, 'node_modules', name, 'package.json'), 'utf8')); + if (!expected || installed.name !== name || installed.version !== expected) throw new Error(`Runtime package version mismatch: ${name}`); + } + for (const entry of files) { + if (!['@gajae-code/natives', '@gajae-code/natives-win32-x64'].includes(entry.package) + || typeof entry.path !== 'string' || !entry.path.startsWith('native/') + || entry.path.includes('\\') || entry.path.split('/').some(part => !part || part === '.' || part === '..') + || !/^[a-f0-9]{64}$/.test(entry.sha256)) throw new Error('Invalid native manifest entry.'); + const filePath = path.join(payloadDir, 'node_modules', entry.package, entry.path); + if (await sha256(filePath) !== entry.sha256) throw new Error(`Manifest hash mismatch: ${entry.package}/${entry.path}`); + if (entry.path.endsWith('.node')) await assertWindowsX64Executable(filePath); + } +} + +export async function verifyNode(binary, options = {}) { + await assertWindowsX64Executable(binary); + const { stdout } = await promisify(execFile)(binary, ['-p', 'JSON.stringify([process.platform, process.arch, process.version])'], { + ...options, shell: false, windowsHide: true, timeout: 15_000, + }); + if (stdout.trim() !== JSON.stringify(['win32', 'x64', `v${NODE_VERSION}`])) throw new Error('Pinned Windows Node runtime verification failed.'); +} + +/** Windows environment keys are case-insensitive; never retain both PATH and Path. */ +export function windowsBuildEnvironment(nodeDirectory, inherited = process.env) { + const env = { ...inherited }; + const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path'); + const previous = pathKey ? env[pathKey] : ''; + for (const key of Object.keys(env)) { + if (['path', 'node_path', 'node_options'].includes(key.toLowerCase())) delete env[key]; + } + return { ...env, PATH: [nodeDirectory, previous].filter(Boolean).join(';'), npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false' }; +} + +export function windowsSmokeEnvironment(nodeDirectory, stateDir, inherited = process.env) { + const env = {}; + // Keep Windows/.NET installation and account metadata needed by OS tools. + // User homes, module search paths, caches and credentials stay isolated below. + for (const name of [ + 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', 'SystemDrive', 'OS', + 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'PROCESSOR_LEVEL', 'PROCESSOR_REVISION', 'NUMBER_OF_PROCESSORS', + 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'CommonProgramFiles', 'CommonProgramFiles(x86)', 'CommonProgramW6432', + 'ProgramData', 'ALLUSERSPROFILE', 'COMPUTERNAME', 'USERNAME', 'USERDOMAIN', + ]) { + const key = Object.keys(inherited).find(key => key.toLowerCase() === name.toLowerCase()); + if (key) env[name] = inherited[key]; + } + const systemRoot = env.SystemRoot || env.WINDIR || 'C:\\Windows'; + const powershellDirectory = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0'); + return { + ...env, + SystemRoot: systemRoot, + WINDIR: systemRoot, + ComSpec: env.ComSpec || path.win32.join(systemRoot, 'System32', 'cmd.exe'), + SystemDrive: env.SystemDrive || path.win32.parse(systemRoot).root.replace(/\\$/, ''), + PATHEXT: env.PATHEXT || '.COM;.EXE;.BAT;.CMD', + PATH: [nodeDirectory, path.win32.join(systemRoot, 'System32'), systemRoot, powershellDirectory].join(';'), + PSModulePath: [ + ...(env.ProgramFiles ? [path.win32.join(env.ProgramFiles, 'WindowsPowerShell', 'Modules')] : []), + path.win32.join(powershellDirectory, 'Modules'), + ].join(';'), + HOME: stateDir, USERPROFILE: stateDir, + HOMEDRIVE: path.win32.parse(stateDir).root.replace(/\\$/, ''), + HOMEPATH: stateDir.slice(path.win32.parse(stateDir).root.length - 1), + APPDATA: path.join(stateDir, 'AppData', 'Roaming'), LOCALAPPDATA: path.join(stateDir, 'AppData', 'Local'), + XDG_CONFIG_HOME: path.join(stateDir, 'config'), XDG_DATA_HOME: path.join(stateDir, 'data'), XDG_CACHE_HOME: path.join(stateDir, 'cache'), + TEMP: path.join(stateDir, 'tmp'), TMP: path.join(stateDir, 'tmp'), + DATABASE_PATH: path.join(stateDir, 'auth.db'), GJC_WORKER_AGENT_DIR: path.join(stateDir, 'agent'), + WORKSPACES_ROOT: path.join(stateDir, 'workspaces'), HOST: '127.0.0.1', NODE_ENV: 'production', + }; +} + +/** Test Windows PowerShell's actual .NET compiler without a built server payload. */ +export async function verifyWindowsSmokeEnvironment(env, cwd, { execute = promisify(execFile) } = {}) { + // Packaging runs after npm ci, before a compiled server is required. Load the + // source-only compiler helper through the existing build-time tsx runtime so + // this preflight exercises exactly the code shipped by the production guard. + const { tsImport } = await import('tsx/esm/api'); + const { windowsCodeDomCompileScript, encodeWindowsPowerShellCommand } = await tsImport(new URL('../../server/gjc-windows-job.ts', import.meta.url).href, import.meta.url); + const powershell = path.win32.join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + const source = String.raw` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +[Console]::Error.WriteLine('Checking isolated Windows PowerShell/.NET compiler environment.') +try { + $runtime = [System.Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory() + $temporary = [System.IO.Path]::GetTempPath() + $compiler = [System.IO.Path]::Combine($runtime, 'csc.exe') + $details = [ordered]@{ + powershell = $PSVersionTable.PSVersion.ToString() + clr = [Environment]::Version.ToString() + runtime = $runtime + compiler = $compiler + compilerExists = [System.IO.File]::Exists($compiler) + cwd = [Environment]::CurrentDirectory + temp = $temporary + tempExists = [System.IO.Directory]::Exists($temporary) + userProfile = $env:USERPROFILE + systemRoot = $env:SystemRoot + } + [Console]::Out.WriteLine(($details | ConvertTo-Json -Compress)) + if (!$details.compilerExists) { throw 'The Windows .NET Framework csc.exe compiler is missing.' } + if (!$details.tempExists) { throw 'The isolated .NET temporary directory does not exist.' } + $probe = [System.IO.Path]::Combine($temporary, ('gajae-' + [Guid]::NewGuid().ToString() + '.tmp')) + [System.IO.File]::WriteAllText($probe, 'isolated-temp-writable') + [System.IO.File]::Delete($probe) + ${windowsCodeDomCompileScript('public static class GajaeSmokeEnvironmentProbe { public static int Value() { return 42; } }', true)} + [Console]::Out.WriteLine(('{"compiled":' + [GajaeSmokeEnvironmentProbe]::Value() + '}')) +} catch { + [Console]::Error.WriteLine($_.Exception.ToString()) + exit 1 +} +`.trim(); + try { + const encoded = encodeWindowsPowerShellCommand(source); + const { stdout } = await execute(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { + cwd, env, windowsHide: true, shell: false, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024, + }); + const records = stdout.trim().split(/\r?\n/).map(line => JSON.parse(line)); + if (records.at(-1)?.compiled !== 42) throw new Error('Add-Type did not return its compiled result.'); + return Object.assign({}, ...records.slice(0, -1)); + } catch (error) { + // Do not echo execFile's command field (production guards use huge encoded + // commands). The bounded stdout/stderr contain the useful native evidence. + throw new Error(`Isolated Windows Add-Type preflight failed (exit ${error.code ?? 'unknown'}${error.killed ? ', timed out' : ''}).\n${String(error.stdout ?? '').slice(-16_384)}\n${String(error.stderr ?? (error.cmd ? '' : error.message)).slice(-16_384)}`); + } +} diff --git a/scripts/release/windows-payload.test.mjs b/scripts/release/windows-payload.test.mjs new file mode 100644 index 00000000..af2536a5 --- /dev/null +++ b/scripts/release/windows-payload.test.mjs @@ -0,0 +1,355 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { buildWindowsServerPayload } from './build-windows-server-payload.mjs'; +import { removeExcludedDistributionPackages } from './distribution-exclusions.mjs'; +import { runGuardedSmoke } from './smoke-windows-server.mjs'; +import { + assertWindowsHost, assertWindowsX64Executable, pruneNonRuntimeMetadata, + restrictRuntimeDependencies, verifyManifest, windowsBuildEnvironment, windowsSmokeEnvironment, +} from './windows-payload.mjs'; +import { assertRuntimeCatalog, serverSmoke, stopProcessTree, stopServerGracefully, workerHandshake } from './windows-server-smoke-checks.mjs'; + +async function fixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'windows payload 가재-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} + +function pe(machine = 0x8664) { + const buffer = Buffer.alloc(134); + buffer.write('MZ'); + buffer.writeUInt32LE(128, 60); + buffer.writeUInt32LE(0x00004550, 128); + buffer.writeUInt16LE(machine, 132); + return buffer; +} + +test('Windows builder rejects other hosts before touching payload outputs', async () => { + assert.doesNotThrow(() => assertWindowsHost('win32', 'x64')); + for (const [platform, arch] of [['linux', 'x64'], ['darwin', 'arm64'], ['win32', 'arm64'], ['win32', 'ia32']]) { + assert.throws(() => assertWindowsHost(platform, arch), /requires win32-x64/); + } + if (process.platform !== 'win32') await assert.rejects(buildWindowsServerPayload(), /requires win32-x64/); +}); + +test('runtime package restriction retains only the macOS runtime closure at locked versions', async t => { + const root = await fixture(t); + // Use the repository lock as the integration fixture: this catches omitted + // transitive imports such as shell-quote as well as upstream dependency drift. + const lock = JSON.parse(await fs.readFile(new URL('../../package-lock.json', import.meta.url), 'utf8')); + const source = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf8')); + source.optionalDependencies = { 'not-a-runtime': '1.0.0' }; + await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(source)); + await fs.writeFile(path.join(root, 'package-lock.json'), JSON.stringify(lock)); + await restrictRuntimeDependencies(root); + const result = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); + assert.equal(result.dependencies['@gajae-code/coding-agent'], lock.packages['node_modules/@gajae-code/coding-agent'].version); + assert.equal(result.dependencies['shell-quote'], lock.packages['node_modules/shell-quote'].version); + for (const excluded of ['react', 'vite', 'typescript', '@tauri-apps/cli']) assert.equal(result.dependencies[excluded], undefined); + assert.equal(result.devDependencies, undefined); + assert.equal(result.optionalDependencies, undefined); + assert.deepEqual(result.scripts, {}); + for (const [name, version] of Object.entries(result.dependencies)) assert.equal(version, lock.packages[`node_modules/${name}`].version); + delete lock.packages['node_modules/shell-quote']; + await fs.writeFile(path.join(root, 'package-lock.json'), JSON.stringify(lock)); + await assert.rejects(restrictRuntimeDependencies(root), /shell-quote/); +}); + +test('PE verification rejects Linux, ARM64 and truncated inputs before process launch', async t => { + const root = await fixture(t); + const binary = path.join(root, 'runtime.exe'); + await fs.writeFile(binary, pe()); + await assertWindowsX64Executable(binary); + for (const invalid of [Buffer.from('\u007fELF'), pe(0xaa64), pe().subarray(0, 130)]) { + await fs.writeFile(binary, invalid); + await assert.rejects(assertWindowsX64Executable(binary), /Expected a Windows x64 executable/); + } +}); + +test('smoke environment discards developer identity, runtime overrides and global module paths', () => { + const env = windowsSmokeEnvironment(String.raw`C:\runtime space 가재`, String.raw`C:\isolated user 가재`, { + SystemRoot: String.raw`C:\Windows`, Path: 'C:\\global-node', PATH: 'C:\\another-node', + HOME: 'C:\\real-user', USERPROFILE: 'C:\\real-user', APPDATA: 'C:\\real-appdata', + NODE_PATH: 'C:\\repo\\node_modules', NODE_OPTIONS: '--require C:\\injection.cjs', + GJC_RUNTIME_MANIFEST_PATH: 'C:\\wrong.json', GJC_ALLOW_RUNTIME_MANIFEST_OVERRIDE: '1', + DATABASE_PATH: 'C:\\real.db', GJC_BUN_PATH: 'C:\\global\\bun.exe', ANTHROPIC_API_KEY: 'must-not-inherit', + }); + assert.equal(env.Path, undefined); + assert.equal(env.NODE_PATH, undefined); + assert.equal(env.NODE_OPTIONS, undefined); + assert.equal(env.ANTHROPIC_API_KEY, undefined); + assert.equal(env.GJC_ALLOW_RUNTIME_MANIFEST_OVERRIDE, undefined); + assert.equal(env.GJC_BUN_PATH, undefined); + assert.equal(env.HOME, env.USERPROFILE); + assert.ok(env.PATH.startsWith('C:\\runtime space 가재;')); + for (const key of ['APPDATA', 'LOCALAPPDATA', 'DATABASE_PATH', 'WORKSPACES_ROOT', 'TEMP', 'GJC_WORKER_AGENT_DIR']) { + assert.ok(env[key].startsWith(env.USERPROFILE), `${key} must be isolated`); + } + const build = windowsBuildEnvironment('C:\\pinned node', { Path: 'C:\\toolchain', NODE_OPTIONS: '--require bad', NODE_PATH: 'bad' }); + assert.equal(build.PATH, 'C:\\pinned node;C:\\toolchain'); + assert.equal(build.Path, undefined); + assert.equal(build.NODE_OPTIONS, undefined); +}); + +async function manifestFixture(root) { + const binary = pe(); + const manifest = { schemaVersion: 1, bun: '1.4.0', gjcSdk: '0.15.6', natives: '0.15.6', platforms: { + 'win32-x64': { files: [{ package: '@gajae-code/natives-win32-x64', path: 'native/addon.node', sha256: createHash('sha256').update(binary).digest('hex') }] }, + } }; + for (const name of ['@gajae-code/coding-agent', '@gajae-code/natives', '@gajae-code/natives-win32-x64']) { + const packageDir = path.join(root, 'node_modules', name); + await fs.mkdir(path.join(packageDir, 'native'), { recursive: true }); + await fs.writeFile(path.join(packageDir, 'package.json'), JSON.stringify({ name, version: '0.15.6' })); + } + await fs.writeFile(path.join(root, 'node_modules/@gajae-code/natives-win32-x64/native/addon.node'), binary); + const write = async () => { + for (const dir of ['server', 'dist-server/server']) { + await fs.mkdir(path.join(root, dir), { recursive: true }); + await fs.writeFile(path.join(root, dir, 'gjc-runtime-manifest.json'), JSON.stringify(manifest)); + } + }; + await write(); + return { manifest, write }; +} + +test('manifest verification detects absent Windows closure, stale compiled manifests and tampered binaries', async t => { + const root = await fixture(t); + const { manifest, write } = await manifestFixture(root); + await verifyManifest(root); + await fs.writeFile(path.join(root, 'dist-server/server/gjc-runtime-manifest.json'), '{}'); + await assert.rejects(verifyManifest(root), /Compiled runtime manifest is stale/); + await write(); + const file = manifest.platforms['win32-x64'].files[0]; + const original = file.sha256; + file.sha256 = '0'.repeat(64); + await write(); + await assert.rejects(verifyManifest(root), /Manifest hash mismatch/); + file.sha256 = original; + file.path = 'native/../../escape.node'; + await write(); + await assert.rejects(verifyManifest(root), /Invalid native manifest entry/); + manifest.platforms = {}; + await write(); + await assert.rejects(verifyManifest(root), /win32-x64 native closure/); +}); + +test('production pruning keeps runtime TypeScript, DLLs, Unicode names and distribution stubs', async t => { + const root = await fixture(t); + const modules = path.join(root, 'node_modules'); + for (const name of ['elkjs', 'mupdf', 'example']) { + await fs.mkdir(path.join(modules, name), { recursive: true }); + await fs.writeFile(path.join(modules, name, 'package.json'), JSON.stringify({ name, version: '1.2.3' })); + } + for (const name of ['runtime.ts', 'types.d.ts', 'module.d.mts', 'module.js.map', 'conpty.dll', '가재.js']) { + await fs.writeFile(path.join(modules, 'example', name), 'fixture'); + } + const exclusions = await removeExcludedDistributionPackages(fs, path, modules); + assert.ok(exclusions.stubbed.includes('elkjs')); + await assert.rejects(fs.access(path.join(modules, 'mupdf')), { code: 'ENOENT' }); + const stub = JSON.parse(await fs.readFile(path.join(modules, 'elkjs', 'package.json'), 'utf8')); + assert.equal(stub.license, 'MIT'); + assert.equal(stub.version, '1.2.3'); + assert.equal(await pruneNonRuntimeMetadata(modules), 3); + assert.deepEqual((await fs.readdir(path.join(modules, 'example'))).sort(), ['conpty.dll', 'package.json', 'runtime.ts', '가재.js'].sort()); +}); + +test('Bun worker smoke handles chunked protocol output and demands acknowledged shutdown', async t => { + const root = await fixture(t); + const worker = path.join(root, 'fake worker 가재.mjs'); + await fs.writeFile(worker, ` + import readline from 'node:readline'; + const lines = readline.createInterface({ input: process.stdin }); + lines.on('line', line => { + const request = JSON.parse(line); + const response = JSON.stringify({ ...request, kind: 'response', payload: { ok: true } }) + '\\n'; + process.stdout.write(response.slice(0, 7)); + process.stdout.write(response.slice(7)); + if (request.method === 'worker.shutdown') lines.close(); + }); + `); + await workerHandshake(process.execPath, worker, { timeout: 5_000 }); + await fs.writeFile(worker, 'process.exit(0);'); + await assert.rejects(workerHandshake(process.execPath, worker, { timeout: 5_000 }), /handshake failed/); + await fs.writeFile(worker, 'process.stdout.write("not JSON\\n"); setInterval(() => {}, 1000);'); + await assert.rejects(workerHandshake(process.execPath, worker, { timeout: 5_000 }), /JSON/); + await fs.writeFile(worker, 'setInterval(() => {}, 1000);'); + await assert.rejects(workerHandshake(process.execPath, worker, { timeout: 100 }), /timed out/); +}); + +test('successful worker initialization and shutdown tolerate SDK stderr diagnostics', async t => { + const root = await fixture(t); + const worker = path.join(root, 'diagnostic worker.mjs'); + await fs.writeFile(worker, ` + import readline from 'node:readline'; + const lines = readline.createInterface({ input: process.stdin }); + lines.on('line', line => { + const request = JSON.parse(line); + process.stderr.write('SDK diagnostic: no credentials configured\\n'); + process.stdout.write(JSON.stringify({ ...request, kind: 'response', payload: { ok: true } }) + '\\n'); + if (request.method === 'worker.shutdown') lines.close(); + }); + `); + await workerHandshake(process.execPath, worker, { timeout: 5_000 }); + await fs.writeFile(worker, 'process.stderr.write("SDK diagnostic before failure\\n"); process.exit(1);'); + await assert.rejects(workerHandshake(process.execPath, worker, { timeout: 5_000 }), /SDK diagnostic before failure/); +}); + +test('catalog smoke accepts empty runtime availability but rejects preset-only and cached responses', () => { + const catalog = { success: true, data: { provider: 'gjc', models: { OPTIONS: [], MODELS: [] }, cache: { source: 'fresh' } } }; + assert.doesNotThrow(() => assertRuntimeCatalog(catalog)); + delete catalog.data.models.MODELS; + assert.throws(() => assertRuntimeCatalog(catalog), /preset-only fallback/); + catalog.data.models = Object.assign(Object.create({ MODELS: [] }), { OPTIONS: [] }); + assert.throws(() => assertRuntimeCatalog(catalog), /preset-only fallback/); + catalog.data.models.MODELS = []; + catalog.data.cache.source = 'disk'; + assert.throws(() => assertRuntimeCatalog(catalog), /bypass disk and memory caches/); +}); + +test('server smoke uses the production bootstrap, authenticated catalog and graceful stdin shutdown', async t => { + const root = await fixture(t); + await fs.mkdir(path.join(root, 'dist-server', 'server'), { recursive: true }); + await fs.writeFile(path.join(root, 'package.json'), JSON.stringify({ type: 'module' })); + await fs.copyFile(new URL('../../src-tauri/src/windows-server-bootstrap.cjs', import.meta.url), path.join(root, '.gajae-windows-server-bootstrap.cjs')); + await fs.writeFile(path.join(root, 'catalog.json'), JSON.stringify({ OPTIONS: [], MODELS: [] })); + await fs.writeFile(path.join(root, 'dist-server', 'server', 'index.js'), ` + import assert from 'node:assert/strict'; + import http from 'node:http'; + import fs from 'node:fs'; + import os from 'node:os'; + import path from 'node:path'; + assert.equal(process.execArgv[0], '--eval'); + assert.equal(os.homedir(), process.env.HOME); + assert.equal(process.env.ANTHROPIC_API_KEY, undefined); + assert.equal(process.env.OPENAI_API_KEY, undefined); + const port = Number(process.env.SERVER_PORT); + let used = false; + let catalogRequested = false; + const app = http.createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1:' + port); + const json = (body, status = 200) => { response.writeHead(status, { 'content-type': 'application/json' }); response.end(JSON.stringify(body)); }; + if (url.pathname === '/health') return json({ status: 'ok', product: 'gajae-app', protocolVersion: 1, version: 'smoke-version' }); + if (url.pathname === '/desktop/bootstrap') { + if (used || url.searchParams.get('nonce') !== process.env.GJC_DESKTOP_BOOTSTRAP_NONCE) return json({}, 401); + used = true; + response.writeHead(303, { location: '/', 'set-cookie': 'gajae_desktop_api_key=' + process.env.GJC_DESKTOP_API_KEY + '; HttpOnly' }); + response.end(); return; + } + if (request.headers.cookie !== 'gajae_desktop_api_key=' + process.env.GJC_DESKTOP_API_KEY) return json({}, 401); + if (url.pathname === '/') { response.end('fixture'); return; } + if (url.pathname === '/api/projects') return json([]); + if (url.pathname === '/api/providers/gjc/models' && url.searchParams.get('bypassCache') === 'true') { + assert.equal(request.method, 'GET'); + assert.equal(request.headers.origin, 'http://127.0.0.1:' + port); + catalogRequested = true; + // A real cold SDK takes longer than the health request's two seconds. + // This catches regressions that overwrite the catalog-specific signal. + setTimeout(() => json({ success: true, data: { provider: 'gjc', models: JSON.parse(fs.readFileSync('catalog.json', 'utf8')), cache: { source: 'fresh' } } }), 2100); + return; + } + json({}, 404); + }); + process.on('SIGTERM', () => { + fs.writeFileSync(path.join(process.env.HOME, 'shutdown.json'), JSON.stringify({ catalogRequested })); + app.close(() => process.exit(0)); + app.closeAllConnections(); + }); + fs.writeFileSync(process.env.DATABASE_PATH, 'isolated fixture database'); + app.listen(port, '127.0.0.1', () => console.log(JSON.stringify({ kind: 'gajae-desktop-ready', pid: process.pid, host: '127.0.0.1', port, protocolVersion: 1, version: 'smoke-version' }))); + `); + const env = windowsSmokeEnvironment(path.dirname(process.execPath), root); + await serverSmoke(root, 'smoke-version', { env, shutdownTimeoutMs: 5_000 }); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, 'shutdown.json'), 'utf8')), { catalogRequested: true }); + await fs.rm(path.join(root, 'shutdown.json')); + await fs.writeFile(path.join(root, 'catalog.json'), JSON.stringify({ OPTIONS: [] })); + await assert.rejects(serverSmoke(root, 'smoke-version', { env, shutdownTimeoutMs: 5_000 }), /preset-only fallback/); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, 'shutdown.json'), 'utf8')), { catalogRequested: true }); +}); + +test('unresponsive stdin shutdown fails within its bound and forcibly reaps the server', async t => { + const root = await fixture(t); + const child = spawn(process.execPath, ['-e', 'process.stdin.resume(); console.log("ready"); setInterval(() => {}, 1000);'], { + env: windowsSmokeEnvironment(path.dirname(process.execPath), root), stdio: ['pipe', 'pipe', 'pipe'], + }); + t.after(() => stopProcessTree(child)); + child.stdin.on('error', () => {}); + await once(child.stdout, 'data'); + await assert.rejects(stopServerGracefully(child, { timeoutMs: 100 }), /graceful shutdown timed out/); + assert.ok(child.exitCode !== null || child.signalCode !== null); +}); + +test('outer smoke reaps its named Job after success, failure, invalid prelude and timeout', async t => { + const root = await fixture(t); + const guard = path.join(root, 'fake guard.mjs'); + await fs.writeFile(guard, ` + import readline from 'node:readline'; + const mode = process.argv[2]; + console.log(mode === 'invalid' ? 'bad prelude' : 'fixture-ready'); + const lines = readline.createInterface({ input: process.stdin }); + lines.on('line', line => { + if (line !== 'fixture-ack') process.exit(3); + if (mode !== 'timeout') process.stdout.write('checks ran\\n', () => process.exit(mode === 'failure' ? 7 : 0)); + }); + `); + for (const mode of ['success', 'failure', 'invalid', 'timeout']) { + const reaped = []; + let output = ''; + const jobRuntime = { + GJC_WINDOWS_JOB_GUARD_READY: 'fixture-ready', GJC_WINDOWS_JOB_GUARD_ACK: 'fixture-ack', + createWindowsJobLaunch: (_node, _args, env) => ({ command: process.execPath, args: [guard, mode], env, jobName: 'fixture-job' }), + killWindowsJobGuard: async (child, launch) => { + reaped.push({ exitCode: child.exitCode, jobName: launch.jobName }); + await stopProcessTree(child); + }, + }; + const running = runGuardedSmoke({ nodePath: process.execPath, args: [], cwd: root, + env: windowsSmokeEnvironment(path.dirname(process.execPath), root), jobRuntime, + timeoutMs: mode === 'timeout' ? 100 : 5_000, stdout: { write: chunk => { output += chunk.toString(); } }, + }); + if (mode === 'success') { + await running; + assert.match(output, /checks ran/); + assert.equal(reaped[0].exitCode, 0); + } else await assert.rejects(running, /failed|ownership|timed out/); + assert.equal(reaped.length, 1); + assert.equal(reaped[0].jobName, 'fixture-job'); + } +}); + +test('a failing reaper preserves Add-Type startup diagnostics without dumping encoded commands', async t => { + const root = await fixture(t); + const guard = path.join(root, 'failed guard.mjs'); + await fs.writeFile(guard, 'process.stderr.write("Add-Type: invalid Unicode compiler path\\n", () => process.exit(1));'); + let reaped = false; + const jobRuntime = { + GJC_WINDOWS_JOB_GUARD_READY: 'fixture-ready', GJC_WINDOWS_JOB_GUARD_ACK: 'fixture-ack', + createWindowsJobLaunch: (_node, _args, env) => ({ command: process.execPath, args: [guard], env, jobName: 'fixture-job' }), + killWindowsJobGuard: async () => { + reaped = true; + throw new Error('Windows job termination could not be verified.', { cause: Object.assign( + new Error('huge-encoded-command-must-not-appear'), { killed: true, stderr: 'reaper diagnostic', cmd: 'huge-encoded-command-must-not-appear' }, + ) }); + }, + }; + await assert.rejects(runGuardedSmoke({ nodePath: process.execPath, args: [], cwd: root, + env: windowsSmokeEnvironment(path.dirname(process.execPath), root), jobRuntime, + stdout: { write() {} }, stderr: { write() {} }, + }), error => { + assert.ok(error instanceof AggregateError); + assert.equal(error.errors.length, 2); + assert.match(error.message, /Add-Type: invalid Unicode compiler path/); + assert.match(error.message, /reaper timed out/); + assert.match(error.message, /reaper diagnostic/); + assert.ok(!error.message.includes('huge-encoded-command-must-not-appear')); + assert.ok(error.errors.every(entry => entry.cause === undefined)); + return true; + }); + assert.equal(reaped, true); +}); diff --git a/scripts/release/windows-server-smoke-checks.mjs b/scripts/release/windows-server-smoke-checks.mjs new file mode 100644 index 00000000..1115bfd7 --- /dev/null +++ b/scripts/release/windows-server-smoke-checks.mjs @@ -0,0 +1,286 @@ +// Copied into the isolated payload by smoke-windows-server.mjs. Built-ins only: +// imports here must never pull a dependency from the repository running CI. +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +export async function stopProcessTree(child) { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + const closed = new Promise(resolve => child.once('close', resolve)); + if (process.platform === 'win32') { + const taskkill = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'); + try { + await execute(taskkill, ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10_000 }); + } catch (error) { + if (child.exitCode === null && child.signalCode === null) throw error; + } + } else child.kill('SIGKILL'); + let timer; + try { + await Promise.race([closed, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Smoke child did not stop.')), 10_000); + })]); + } finally { clearTimeout(timer); } +} + +export async function workerHandshake(binary, entrypoint, { env = process.env, timeout = 30_000 } = {}) { + const worker = spawn(binary, [entrypoint], { env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let timer; + let buffered = ''; + let stderr = ''; + let initialized = false; + let shutdown = false; + try { + await new Promise((resolve, reject) => { + const send = (id, method) => worker.stdin.write(JSON.stringify({ protocolVersion: 1, kind: 'request', id, method, payload: {} }) + '\n'); + timer = setTimeout(() => reject(new Error(`Bun worker timed out: ${stderr}${buffered}`)), timeout); + worker.once('error', reject); + worker.stdin.once('error', reject); + worker.stdout.setEncoding('utf8'); + worker.stderr.setEncoding('utf8'); + worker.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-32_768); }); + worker.stdout.on('data', chunk => { + buffered += chunk; + if (buffered.length > 1_048_576) { reject(new Error('Bun worker frame too large.')); return; } + const lines = buffered.split('\n'); + buffered = lines.pop(); + try { + for (const line of lines) { + if (!line.trim()) continue; + const frame = JSON.parse(line); + assert.equal(frame.protocolVersion, 1, 'Bun worker protocol mismatch'); + if (frame.kind === 'event') continue; + assert.equal(frame.kind, 'response', 'Bun worker response kind mismatch'); + assert.equal(frame.payload?.ok, true, `Bun worker rejected ${frame.method}: ${JSON.stringify(frame.payload)}`); + if (frame.id === 'init' && frame.method === 'worker.initialize' && !initialized) { + initialized = true; + send('shutdown', 'worker.shutdown'); + worker.stdin.end(); + } else if (frame.id === 'shutdown' && frame.method === 'worker.shutdown' && initialized) shutdown = true; + else throw new Error('Unexpected Bun worker response.'); + } + } catch (error) { reject(error); } + }); + worker.once('close', code => { + if (code === 0 && initialized && shutdown && !buffered.trim()) resolve(); + else reject(new Error(`Bun worker handshake failed (exit ${code}, init=${initialized}, shutdown=${shutdown}): ${stderr}${buffered}`)); + }); + worker.once('spawn', () => send('init', 'worker.initialize')); + }); + } catch (error) { + throw new Error(`${error.message}${stderr.trim() ? `\nWorker diagnostics:\n${stderr}` : ''}`, { cause: error }); + } finally { + clearTimeout(timer); + await stopProcessTree(worker); + } +} + +export async function stopServerGracefully(child, { timeoutMs = 20_000, forceStop = stopProcessTree } = {}) { + if (!child.pid) return; + let timer; + try { + const closed = child.exitCode !== null || child.signalCode !== null + ? Promise.resolve({ code: child.exitCode, signal: child.signalCode }) + : new Promise(resolve => child.once('close', (code, signal) => resolve({ code, signal }))); + const requestAndExit = async () => { + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve, reject) => { + child.stdin.write('gajae-desktop-shutdown\n', error => error ? reject(error) : resolve()); + }); + } + return closed; + }; + const result = await Promise.race([requestAndExit(), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Desktop server graceful shutdown timed out.')), timeoutMs); + })]); + assert.equal(result.code, 0, `Desktop server shutdown failed (exit ${result.code}, signal ${result.signal}).`); + } finally { + clearTimeout(timer); + // A timed-out or broken stdin shutdown must not leave the server or its + // worker guard alive. The outer Job also owns detached descendants. + await forceStop(child); + } +} + +export function assertRuntimeCatalog(catalog) { + assert.equal(catalog.success, true, 'Provider model catalog request failed'); + assert.equal(catalog.data?.provider, 'gjc'); + assert.ok(Array.isArray(catalog.data?.models?.OPTIONS), 'Provider model presets are missing'); + // The route returns preset-only HTTP 200 even when supervisor initialization + // fails. MODELS is present (possibly empty with no credentials) only when + // the runtime catalog loader actually returned through the supervisor. + assert.ok(Object.hasOwn(catalog.data.models, 'MODELS') && Array.isArray(catalog.data.models.MODELS), + 'Supervised GJC runtime catalog is unavailable; preset-only fallback cannot pass smoke'); + assert.equal(catalog.data.cache?.source, 'fresh', 'Catalog smoke must bypass disk and memory caches'); +} + +async function terminalSmoke(require) { + const pty = require('node-pty'); + await new Promise((resolve, reject) => { + const terminal = pty.spawn(process.execPath, ['-e', 'process.stdout.write("GAJAE_PTY_OK"); process.exitCode = 0'], { + name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env, + }); + let output = ''; + const timer = setTimeout(() => { + terminal.kill(); + reject(new Error(`ConPTY smoke timed out: ${output}`)); + }, 15_000); + terminal.onData(chunk => { output += chunk; }); + terminal.onExit(({ exitCode }) => { + clearTimeout(timer); + if (exitCode === 0 && output.includes('GAJAE_PTY_OK')) resolve(); + else reject(new Error(`ConPTY smoke failed (${exitCode}): ${output}`)); + }); + }); +} + +async function freePort() { + const socket = net.createServer(); + return new Promise((resolve, reject) => { + socket.once('error', reject); + socket.listen(0, '127.0.0.1', () => { + const { port } = socket.address(); + socket.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +export async function serverSmoke(payloadDir, expectedVersion, { env = process.env, shutdownTimeoutMs = 20_000 } = {}) { + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const nonce = randomUUID(); + const bootstrapSource = await fs.readFile(path.join(payloadDir, '.gajae-windows-server-bootstrap.cjs'), 'utf8'); + const server = spawn(process.execPath, ['--eval', bootstrapSource, path.join(payloadDir, 'dist-server', 'server', 'index.js')], { + cwd: payloadDir, + env: { ...env, SERVER_PORT: String(port), GJC_DESKTOP: '1', GJC_DESKTOP_API_KEY: randomUUID(), GJC_DESKTOP_BOOTSTRAP_NONCE: nonce }, + shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + }); + let output = ''; + let spawnError; + let ready; + let buffered = ''; + server.once('error', error => { spawnError = error; }); + server.stdin.on('error', error => { spawnError = error; }); + server.stdout.setEncoding('utf8'); + server.stderr.setEncoding('utf8'); + server.stdout.on('data', chunk => { + output = (output + chunk).slice(-32_768); + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop(); + for (const line of lines) { + try { + const frame = JSON.parse(line); + if (frame.kind === 'gajae-desktop-ready') ready = frame; + } catch { /* Other stdout lines are ordinary server diagnostics. */ } + } + }); + server.stderr.on('data', chunk => { output = (output + chunk).slice(-32_768); }); + const request = (route, options = {}) => fetch(base + route, { + ...options, redirect: 'manual', signal: options.signal ?? AbortSignal.timeout(2_000), + headers: { connection: 'close', ...options.headers }, + }); + try { + let health; + for (let attempt = 0; attempt < 150; attempt += 1) { + if (spawnError) throw spawnError; + if (server.exitCode !== null || server.signalCode !== null) throw new Error(`Server exited before health: ${output}`); + try { + const response = await request('/health'); + if (response.ok) health = await response.json(); + } catch { /* The server has not bound its loopback socket yet. */ } + if (health && ready) break; + await delay(100); + } + assert.ok(health && ready, `Server did not become ready: ${output}`); + assert.equal(ready.pid, server.pid); + assert.equal(ready.host, '127.0.0.1'); + assert.equal(ready.port, port); + assert.equal(ready.protocolVersion, 1); + assert.equal(ready.version, expectedVersion); + assert.equal(health.status, 'ok'); + assert.equal(health.product, 'gajae-app'); + assert.equal(health.protocolVersion, 1); + assert.equal(health.version, expectedVersion); + const unauthorized = await request('/api/projects'); + assert.equal(unauthorized.status, 401); + await unauthorized.arrayBuffer(); + const bootstrap = await request(`/desktop/bootstrap?nonce=${encodeURIComponent(nonce)}`); + assert.equal(bootstrap.status, 303); + assert.equal(bootstrap.headers.get('location'), '/'); + const cookie = bootstrap.headers.get('set-cookie'); + assert.ok(cookie?.includes('HttpOnly') && cookie.includes('gajae_desktop_api_key=')); + await bootstrap.arrayBuffer(); + const headers = { cookie: cookie.split(';', 1)[0], origin: base }; + const page = await request('/', { headers }); + assert.equal(page.status, 200); + assert.match(await page.text(), /]/i); + const projects = await request('/api/projects', { headers }); + assert.equal(projects.status, 200); + await projects.json(); + const models = await request('/api/providers/gjc/models?bypassCache=true', { + headers, signal: AbortSignal.timeout(45_000), + }); + assert.equal(models.status, 200, `Supervised model catalog failed: ${output}`); + assertRuntimeCatalog(await models.json()); + const replay = await request(`/desktop/bootstrap?nonce=${encodeURIComponent(nonce)}`); + assert.equal(replay.status, 401); + await replay.arrayBuffer(); + assert.ok((await fs.stat(env.DATABASE_PATH)).isFile(), 'Smoke database was not created in the isolated profile'); + } catch (error) { + throw new Error(`${error.message}\nServer diagnostics:\n${output}`, { cause: error }); + } finally { + await stopServerGracefully(server, { timeoutMs: shutdownTimeoutMs }).catch(error => { + throw new Error(`${error.message}\nServer diagnostics:\n${output}`, { cause: error }); + }); + } +} + +async function main() { + assert.equal(process.platform, 'win32'); + assert.equal(process.arch, 'x64'); + const [expectedNode, expectedBun] = process.argv.slice(2); + assert.equal(process.version, `v${expectedNode}`); + assert.equal(path.basename(process.execPath), 'gajae-app-server.exe'); + assert.equal(os.homedir().toLowerCase(), process.env.USERPROFILE.toLowerCase()); + const payloadDir = process.cwd(); + const require = createRequire(path.join(payloadDir, 'package.json')); + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + try { assert.equal(db.prepare('SELECT 22 AS value').get().value, 22); } + finally { db.close(); } + await terminalSmoke(require); + const bun = path.join(payloadDir, 'dist-native', 'bun.exe'); + const core = path.join(payloadDir, 'dist-native', 'gajae-core.exe'); + const capture = async (command, args) => (await execute(command, args, { windowsHide: true, timeout: 15_000 })).stdout.trim(); + assert.equal(await capture(bun, ['--version']), expectedBun); + assert.match(await capture(core, ['--version']), /^gajae-core \d+\.\d+\.\d+$/); + assert.equal(await capture(core, ['--', process.execPath, '--version']), `v${expectedNode}`); + const { rgPath } = require('@vscode/ripgrep'); + assert.match(await capture(rgPath, ['--version']), /^ripgrep /); + await workerHandshake(bun, path.join(payloadDir, 'dist-server', 'server', 'gjc-bun-worker.js')); + const { version } = JSON.parse(await fs.readFile(path.join(payloadDir, 'package.json'), 'utf8')); + await serverSmoke(payloadDir, version); + await new Promise((resolve, reject) => { + process.stdout.write('Windows payload smoke passed: Node, SQLite, ConPTY, core, ripgrep, Bun worker, supervised model catalog/Job chain, desktop bootstrap/auth, frontend and graceful shutdown.\n', error => error ? reject(error) : resolve()); + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); + // All worker/server exits and the final output flush have been awaited. + // node-pty's Windows native helpers can retain event-loop handles afterwards; + // this short-lived checker must finish explicitly, like the production server. + // The caller independently reaps the owned Job and verifies no descendants. + process.exit(0); +} diff --git a/scripts/release/windows-smoke-environment.test.mjs b/scripts/release/windows-smoke-environment.test.mjs new file mode 100644 index 00000000..2abdc8e6 --- /dev/null +++ b/scripts/release/windows-smoke-environment.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { gunzipSync } from 'node:zlib'; +import { promisify } from 'node:util'; + +import { verifyWindowsSmokeEnvironment, windowsSmokeEnvironment } from './windows-payload.mjs'; + +test('isolated Windows environment retains OS/compiler metadata and isolates all writable user paths', () => { + const profile = String.raw`C:\Users\runner\smoke 사용자 profile`; + const env = windowsSmokeEnvironment(String.raw`C:\runtime 가재`, profile, { + windir: String.raw`C:\Windows`, programfiles: String.raw`C:\Program Files`, + 'PROGRAMFILES(X86)': String.raw`C:\Program Files (x86)`, ProgramData: String.raw`C:\ProgramData`, + USERNAME: 'runner', USERDOMAIN: 'test-machine', COMPUTERNAME: 'test-machine', + HOME: 'private-home', USERPROFILE: 'private-home', TEMP: 'private-temp', APPDATA: 'private-appdata', + PSModulePath: 'private-powershell-modules', NODE_OPTIONS: '--require private.js', + OPENAI_API_KEY: 'do-not-inherit', ANTHROPIC_API_KEY: 'do-not-inherit', + }); + assert.equal(env.SystemRoot, String.raw`C:\Windows`); + assert.equal(env.WINDIR, env.SystemRoot); + assert.equal(env.ComSpec, String.raw`C:\Windows\System32\cmd.exe`); + assert.equal(env.ProgramFiles, String.raw`C:\Program Files`); + assert.equal(env['ProgramFiles(x86)'], String.raw`C:\Program Files (x86)`); + assert.equal(env.USERNAME, 'runner'); + assert.equal(env.USERDOMAIN, 'test-machine'); + assert.equal(env.PSModulePath, String.raw`C:\Program Files\WindowsPowerShell\Modules;C:\Windows\System32\WindowsPowerShell\v1.0\Modules`); + for (const key of ['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'DATABASE_PATH', 'GJC_WORKER_AGENT_DIR']) { + assert.ok(env[key].startsWith(profile), `${key} must preserve the isolated Unicode profile`); + } + for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'NODE_OPTIONS']) assert.equal(env[key], undefined); +}); + +test('Add-Type probe compresses its source below Windows command limits and returns native evidence', async () => { + const env = windowsSmokeEnvironment(String.raw`C:\runtime 가재`, String.raw`C:\profile 가재`); + const cwd = String.raw`C:\payload space 가재`; + const native = { runtime: String.raw`C:\Windows\Microsoft.NET\Framework64\v4.0.30319`, temp: env.TEMP, compilerExists: true, tempExists: true }; + const actual = await verifyWindowsSmokeEnvironment(env, cwd, { + execute: async (_command, args, options) => { + assert.ok(args.includes('-EncodedCommand')); + assert.ok(args.join(' ').length < 30_000, 'probe must fit CreateProcess command-line limits'); + const loader = Buffer.from(args.at(-1), 'base64').toString('utf16le'); + const compressed = loader.match(/FromBase64String\('([^']+)'\)/)?.[1]; + assert.ok(compressed); + const source = gunzipSync(Buffer.from(compressed, 'base64')).toString('utf8'); + assert.match(source, /Add-Type -CompilerParameters \$compilerParameters -TypeDefinition/); + assert.match(source, /GetTempPath/); + assert.match(source, /GetRuntimeDirectory/); + assert.ok(!source.includes(cwd)); + assert.equal(options.env, env); + assert.equal(options.cwd, cwd); + assert.equal(options.shell, false); + return { stdout: `${JSON.stringify(native)}\n{"compiled":42}\n`, stderr: '' }; + }, + }); + assert.deepEqual(actual, native); + await assert.rejects(verifyWindowsSmokeEnvironment(env, cwd, { + execute: async () => { throw Object.assign(new Error('encoded-command-must-not-appear'), { + cmd: 'encoded-command-must-not-appear', code: 1, stdout: JSON.stringify(native), stderr: 'Add-Type Win32Exception: invalid path', + }); }, + }), error => { + assert.match(error.message, /Add-Type Win32Exception: invalid path/); + assert.match(error.message, /Framework64/); + assert.ok(!error.message.includes('encoded-command-must-not-appear')); + return true; + }); +}); + +test('Windows raw mandatory ACE validator handles labels independently of SDDL formatting', { + skip: process.platform !== 'win32', timeout: 45_000, +}, async () => { + const { tsImport } = await import('tsx/esm/api'); + const { windowsCodeDomLabelValidationScript } = await tsImport(new URL('../../server/gjc-windows-job.ts', import.meta.url).href, import.meta.url); + const cases = [ + { name: 'high', sddl: 'S:(ML;OI;NW;;;HI)', expected: true, count: 1 }, + { name: 'numeric high SID', sddl: 'S:(ML;OICI;NW;;;S-1-16-12288)', expected: true, count: 1 }, + { name: 'additional restrictions', sddl: 'S:(ML;OI;NWNR;;;HI)', expected: true, count: 1 }, + { name: 'medium', sddl: 'S:(ML;OI;NW;;;ME)', expected: false, count: 1 }, + { name: 'missing no-write-up', sddl: 'S:(ML;OI;NR;;;HI)', expected: false, count: 1 }, + { name: 'inherit-only', sddl: 'S:(ML;OIIO;NW;;;HI)', expected: false, count: 1 }, + { name: 'missing SACL', sddl: 'D:(A;;FA;;;BA)', expected: false, count: 0 }, + { name: 'empty SACL', sddl: 'D:(A;;FA;;;BA)S:AI', expected: false, count: 0 }, + { name: 'audit ACE is not a label', sddl: 'S:(AU;SA;FA;;;S-1-16-12288)', expected: false, count: 1 }, + ]; + const source = `$ErrorActionPreference = 'Stop' +${windowsCodeDomLabelValidationScript()} +foreach ($case in ($env:GAJAE_LABEL_FIXTURES | ConvertFrom-Json)) { + $state = Get-GajaeCompilerLabelState ([Security.AccessControl.RawSecurityDescriptor]::new($case.sddl)) + [Console]::Out.WriteLine((@{ name = $case.name; valid = $state.hasHighLabel; count = $state.saclCount; aces = $state.aces } | ConvertTo-Json -Compress -Depth 4)) +}`; + const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows'; + const { stdout } = await promisify(execFile)(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64'), + ], { env: { ...process.env, GAJAE_LABEL_FIXTURES: JSON.stringify(cases) }, windowsHide: true, shell: false, timeout: 30_000 }); + const results = stdout.trim().split(/\r?\n/).map(line => JSON.parse(line)); + assert.deepEqual(results.map(({ name, valid, count }) => ({ name, valid, count })), + cases.map(({ name, expected, count }) => ({ name, valid: expected, count }))); + assert.deepEqual(results[0].aces, [{ type: 0x11, size: 20, flags: 1, mask: 1, sid: 'S-1-16-12288' }]); +}); + +test('Windows compiler path policy accepts only ASCII aliases of the same protected directory', { + skip: process.platform !== 'win32', timeout: 45_000, +}, async () => { + const { tsImport } = await import('tsx/esm/api'); + const { windowsCodeDomPathValidationScript } = await tsImport(new URL('../../server/gjc-windows-job.ts', import.meta.url).href, import.meta.url); + const original = String.raw`C:\private 가재\compiler`; + const cases = [ + { name: 'verified alias', original, alias: String.raw`C:\PRIVAT~1\compiler`, resolved: original, valid: true }, + { name: 'case-insensitive round trip', original, alias: String.raw`C:\PRIVAT~1\compiler`, resolved: String.raw`c:\PRIVATE 가재\COMPILER`, valid: true }, + { name: 'short names unavailable', original, alias: original, resolved: original, valid: false }, + { name: 'empty alias', original, alias: '', resolved: original, valid: false }, + { name: 'relative alias', original, alias: 'PRIVAT~1', resolved: original, valid: false }, + { name: 'different target', original, alias: String.raw`C:\PRIVAT~1\compiler`, resolved: String.raw`C:\another\compiler`, valid: false }, + ]; + const source = `$ErrorActionPreference = 'Stop' +${windowsCodeDomPathValidationScript()} +foreach ($case in ($env:GAJAE_PATH_FIXTURES | ConvertFrom-Json)) { + try { $null = Assert-GajaeCompilerPath $case.original $case.alias $case.resolved; $valid = $true; $reason = '' } + catch { $valid = $false; $reason = $_.Exception.Message } + [Console]::Out.WriteLine((@{ name = $case.name; valid = $valid; reason = $reason } | ConvertTo-Json -Compress)) +}`; + const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows'; + const { stdout } = await promisify(execFile)(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64'), + ], { env: { ...process.env, GAJAE_PATH_FIXTURES: JSON.stringify(cases) }, windowsHide: true, shell: false, timeout: 30_000 }); + const results = stdout.trim().split(/\r?\n/).map(line => JSON.parse(line)); + assert.deepEqual(results.map(({ name, valid }) => ({ name, valid })), cases.map(({ name, valid }) => ({ name, valid }))); + assert.match(results[2].reason, /short-name generation may be disabled/); + assert.match(results[5].reason, /same protected directory/); +}); + +test('real Windows Add-Type works with baseline and isolated Unicode profile, cwd and temp', { + skip: process.platform !== 'win32', timeout: 140_000, +}, async t => { + // No Bun, core, native addon or compiled server is needed: run this before + // the expensive packaging build. Only the constant Add-Type probe runs; + // neither case loads a PowerShell profile or application credentials. + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gajae Add-Type 가재 space-')); + t.after(async () => { + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + }); + const cwd = path.join(root, 'payload cwd 가재'); + const env = windowsSmokeEnvironment(path.dirname(process.execPath), path.join(root, 'profile 사용자')); + for (const directory of [cwd, env.USERPROFILE, env.APPDATA, env.LOCALAPPDATA, env.TEMP, + env.XDG_CONFIG_HOME, env.XDG_DATA_HOME, env.XDG_CACHE_HOME, env.GJC_WORKER_AGENT_DIR, env.WORKSPACES_ROOT]) { + await fs.mkdir(directory, { recursive: true }); + } + const failures = []; + for (const [label, candidate] of [ + ['baseline', { ...process.env, SystemRoot: env.SystemRoot }], + ['isolated Unicode', env], + ]) { + try { + const result = await verifyWindowsSmokeEnvironment(candidate, cwd); + t.diagnostic(`${label}: ${JSON.stringify(result)}`); + assert.equal(result.compilerExists, true); + assert.equal(result.tempExists, true); + assert.ok(result.runtime); + assert.ok(path.resolve(result.compilerTemp).startsWith(path.resolve(result.temp) + path.sep)); + assert.equal(result.compilerPathVerified, true); + assert.equal(path.resolve(result.compilerLongPath).toLowerCase(), path.resolve(result.compilerTemp).toLowerCase()); + assert.match(result.compilerPath, /^[\x20-\x7e]+$/); + assert.ok(result.compilerBasePath.startsWith(result.compilerPath + path.sep)); + assert.ok(result.compilerOutputAssembly.startsWith(result.compilerPath + path.sep)); + assert.equal(result.compilerEnvironmentRestored, true); + assert.equal(result.compilerRestoredTemp, candidate.TEMP); + assert.equal(result.compilerRestoredTmp, candidate.TMP); + if (result.elevated) { + assert.match(result.compilerSddl, /\(D;OI;SD;;;/); + assert.match(result.compilerSddl, /\(A;OICI;FA;;;BA\)/); + assert.equal(result.hasHighLabel, true); + assert.ok(result.compilerSaclCount > 0); + assert.ok(result.compilerSaclAces.some(ace => ace.type === 0x11 + && ace.sid === 'S-1-16-12288' && (ace.mask & 1) !== 0 && (ace.flags & 8) === 0)); + } + await assert.rejects(fs.access(result.compilerTemp), { code: 'ENOENT' }); + if (label === 'isolated Unicode') { + assert.equal(await fs.realpath(result.temp), await fs.realpath(env.TEMP)); + assert.equal(path.resolve(result.userProfile).toLowerCase(), path.resolve(env.USERPROFILE).toLowerCase()); + } + } catch (error) { + t.diagnostic(`${label}: ${error.message}`); + failures.push(new Error(`${label}: ${error.message}`)); + } + } + if (failures.length) throw new AggregateError(failures, 'Windows .NET/Add-Type environment preflight failed; inspect baseline versus isolated diagnostics.'); +}); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index f77409f8..c0fd5bda 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -109,12 +109,13 @@ function runBunTests(label, files) { } } -const [serverTestsAll, clientTests, scriptTests] = await Promise.all([ +const [serverTestsAll, clientTests, scriptTests, desktopScriptTests] = await Promise.all([ collectTests('server'), collectTests('src'), // Build and release tooling: plain Node, no tsconfig. This is where the // distribution-exclusion stubs are checked before any payload is built. collectTests('scripts'), + collectTests('src-tauri/scripts'), ]); const serverBunTests = serverTestsAll.filter((file) => BUN_TEST_FILE_PATTERN.test(file)); const serverTests = serverTestsAll.filter((file) => !BUN_TEST_FILE_PATTERN.test(file)); @@ -125,4 +126,4 @@ runTests('server', serverTests, { tsconfig: 'server/tsconfig.json' }); runBunTests('server-bun', serverBunTests); runTests('client', clientNodeTests, { tsconfig: 'tsconfig.json' }); runBunTests('client-bun', clientBunTests); -runTests('scripts', scriptTests); +runTests('scripts', [...scriptTests, ...desktopScriptTests]); diff --git a/scripts/run-windows-tests.mjs b/scripts/run-windows-tests.mjs new file mode 100644 index 00000000..08decc67 --- /dev/null +++ b/scripts/run-windows-tests.mjs @@ -0,0 +1,97 @@ +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { BUN_VERSION, versionOf } from './fetch-bun.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const options = process.argv.slice(2); +if (options.length > 1 || options.some(option => !['--server-only', '--scripts-only'].includes(option))) { + throw new Error('Usage: node scripts/run-windows-tests.mjs [--server-only|--scripts-only]'); +} +// The full existing suite remains in the Linux verify gate. This additional +// lane exercises the native Windows worker, PTY, path and packaging contracts. +const serverTests = [ + 'server/gjc-windows-job.test.ts', + 'server/gjc-worker-client.test.ts', + 'server/gjc-core-host.test.ts', + 'server/gjc-engine-manifest.test.ts', + 'server/gjc-cli-shim.test.ts', + 'server/gjc-worker-protocol.test.ts', + 'server/gjc-worker-protocol-spec.test.ts', + 'server/routes/system.test.js', + 'server/modules/websocket/services/shell-command.test.ts', + 'server/modules/websocket/services/shell-websocket.service.test.ts', + 'server/utils/runtime-paths.test.js', +]; +const scriptTests = ['scripts/lib/npm-cli.test.mjs']; +for (const directory of ['scripts', 'scripts/lib', 'scripts/release', 'src-tauri/scripts']) { + for (const name of readdirSync(path.join(root, directory))) { + if (/(?:windows|bun|tauri|runtime-archive).*\.test\.mjs$/.test(name)) { + scriptTests.push(`${directory}/${name}`); + } + } +} + +const groups = [ + ...(!options.includes('--scripts-only') ? [[serverTests, 'server/tsconfig.json']] : []), + ...(!options.includes('--server-only') ? [[scriptTests, null]] : []), +]; +const stateDirectory = mkdtempSync(path.join(os.tmpdir(), 'gajae-windows-tests-')); +const env = { ...process.env, DATABASE_PATH: path.join(stateDirectory, 'auth.db') }; +// SDK fixtures must not overwrite the operator's active terminal breadcrumb. +for (const name of ['TMUX', 'TMUX_PANE', 'KITTY_WINDOW_ID', 'TERM_SESSION_ID', 'WT_SESSION']) { + delete env[name]; +} +let exitCode = 0; +try { + for (const [files, tsconfig] of groups) { + const result = spawnSync(process.execPath, [ + ...(tsconfig ? ['--import', 'tsx'] : []), + '--test', '--test-concurrency=1', ...files, + ], { + cwd: root, + env: tsconfig ? { ...env, TSX_TSCONFIG_PATH: tsconfig } : env, + stdio: 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) { + exitCode = result.status ?? 1; + break; + } + } + if (exitCode === 0 && !options.includes('--scripts-only')) { + const bun = path.join(root, 'dist-native', process.platform === 'win32' ? 'bun.exe' : 'bun'); + if (await versionOf(bun) !== BUN_VERSION) { + throw new Error(`Bun ${BUN_VERSION} is required; run node scripts/fetch-bun.mjs.`); + } + // Workflow evidence runs Bun in child shells too. Use the same pinned + // runtime there without changing the operator's process environment. + const bunEnv = { ...env }; + const pathKey = Object.keys(bunEnv).find(key => key.toLowerCase() === 'path'); + const previousPath = pathKey ? bunEnv[pathKey] : ''; + for (const key of Object.keys(bunEnv)) { + if (key.toLowerCase() === 'path') delete bunEnv[key]; + } + bunEnv.PATH = [path.dirname(bun), previousPath].filter(Boolean).join(path.delimiter); + for (const args of [ + ['scripts/probe-windows-sdk-locks.mjs'], + ['test', 'server/gjc-sdk-contract.bun.test.ts', 'server/gjc-delegation-executor.bun.test.ts'], + ]) { + const result = spawnSync(bun, args, { + cwd: root, + env: bunEnv, + stdio: ['ignore', 'inherit', 'inherit'], + ...(args[0] === 'test' ? {} : { timeout: 30_000 }), + }); + if (result.error) throw result.error; + // Keep the full suites enabled even when the isolated native probe fails. + if (result.status !== 0) exitCode = result.status ?? 1; + } + } +} finally { + rmSync(stateDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} +process.exitCode = exitCode; diff --git a/scripts/runtime-archive.mjs b/scripts/runtime-archive.mjs new file mode 100644 index 00000000..4b776c3e --- /dev/null +++ b/scripts/runtime-archive.mjs @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { promisify } from 'node:util'; + +export async function sha256(filePath) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) hash.update(chunk); + return hash.digest('hex'); +} + +export async function downloadVerifiedArchive(url, destination, expectedSha256, { fetchImpl = fetch } = {}) { + if (!/^[a-f0-9]{64}$/.test(expectedSha256)) throw new Error('A pinned SHA-256 digest is required.'); + try { + const response = await fetchImpl(url, { redirect: 'follow', signal: AbortSignal.timeout(300_000) }); + if (!response.ok || !response.body) throw new Error(`Runtime download failed with HTTP ${response.status}.`); + await pipeline(response.body, createWriteStream(destination, { mode: 0o600 })); + if (await sha256(destination) !== expectedSha256) throw new Error('Downloaded runtime archive failed SHA-256 verification.'); + } catch (error) { + await fs.rm(destination, { force: true }); + throw error; + } +} + +/** Paths are data in environment variables, never PowerShell source or shell arguments. */ +export async function extractWindowsZip(archivePath, destinationDirectory, { + env = process.env, + execute = promisify(execFile), +} = {}) { + const systemRootKey = Object.keys(env).find(key => key.toLowerCase() === 'systemroot'); + const systemRoot = env[systemRootKey] || 'C:\\Windows'; + await execute(path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoProfile', '-NonInteractive', '-Command', + '$ErrorActionPreference = "Stop"; Expand-Archive -LiteralPath $env:GAJAE_RUNTIME_ARCHIVE -DestinationPath $env:GAJAE_RUNTIME_EXTRACT -Force', + ], { + shell: false, + windowsHide: true, + timeout: 300_000, + env: { ...env, GAJAE_RUNTIME_ARCHIVE: archivePath, GAJAE_RUNTIME_EXTRACT: destinationDirectory }, + }); +} diff --git a/scripts/start-isolated-dev.mjs b/scripts/start-isolated-dev.mjs index 06c6812a..26f0efa9 100644 --- a/scripts/start-isolated-dev.mjs +++ b/scripts/start-isolated-dev.mjs @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { npmInvocation } from './lib/npm-cli.mjs'; + const SAFE_AGENT_FILES = Object.freeze(['config.yml', 'models.yml']); export function isLoopbackHost(host) { @@ -95,7 +97,8 @@ export async function main() { console.log(`[isolated-qa] UI: http://${host}:${vitePort}`); console.log(`[isolated-qa] API: http://${host}:${serverPort}`); - const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'dev'], { + const npm = npmInvocation(['run', 'dev']); + const child = spawn(npm.command, npm.args, { cwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), env, stdio: 'inherit', diff --git a/server/GJC-LIVE-SPEC.md b/server/GJC-LIVE-SPEC.md index 1de244ce..7dc6482f 100644 --- a/server/GJC-LIVE-SPEC.md +++ b/server/GJC-LIVE-SPEC.md @@ -1,7 +1,13 @@ # GJC live provider specification Status: Production Bun SDK worker, native host/watcher, durable jobs, and native -PTY slices implemented (updated 2026-09-01) +PTY slices implemented (updated 2026-09-06) + +The current source release pins GJC SDK `0.16.4` and Bun `1.4.0` in +`server/gjc-runtime-manifest.json`; packaged workers must continue to match +that manifest. The Windows x64 implementation remains a preview path, with +native CI and interactive acceptance tracked separately in +`docs/WINDOWS-DESKTOP.md`. GJC is the only provider routed through an isolated provider worker. Claude, Codex, Cursor, and OpenCode retain their existing execution paths. @@ -128,6 +134,18 @@ terminal behavior remain unchanged. The worker does not own or mutate application database, browser WebSocket, replay, or notification state. +Root and delegated SDK sessions set `sdkHostModeSupported: false`: the private +worker protocol is the app's control endpoint, not the SDK's detached broker. +Workflow identity, file-based resume and in-process async ownership remain active. + +Each run owns its Settings clone's pending writes, but not the shared parent +storage. Teardown awaits `flushOrThrow()` after the final session writer stops; +failure fences the worker instead of publishing completion or permitting reuse. +Delegated model selection uses runtime-only `overrideModelRoles`, preserving +other roles and the user's global model configuration. Failed SDK construction +also drains the clone and closes its caller-owned SessionManager; after successful +construction, the SDK session owns that manager. + ### Identity model Three IDs are intentionally separate: @@ -219,12 +237,15 @@ method or frame changes; the policy travels inside existing payloads: ## Process and terminal lifecycle - On POSIX (Linux and macOS), the application starts the Rust core as a detached - process-group leader. The Node worker and GJC children inherit that group; + process-group leader. The Bun worker and GJC children inherit that group; reaping requires direct-child close and process-group `ESRCH`. -- Windows is a v2 non-target and runtime-frozen per this brief: CI and a - verified desktop machine are unavailable. No `taskkill /T /F` fallback is - part of the v2 contract. Windows cleanup is fail-closed as `unconfirmed`, so - it cannot release a lease or admit a replacement generation. +- The Windows x64 preview extends this contract with an atomic Job Object + guard. Each worker generation owns a named kill-on-close job; cleanup must + verify guard exit and independently verify that the owned job is empty. + An unowned child or failed reap still blocks lease release and replacement. + The Tauri shell separately owns the complete server tree in an unnamed job + and requests graceful shutdown through its private stdin bootstrap. Native + CI and desktop acceptance are tracked in `docs/WINDOWS-DESKTOP.md`. - `worker.initialize` covers the whole SDK bootstrap (runtime manifest check, model registry build, online model discovery), which takes several seconds on a loaded machine. The application bounds it at 60 s diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index b43111b3..af3885f4 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -96,6 +96,7 @@ type ActiveRun = { goals?: GjcGoalSession; goalScope?: GjcGoalScope; markAborted?: () => void; + settings: Settings; session: { prompt(message: string, options?: { streamingBehavior?: 'steer' | 'followUp' }): Promise; abort(): Promise; @@ -378,8 +379,14 @@ async function resumeManager(providerSessionId: string, sessionRoot: string): Pr const matches = (await SessionManager.list('', sessionRoot)).filter((session) => session.id === providerSessionId); if (matches.length !== 1) throw new Error(FAILURE); const manager = await SessionManager.open(matches[0].path, sessionRoot); - if (manager.getSessionId() !== providerSessionId) throw new Error(FAILURE); - return manager; + try { + if (manager.getSessionId() !== providerSessionId) throw new Error(FAILURE); + return manager; + } catch (error) { + try { await manager.close(); } + catch { throw new GjcCleanupUnconfirmedError(); } + throw error; + } } /** In-process, serial-only SDK runtime. AuthStorage and ModelRegistry are app-owned singleton inputs. */ @@ -628,6 +635,10 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { () => run.askController.dispose(), () => run.delegation?.dispose(), () => run.session.dispose(), + // The SDK session does not own the app's per-run Settings clone. + // Drain it after every session writer has stopped; never close the + // shared parent Settings/storage here. + () => run.settings.flushOrThrow(), ]) { try { await cleanup(); } catch { disposalError ??= new Error(FAILURE); } @@ -645,11 +656,17 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { } async #runInner(runId: string, options: Record, config: SdkRunConfig, writer: GjcWorkerWriter, message: string, setActive: (run: ActiveRun) => void): Promise { - { + let flushUnownedSettings: (() => Promise) | undefined; + let closeUnownedManager: (() => Promise) | undefined; + let unownedSession: ActiveRun['session'] | undefined; + let settingsTransferred = false; + let managerTransferred = false; + try { const resumedId = typeof options.sessionId === 'string' && options.sessionId ? options.sessionId : undefined; const sessionManager = resumedId ? await resumeManager(resumedId, config.sessionRoot) : SessionManager.create(config.cwd, config.sessionRoot); + closeUnownedManager = () => sessionManager.close(); const globalSettings = this.options.settings ?? await this.options.loadSettings?.() ?? await Settings.init( @@ -668,6 +685,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { // clone before session creation. A Bun worker can serve multiple project // sessions, and the clone keeps their project settings and overrides isolated. const settings = await globalSettings.cloneForCwd(config.cwd); + flushUnownedSettings = () => settings.flushOrThrow(); applyGjcToolSettingsPolicy(settings); const goalScope = config.appSessionId && config.goalOwner ? { appSessionId: config.appSessionId, owner: config.goalOwner, cwd: await realpath(config.cwd), @@ -718,6 +736,9 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { bashAllowedPrefixes: config.bashPolicy.allowedPrefixes, ...(config.bashPolicy.restrictionProfile ? { bashRestrictionProfile: config.bashPolicy.restrictionProfile } : {}), hasUI: true, + // GjcWorkerHost owns app session/control admission. Do not publish a + // second SDK endpoint through a detached, independently owned broker. + sdkHostModeSupported: false, ...(config.appSessionId ? { automationTools: serializeGjcDelegationAutomationTools(createGjcAutomationTools( config.appSessionId, @@ -744,6 +765,9 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { // native SDK spawning is denied for goal/delegation-capable sessions. spawns: delegation || goalEnabled ? 'deny' : sessionOptions.spawns, }); + unownedSession = result.session; + // AgentSession owns the caller-created manager from this point. + managerTransferred = true; this.#assertHealthy(); if (config.modelProfile) { await activateModelProfile({ @@ -807,6 +831,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { const activeRun: ActiveRun = { markAborted: writer.setAborted, ...(goalScope ? { goalScope } : {}), + settings, session: result.session, sessionManager, unsubscribe, @@ -817,6 +842,8 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { ...(config.appSessionId ? { appSessionId: config.appSessionId } : {}), }; setActive(activeRun); + unownedSession = undefined; + settingsTransferred = true; this.#runs.set(runId, activeRun); if (goalEnabled && goalScope) { goals = new GjcGoalSession(result.session, sessionManager, goalScope, runId, @@ -935,6 +962,25 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { try { await delegation?.dispose(); } finally { resolvedCredential.dispose(); } } + } finally { + let cleanupFailed = false; + if (!settingsTransferred && unownedSession) { + try { await unownedSession.dispose(); } + catch { cleanupFailed = true; } + } + if (!settingsTransferred) { + try { await flushUnownedSettings?.(); } + catch { cleanupFailed = true; } + } + // A session created by the SDK owns this manager; only close it directly + // when creation never handed ownership to a session. + if (!managerTransferred) { + try { await closeUnownedManager?.(); } + catch { cleanupFailed = true; } + } + // #run checks health before publishing success or the original error. + // Fence here without replacing an in-flight exception from finally. + if (cleanupFailed) this.#poison(); } } } diff --git a/server/gjc-cli-shim.test.ts b/server/gjc-cli-shim.test.ts index fb1e960e..7fe1c4b0 100644 --- a/server/gjc-cli-shim.test.ts +++ b/server/gjc-cli-shim.test.ts @@ -37,7 +37,7 @@ test('creates an executable gjc shim and prepends it to PATH', () => { assert.match(shim, new RegExp(BUN_PATH)); assert.match(shim, new RegExp(BIN_PATH)); assert.match(shim, /"\$@"/); - assert.equal(statSync(path.join(installed.shimDir, 'gjc')).mode & 0o777, 0o755); + if (process.platform !== 'win32') assert.equal(statSync(path.join(installed.shimDir, 'gjc')).mode & 0o777, 0o755); assert.equal(env.PATH, `${installed.shimDir}${path.delimiter}/existing/bin`); }); }); @@ -66,7 +66,7 @@ test('rewrites a shim whose content drifted', () => { }); }); -test('restores executable mode when replacing stale shim content', () => { +test('restores executable mode when replacing stale shim content', { skip: process.platform === 'win32' }, () => { withTempHome((homeDir) => { const installed = install(homeDir); assert.ok(installed); @@ -110,7 +110,7 @@ test('uses the existing case-insensitive PATH key on win32', () => { resolveRuntimeBin: () => BIN_PATH, }); assert.ok(installed); - assert.equal(env.Path, `${installed.shimDir}${path.delimiter}/existing/bin`); + assert.equal(env.Path, `${installed.shimDir};/existing/bin`); assert.equal(env.PATH, undefined); }); }); @@ -118,6 +118,7 @@ test('uses the existing case-insensitive PATH key on win32', () => { test('writes a cmd shim on win32', () => { withTempHome((homeDir) => { const installed = installGjcCliShim({ + env: {}, homeDir, bunPath: BUN_PATH, platform: 'win32', @@ -126,11 +127,34 @@ test('writes a cmd shim on win32', () => { assert.ok(installed); assert.equal( readFileSync(path.join(installed.shimDir, 'gjc.cmd'), 'utf8'), - `@echo off\r\n"${BUN_PATH}" "${BIN_PATH}" %*\r\n`, + `@echo off\r\nsetlocal DisableDelayedExpansion\r\n"${BUN_PATH}" "${BIN_PATH}" %*\r\n`, ); }); }); +test('Windows shim preserves percent and bang characters in installed runtime paths', () => { + withTempHome((homeDir) => { + const bunPath = 'C:\\Users\\100%TEMP%!user!\\bun.exe'; + const binPath = 'C:\\Program Files\\Gajae & Tools\\gjc.js'; + const installed = installGjcCliShim({ homeDir, env: {}, platform: 'win32', bunPath, resolveRuntimeBin: () => binPath }); + assert.ok(installed); + assert.equal(readFileSync(path.join(installed.shimDir, 'gjc.cmd'), 'utf8'), + '@echo off\r\nsetlocal DisableDelayedExpansion\r\n"C:\\Users\\100%%TEMP%%!user!\\bun.exe" "C:\\Program Files\\Gajae & Tools\\gjc.js" %*\r\n'); + const shell = readFileSync(path.join(installed.shimDir, 'gjc'), 'utf8'); + assert.ok(shell.includes("'C:/Users/100%TEMP%!user!/bun.exe'")); + }); +}); + +test('Windows PATH is deduplicated across key casing and always prefers the bundled shim', () => { + withTempHome((homeDir) => { + const shimDir = path.join(homeDir, '.gajae-app', 'gjc-cli-shim'); + const env = { Path: `C:\\tools;${shimDir.toUpperCase()}`, PATH: 'C:\\global;C:/TOOLS' } as NodeJS.ProcessEnv; + assert.ok(installGjcCliShim({ env, homeDir, platform: 'win32', bunPath: BUN_PATH, resolveRuntimeBin: () => BIN_PATH })); + assert.equal(env.PATH, `${shimDir};C:\\global;C:/TOOLS`); + assert.equal(env.Path, undefined); + }); +}); + test('returns null without changing PATH when the runtime bin cannot resolve', () => { withTempHome((homeDir) => { const env = { PATH: '/existing/bin' }; diff --git a/server/gjc-cli-shim.ts b/server/gjc-cli-shim.ts index 276ec956..d3a571a6 100644 --- a/server/gjc-cli-shim.ts +++ b/server/gjc-cli-shim.ts @@ -42,11 +42,21 @@ function quoteShellArgument(value: string): string { } function prependPath(env: NodeJS.ProcessEnv, shimDir: string, platform: NodeJS.Platform): void { - const pathKey = platform === 'win32' - ? Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH' - : 'PATH'; - const entries = (env[pathKey] ?? '').split(path.delimiter).filter(Boolean); - if (!entries.includes(shimDir)) env[pathKey] = [shimDir, ...entries].join(path.delimiter); + const windows = platform === 'win32'; + const keys = windows ? Object.keys(env).filter((key) => key.toLowerCase() === 'path').sort() : ['PATH']; + const pathKey = keys[0] ?? 'PATH'; + const delimiter = windows ? ';' : ':'; + const comparable = (entry: string) => windows ? entry.replaceAll('\\', '/').toLowerCase() : entry; + const seen = new Set([comparable(shimDir)]); + const entries = keys.flatMap((key) => (env[key] ?? '').split(delimiter)).filter((entry) => { + if (!entry || seen.has(comparable(entry))) return false; + seen.add(comparable(entry)); + return true; + }); + // Node selects the first PATH spelling on Windows. Keep one key and put the + // bundled CLI ahead of any previously installed global gjc shim. + for (const key of keys.slice(1)) delete env[key]; + env[pathKey] = [shimDir, ...entries].join(delimiter); } export function installGjcCliShim(options: GjcCliShimOptions = {}): { shimDir: string } | null { @@ -59,9 +69,14 @@ export function installGjcCliShim(options: GjcCliShimOptions = {}): { shimDir: s if (!binPath) return null; const shimDir = path.join(homeDir, '.gajae-app', 'gjc-cli-shim'); mkdirSync(shimDir, { recursive: true }); - writeShimIfNeeded(path.join(shimDir, 'gjc'), `#!/bin/sh\nexec ${quoteShellArgument(bunPath)} ${quoteShellArgument(binPath)} "$@"\n`); + const shellPath = (value: string) => platform === 'win32' ? value.replaceAll('\\', '/') : value; + writeShimIfNeeded(path.join(shimDir, 'gjc'), `#!/bin/sh\nexec ${quoteShellArgument(shellPath(bunPath))} ${quoteShellArgument(shellPath(binPath))} "$@"\n`); if (platform === 'win32') { - writeShimIfNeeded(path.join(shimDir, 'gjc.cmd'), `@echo off\r\n"${bunPath}" "${binPath}" %*\r\n`); + // Batch files expand %variables% even inside quotes; !variables! expand + // when the caller enabled delayed expansion. Neither is path syntax. + if (/["\r\n\0]/u.test(bunPath + binPath)) return null; + const batchPath = (value: string) => value.replaceAll('%', '%%'); + writeShimIfNeeded(path.join(shimDir, 'gjc.cmd'), `@echo off\r\nsetlocal DisableDelayedExpansion\r\n"${batchPath(bunPath)}" "${batchPath(binPath)}" %*\r\n`); } prependPath(env, shimDir, platform); return { shimDir }; diff --git a/server/gjc-core-host.test.ts b/server/gjc-core-host.test.ts index 995f6c37..ea54ee71 100644 --- a/server/gjc-core-host.test.ts +++ b/server/gjc-core-host.test.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { appendFile, mkdir, mkdtemp, realpath, rename, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; @@ -11,6 +12,13 @@ const corePath = fileURLToPath(new URL(`../dist-native/${executable}`, import.me const WATCHER_FRAME_TIMEOUT_MS = 60_000; const WATCHER_PROCESS_TIMEOUT_MS = 90_000; const WATCHER_FRAME_POLL_INTERVAL_MS = 10; +// Windows handles and filesystem scanners can briefly outlive child exit. +// Retry transient removal errors with at most 3 seconds of linear backoff. +const TEMP_ROOT_CLEANUP_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }; + +// Rust canonicalize emits verbatim drive/UNC paths on Windows; Node realpath +// returns their ordinary spelling. Compare the same filesystem path form. +const coreReportedPath = (value: string): string => path.toNamespacedPath(value); type CoreResult = { code: number | null; @@ -85,6 +93,7 @@ test('native core recursively watches multiple roots and filters non-transcript ], { stdio: ['pipe', 'pipe', 'pipe'], }); + const closed = new Promise((resolve) => child.once('close', () => resolve())); const frames: Array> = []; let buffered = ''; let diagnostics = ''; @@ -133,8 +142,9 @@ test('native core recursively watches multiple roots and filters non-transcript const nested = path.join(firstRoot, 'workspace'); await mkdir(nested); await writeFile(path.join(nested, 'ignored.txt'), 'ignored', 'utf8'); - const transcript = path.join(nested, 'session.jsonl'); - await writeFile(transcript, '{"type":"session"}\n', 'utf8'); + const transcriptFile = path.join(nested, 'session.jsonl'); + await writeFile(transcriptFile, '{"type":"session"}\n', 'utf8'); + const transcript = coreReportedPath(await realpath(transcriptFile)); await waitForFrame((frame) => frame.kind === 'event' && frame.path === transcript); const priorTranscriptEvents = frames.filter((frame) => frame.path === transcript).length; @@ -159,7 +169,8 @@ test('native core recursively watches multiple roots and filters non-transcript ); } finally { child.kill('SIGKILL'); - await rm(temporaryRoot, { recursive: true, force: true }); + await closed; + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); } }); @@ -175,6 +186,7 @@ test('native core reports transcripts a directory already held when it appeared' const child = spawn(corePath, ['watch', '--root', root], { stdio: ['pipe', 'pipe', 'pipe'], }); + const closed = new Promise((resolve) => child.once('close', () => resolve())); const frames: Array> = []; let buffered = ''; child.stdout.setEncoding('utf8'); @@ -201,7 +213,7 @@ test('native core reports transcripts a directory already held when it appeared' // The whole populated tree arrives as one rename: the transcript inside it // is never observed by the watch, only the directory that now holds it. await rename(staged, path.join(root, 'moved')); - const transcript = path.join(root, 'moved', 'nested', 'session.jsonl'); + const transcript = coreReportedPath(await realpath(path.join(root, 'moved', 'nested', 'session.jsonl'))); await waitForFrame((frame) => ( frame.kind === 'event' && frame.event === 'add' && frame.path === transcript )); @@ -212,19 +224,24 @@ test('native core reports transcripts a directory already held when it appeared' ); } finally { child.kill('SIGKILL'); - await rm(temporaryRoot, { recursive: true, force: true }); + await closed; + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); } }); test('native core relays bytes and child diagnostics without a shell', async () => { const script = [ "process.stdin.on('data', (chunk) => process.stdout.write(chunk));", - "process.stdin.on('end', () => { process.stderr.write('child diagnostic\\n'); process.exit(7); });", + // Let both streams drain before exiting; an immediate process.exit can + // make the fixture truncate an otherwise correct relay. + "process.stdin.on('end', () => { process.stderr.write('child diagnostic\\n'); process.exitCode = 7; });", ].join(''); + const unicode = Buffer.from('한글\n'.repeat(32 * 1024)); const chunks = [ Buffer.from('{"protocolVersion":1,"kind":"request"}\n'), Buffer.from('split-utf8-'), - Buffer.from('한글\n'), + unicode.subarray(0, 1), + unicode.subarray(1), ]; const result = await runCore([ '--', @@ -258,7 +275,7 @@ test('native core preserves a successful child status after child stdin closes', test('native core fails safely when its child executable is unavailable', async () => { const result = await runCore([ '--', - '/definitely/missing/gajae-worker-executable', + path.join(os.tmpdir(), 'definitely-missing-gajae-worker', executable), ]); assert.equal(result.code, 1); @@ -437,25 +454,89 @@ test('native job authority persists and reconciles state across process replacem nextCursor: null, }); } finally { - await rm(temporaryRoot, { recursive: true, force: true }); + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); + } +}); + +test('native git manages worktrees under paths with spaces and Unicode', async () => { + const temporaryRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), 'gajae core git 한글 '))); + const worktree = path.join(temporaryRoot, '.gjc-worktrees', 'job-1'); + const params = { jobId: 'job-1', branch: 'job/job-1', path: worktree }; + const git = (args: string[]) => execFileSync('git', ['-C', temporaryRoot, ...args], { encoding: 'utf8' }); + const request = async (method: string, requestParams: Record = params) => { + const result = await runCore(['git', '--workdir', temporaryRoot], [ + Buffer.from(`${JSON.stringify({ protocolVersion: 1, kind: 'request', id: method, method, params: requestParams })}\n`), + ]); + assert.equal(result.code, 0, result.stderr.toString('utf8')); + assert.equal(result.stderr.length, 0); + const frames = result.stdout.toString('utf8').trim().split('\n').map((line) => JSON.parse(line)); + assert.deepEqual(frames[0], { protocolVersion: 1, kind: 'ready' }); + assert.equal(frames.at(-1).id, method); + assert.equal(frames.at(-1).ok, true, JSON.stringify(frames.at(-1))); + return frames; + }; + try { + git(['init', '--quiet']); + git(['config', 'core.autocrlf', 'false']); + await writeFile(path.join(temporaryRoot, 'tracked.txt'), 'before\n'); + git(['add', 'tracked.txt']); + git(['-c', 'user.name=Gajae Test', '-c', 'user.email=gajae@example.test', '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'initial']); + + const created = (await request('worktree.create')).at(-1).result; + assert.equal(created.created, true); + assert.equal(created.worktree.path, coreReportedPath(await realpath(worktree))); + assert.equal((await request('worktree.create')).at(-1).result.created, false); + const listed = await request('worktree.list', {}); + assert.equal(listed.at(-1).result.count, 1); + assert.equal(listed[1].item.path, created.worktree.path); + + await writeFile(path.join(worktree, 'new file.txt'), 'new file\n'); + assert.equal((await request('status')).at(-1).result.clean, false); + const diff = await request('diff', { ...params, mode: 'unstaged', includeUntracked: true }); + const patch = Buffer.concat(diff.filter((frame) => frame.kind === 'chunk').map((frame) => Buffer.from(frame.data, 'base64'))).toString('utf8'); + assert.match(patch, /\+new file/u); + await rm(path.join(worktree, 'new file.txt')); + assert.equal((await request('worktree.prune', { ...params, confirmed: true })).at(-1).result.pruned, true); + assert.equal((await request('worktree.list', {})).at(-1).result.count, 0); + assert.ok(git(['show-ref', '--verify', 'refs/heads/job/job-1']).trim()); + } finally { + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); } }); -test('native PTY relays bounded input, resize, output, and shutdown lifecycle', async () => { +test('native PTY relays bounded input, resize, output, and shutdown lifecycle', { timeout: 50_000 }, async () => { + const temporaryRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), 'gajae core pty 한글 '))); + const cwdMarker = `native-cwd:${JSON.stringify(temporaryRoot)}`; const child = spawn(corePath, [ 'pty', '--', process.execPath, '-e', - 'process.stdin.pipe(process.stdout)', + [ + "process.stdin.on('data', (chunk) => {", + "console.log('native-cwd:' + JSON.stringify(process.cwd()));", + "process.stdout.write('native-child-echo:' + chunk);", + '});', + "process.stdout.write('native-child-ready\\n');", + ].join(''), ], { + cwd: temporaryRoot, stdio: ['pipe', 'pipe', 'pipe'], }); + let processClosed = false; + const closed = new Promise((resolve) => child.once('close', () => { + processClosed = true; + resolve(); + })); const frames: Array> = []; let buffered = ''; let output = ''; + const decoder = new StringDecoder('utf8'); let diagnostics = ''; + let inputSent = false; let shutdownSent = false; + let answeredCursorQueries = 0; + let phase = 'host readiness'; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk: string) => { @@ -463,56 +544,110 @@ test('native PTY relays bounded input, resize, output, and shutdown lifecycle', }); const completed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - const timer = setTimeout(() => { - child.kill('SIGKILL'); - reject(new Error('native PTY test timed out')); - }, 5_000); + let timer: NodeJS.Timeout; + let failed = false; + const fail = (reason: unknown) => { + if (failed) return; + failed = true; + clearTimeout(timer); + reject(new Error(`native PTY ${phase} failed: ${String(reason)}; ${JSON.stringify({ + output, diagnostics, buffered, frames: frames.map((frame) => frame.kind), + })}`)); + }; + const awaitPhase = (next: string) => { + phase = next; + clearTimeout(timer); + timer = setTimeout(() => fail('timed out after 10s'), 10_000); + }; + const send = (request: Record) => { + child.stdin.write(`${JSON.stringify({ protocolVersion: 1, ...request })}\n`); + }; + awaitPhase('host readiness'); child.stdout.on('data', (chunk: string) => { + if (failed) return; buffered += chunk; - while (buffered.includes('\n')) { - const newline = buffered.indexOf('\n'); - const line = buffered.slice(0, newline); - buffered = buffered.slice(newline + 1); - const frame = JSON.parse(line) as Record; - frames.push(frame); - if (frame.kind === 'ready') { - child.stdin.write(`${JSON.stringify({ - protocolVersion: 1, - method: 'pty.resize', - cols: 100, - rows: 30, - })}\n`); - child.stdin.write(`${JSON.stringify({ - protocolVersion: 1, - method: 'pty.write', - data: Buffer.from('native-pty-token\n').toString('base64'), - })}\n`); - } - if (frame.kind === 'output' && typeof frame.data === 'string') { - output += Buffer.from(frame.data, 'base64').toString('utf8'); - if (output.includes('native-pty-token') && !shutdownSent) { - shutdownSent = true; - child.stdin.write(`${JSON.stringify({ - protocolVersion: 1, - method: 'pty.shutdown', - })}\n`); - child.stdin.end(); + try { + while (buffered.includes('\n')) { + const newline = buffered.indexOf('\n'); + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + const frame = JSON.parse(line) as Record; + frames.push(frame); + if (frame.kind === 'ready') { + awaitPhase('child readiness'); + } + if (frame.kind === 'output' && typeof frame.data === 'string') { + output += decoder.write(Buffer.from(frame.data, 'base64')); + // ConPTY inherits the cursor position. Answer CSI 6 n before resize, + // which can block until that response arrives. Count over accumulated + // output so a query split across frames is answered exactly once. + const queries = output.match(/\x1b\[6n/gu)?.length ?? 0; + while (answeredCursorQueries < queries && !shutdownSent) { + answeredCursorQueries += 1; + send({ method: 'pty.write', data: Buffer.from('\x1b[1;1R').toString('base64') }); + } + if (!inputSent && output.includes('native-child-ready')) { + inputSent = true; + awaitPhase('input echo'); + send({ method: 'pty.resize', cols: 1000, rows: 30 }); + send({ method: 'pty.write', data: Buffer.from('native-pty-token\r').toString('base64') }); + } + if (output.includes('native-child-echo:native-pty-token') && output.includes(cwdMarker) && !shutdownSent) { + shutdownSent = true; + awaitPhase('shutdown'); + send({ method: 'pty.shutdown' }); + child.stdin.end(); + } } } - } + } catch (error) { fail(error); } }); - child.once('error', reject); + child.once('error', fail); + child.stdin.on('error', fail); child.once('close', (code, signal) => { clearTimeout(timer); resolve({ code, signal }); }); }); - const exit = await completed; - assert.deepEqual(exit, { code: 0, signal: null }); - assert.equal(diagnostics, ''); - assert.equal(frames[0]?.kind, 'ready'); - assert.ok(frames.some((frame) => frame.kind === 'output')); - assert.ok(frames.some((frame) => frame.kind === 'exit')); - assert.match(output, /native-pty-token/u); + const cleanup = async () => { + let forceStop: NodeJS.Timeout | undefined; + let cleanupTimeout: NodeJS.Timeout | undefined; + try { + if (!processClosed) { + // EOF lets the core terminate/reap its PTY child before removing the + // temporary cwd. Keep a bounded force-stop fallback for a broken core. + child.stdin.end(); + forceStop = setTimeout(() => child.kill('SIGKILL'), 2_000); + await Promise.race([closed, new Promise((_, reject) => { + cleanupTimeout = setTimeout(() => reject(new Error('native PTY cleanup timed out')), 5_000); + })]); + } + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); + } finally { + clearTimeout(forceStop); + clearTimeout(cleanupTimeout); + } + }; + let testFailed = false; + try { + const exit = await completed; + assert.deepEqual(exit, { code: 0, signal: null }); + assert.equal(diagnostics, ''); + assert.equal(frames[0]?.kind, 'ready'); + assert.ok(frames.some((frame) => frame.kind === 'output')); + assert.ok(frames.some((frame) => frame.kind === 'exit')); + assert.ok(inputSent, 'input must follow the child readiness marker'); + assert.ok(shutdownSent, 'the test must request shutdown after the child echo'); + assert.ok(output.includes(cwdMarker)); + assert.match(output, /native-child-echo:native-pty-token/u); + } catch (error) { + testFailed = true; + throw error; + } finally { + await cleanup().catch((error) => { + if (!testFailed) throw error; + console.error('native PTY cleanup also failed:', error); + }); + } }); diff --git a/server/gjc-delegation-executor.bun.test.ts b/server/gjc-delegation-executor.bun.test.ts index 8770c931..245f43d5 100644 --- a/server/gjc-delegation-executor.bun.test.ts +++ b/server/gjc-delegation-executor.bun.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { mkdir, mkdtemp, realpath, rm, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { mkdir, mkdtemp, realpath, readFile, writeFile } from 'node:fs/promises'; +import { delimiter, dirname, join } from 'node:path'; import { test } from 'node:test'; import { createAgentSession, discoverAuthStorage, type CreateAgentSessionOptions } from '@gajae-code/coding-agent/sdk/session'; @@ -29,6 +29,7 @@ import { installGjcCliShim } from './gjc-cli-shim.js'; import { GJC_CLEANUP_UNCONFIRMED_CODE, isGjcCleanupUnconfirmedError } from './gjc-cleanup-error.js'; import { GjcWorkerHost } from './gjc-worker.js'; import { GJC_WORKER_PROTOCOL_VERSION, type GjcWorkerRequestFrame } from './gjc-worker-protocol.js'; +import { removeSdkFixture } from './gjc-sdk-fixture-cleanup.js'; type Session = Awaited>['session']; type Snapshot = { id: string; status: string; resultText: string }; @@ -144,6 +145,7 @@ async function fixture( execute: async () => ({ content: [{ type: 'text', text: 'app-override-canary' }] }), }], enableMcpAutoload: false, enableLsp: false, skipPythonPreflight: true, disableExtensionDiscovery: true, + sdkHostModeSupported: false, skills: [], rules: [], contextFiles: [], promptTemplates: [], slashCommands: [], systemPrompt: ['Offline SDK delegation contract.'], }; @@ -177,11 +179,31 @@ async function fixture( return { ...current, base, root, calls, children, childInputs, createParent, authStorage, registry, settings, credential, transportErrors, browserCalls: () => browserCalls, async close() { - await Promise.all(executors.map((executor) => executor.dispose())); - for (const session of allRoots) await session.dispose(); - await registry.dispose(); authStorage.close(); await settings.close(); - unregisterCustomApis(root); - await rm(root, { recursive: true, force: true }); + const errors: unknown[] = []; + for (const result of await Promise.allSettled(executors.map((executor) => executor.dispose()))) { + if (result.status === 'rejected') errors.push(result.reason); + } + let sessionsDisposed = true; + for (const session of new Set([...allRoots, ...children])) { + try { await session.dispose(); } + catch (error) { sessionsDisposed = false; errors.push(error); } + } + // A rejected public deadline can leave SDK teardown running. Keep its + // shared stores and root intact; the failed test must not race that owner. + if (!sessionsDisposed) { + throw new AggregateError(errors, `SDK session disposal unconfirmed; retained fixture root: ${root}`); + } + for (const cleanup of [ + () => registry.dispose(), + () => authStorage.close(), + () => settings.close(), + () => unregisterCustomApis(root), + () => removeSdkFixture(root), + ]) { + try { await cleanup(); } + catch (error) { errors.push(error); } + } + if (errors.length) throw new AggregateError(errors, 'SDK delegation fixture cleanup failed.'); }, }; } @@ -216,6 +238,7 @@ test('real SDK children retain parent permissions, exact Astra identity and app- : { outcome: 'selected', optionId: 'reject_once', kind: 'reject_once' }; }); try { + f.base.sdkHostModeSupported = true; f.parent.settings.override('task.agentModelOverrides', { executor: 'unavailable/unsafe' }); await assert.rejects(f.parent.getToolForExecution('bash')!.execute('parent-denied', { command: 'printf delegation-bypass-canary' }), /rejected/); const [started] = await tool(f.parent, 'task', task()); @@ -231,6 +254,7 @@ test('real SDK children retain parent permissions, exact Astra identity and app- assert.ok(results.some((message) => !message.isError && JSON.stringify(message.content).includes('app-override-canary'))); assert.equal(f.childInputs[0]!.automationTools, f.base.automationTools); assert.equal(f.childInputs[0]!.spawns, 'deny'); + assert.equal(f.childInputs[0]!.sdkHostModeSupported, false, 'app-owned children never expose a second SDK control endpoint'); assert.ok(!f.childInputs[0]!.toolNames!.includes('task')); assert.equal(f.children[0]!.sessionManager.getSessionId(), f.children[0]!.agent.sessionId); assert.notEqual(f.children[0]!.credentialSessionId, f.parent.credentialSessionId); @@ -238,6 +262,141 @@ test('real SDK children retain parent permissions, exact Astra identity and app- } finally { await f.close(); } }); +test('delegated model selection overrides only the child runtime role', { timeout: 30_000 }, async () => { + const f = await fixture(); + const roles = { + default: 'openai-codex/gpt-6-parent', + planner: 'openai-codex/gpt-6-astra', + }; + const configPath = join(f.root, 'agent', 'config.yml'); + try { + f.settings.set('modelRoles', roles); + await f.settings.flushOrThrow(); + const before = await readFile(configPath); + const [started] = await tool(f.parent, 'task', task()); + const [settled] = await tool(f.parent, 'subagent', { action: 'await', id: started!.id }); + assert.equal(settled!.status, 'completed', JSON.stringify(settled)); + assert.equal(f.children[0]!.settings.getModelRole('default'), 'openai-codex/gpt-6-astra'); + assert.equal(f.children[0]!.settings.getModelRole('planner'), roles.planner); + assert.equal(f.children[0]!.model?.provider, 'openai-codex'); + assert.equal(f.children[0]!.model?.id, 'gpt-6-astra'); + assert.equal(f.children[0]!.thinkingLevel, 'xhigh'); + assert.equal(f.parent.settings.getModelRole('default'), roles.default); + assert.deepEqual(f.settings.getGlobal('modelRoles'), roles); + assert.deepEqual(await readFile(configPath), before); + } finally { await f.close(); } +}); + +test('delegated settings flush completes before the child becomes reusable', { timeout: 30_000 }, async () => { + const entered = deferred(); + const release = deferred(); + let childSettings: Settings | undefined; + let originalFlush: (() => Promise) | undefined; + let flushes = 0; + const f = await fixture(undefined, undefined, (options) => { + const settings = options.settings; + assert.ok(settings); + childSettings = settings; + originalFlush = settings.flushOrThrow.bind(settings); + settings.flushOrThrow = async () => { + flushes += 1; + entered.resolve(); + await release.promise; + await originalFlush!(); + }; + return options; + }); + try { + const [started] = await tool(f.parent, 'task', task()); + const awaiting = tool(f.parent, 'subagent', { action: 'await', id: started!.id }); + await entered.promise; + let settled = false; + void awaiting.then(() => { settled = true; }); + await Promise.resolve(); + assert.equal(settled, false); + release.resolve(); + const [snapshot] = await awaiting; + assert.equal(snapshot!.status, 'completed', JSON.stringify(snapshot)); + assert.equal(flushes, 1); + } finally { + release.resolve(); + if (childSettings && originalFlush) childSettings.flushOrThrow = originalFlush; + await f.close(); + } +}); + +test('fixture retains shared stores and root until session disposal is confirmed', { timeout: 30_000 }, async () => { + const f = await fixture(); + const originalDispose = f.parent.dispose.bind(f.parent); + const failure = new Error('Fixture session disposal is unconfirmed.'); + const closed: string[] = []; + const disposeRegistry = f.registry.dispose.bind(f.registry); + const closeAuth = f.authStorage.close.bind(f.authStorage); + const closeSettings = f.settings.close.bind(f.settings); + f.registry.dispose = async () => { closed.push('registry'); await disposeRegistry(); }; + f.authStorage.close = () => { closed.push('auth'); return closeAuth(); }; + f.settings.close = async () => { closed.push('settings'); await closeSettings(); }; + f.parent.dispose = async () => { throw failure; }; + try { + await assert.rejects(f.close(), (error: unknown) => error instanceof AggregateError + && error.errors.includes(failure) + && error.message.includes(f.root)); + assert.deepEqual(closed, []); + assert.equal(await realpath(f.root), f.root); + } finally { + f.parent.dispose = originalDispose; + await f.close(); + } + assert.deepEqual(closed, ['registry', 'auth', 'settings']); + await assert.rejects(realpath(f.root), { code: 'ENOENT' }); +}); + +test('delegated SDK creation failure flushes and closes the unowned child scope', { timeout: 30_000 }, async () => { + let flushes = 0; + const f = await fixture(undefined, undefined, async (options) => { + const settings = options.settings; + assert.ok(settings); + const originalFlush = settings.flushOrThrow.bind(settings); + settings.flushOrThrow = async () => { + flushes += 1; + await originalFlush(); + }; + throw new Error('SDK child creation failed'); + }); + try { + const [started] = await tool(f.parent, 'task', task()); + const [failed] = await tool(f.parent, 'subagent', { action: 'await', id: started!.id }); + assert.equal(failed!.status, 'failed', JSON.stringify(failed)); + assert.equal(flushes, 1); + } finally { await f.close(); } +}); + +test('delegated settings flush failure fences executor reuse', { timeout: 30_000 }, async () => { + let childSettings: Settings | undefined; + let originalFlush: (() => Promise) | undefined; + const f = await fixture(undefined, undefined, (options) => { + const settings = options.settings; + assert.ok(settings); + childSettings = settings; + originalFlush = settings.flushOrThrow.bind(settings); + settings.flushOrThrow = async () => { throw new Error('settings flush failure'); }; + return options; + }); + try { + const [started] = await tool(f.parent, 'task', task()); + const [failed] = await tool(f.parent, 'subagent', { action: 'await', id: started!.id }); + assert.equal(failed!.status, 'failed', JSON.stringify(failed)); + await assert.rejects(f.executor.dispose(), /cleanup failed/); + await assert.rejects(tool(f.parent, 'task', task()), /App delegation cancelled/); + } finally { + if (childSettings && originalFlush) childSettings.flushOrThrow = originalFlush; + await assert.rejects(f.close(), (error: unknown) => error instanceof AggregateError + && error.errors.length === 1 + && error.errors[0] instanceof Error + && error.errors[0].message === 'App delegation cleanup failed.'); + } +}); + test('saved children resume only under their owning parent with freshly applied policy', { timeout: 30_000 }, async () => { const f = await fixture(); try { @@ -969,7 +1128,7 @@ test('native Ralplan consumes app-owned role artifacts and resumed review lanes try { const cwd = f.base.cwd!; await writeFile(join(cwd, 'requirements.md'), 'Invariant: deny remains denied. Verification: test the child policy.\n'); - const environment = { ...process.env }; + const environment = { ...process.env, PATH: process.env.PATH ?? '' }; assert.ok(installGjcCliShim({ env: environment, homeDir: f.root, bunPath: process.execPath })); toolPath = environment.PATH!; const owner = f.parent.sessionManager.getSessionId(); @@ -1062,7 +1221,7 @@ test('delegated ask cannot escape the owner Ultragoal guard through a distinct c execute: async () => { asks += 1; return { content: [{ type: 'text', text: 'User question reached.' }] }; }, }); const { parent } = await f.createParent(); - const environment = { ...process.env }; + const environment = { ...process.env, PATH: process.env.PATH ?? '' }; assert.ok(installGjcCliShim({ env: environment, homeDir: f.root, bunPath: process.execPath })); const created = jsonOutput(await nativeBash(parent, 'gjc ultragoal create-goals --brief "Keep work in the owner workflow" --json', { PATH: environment.PATH! })); @@ -1156,9 +1315,9 @@ test('native Ultragoal validates independently produced app-lane evidence and cr f.base.toolNames!.push('write'); const { parent } = await f.createParent(); const owner = parent.sessionManager.getSessionId(); - const environment = { ...process.env }; + const environment = { ...process.env, PATH: process.env.PATH ?? '' }; assert.ok(installGjcCliShim({ env: environment, homeDir: f.root, bunPath: process.execPath })); - toolPath = `${dirname(process.execPath)}:${environment.PATH!}`; + toolPath = `${dirname(process.execPath)}${delimiter}${environment.PATH!}`; const env = { PATH: toolPath }; const create = jsonOutput(await nativeBash(parent, 'gjc ultragoal create-goals --brief "Verify the accepted fixture CLI output contract" --json', env)); assert.equal(create.ok, true); @@ -1293,7 +1452,7 @@ test('final Codex provider tool schema permits default Planner work and nullable executionMode: 'default', repositoryBinding: binding }] }); const [boundSettled] = await tool(f.parent, 'subagent', { action: 'await', id: bound!.id }); assert.equal(boundSettled!.status, 'completed'); - assert.ok(f.calls[2]!.context.systemPrompt?.some((block) => block.includes(binding.worktreeRoot))); + assert.ok(f.calls[2]!.context.systemPrompt?.some((block) => block.includes(JSON.stringify(binding.worktreeRoot)))); await assert.rejects(tool(f.parent, 'task', { agent: 'planner', tasks: [{ ...task().tasks[0], executionMode: 'ultragoal-red-team' }] }), /Red-team execution mode requires the executor role/); assert.equal(f.childInputs.length, 3, 'invalid red-team mode must not create another child'); diff --git a/server/gjc-delegation-executor.ts b/server/gjc-delegation-executor.ts index 60993fb2..b3abaf38 100644 --- a/server/gjc-delegation-executor.ts +++ b/server/gjc-delegation-executor.ts @@ -72,6 +72,7 @@ type Job = { owner: Owner; controller: AbortController; session?: Session; + manager?: SessionManager; abortTask?: Promise; done: Promise; settled: boolean; @@ -312,6 +313,7 @@ export class GjcDelegationExecutor { async #run(job: Job, message: string, resume: boolean): Promise { let unsubscribe: (() => void) | undefined; + let settings: Awaited> | undefined; const timeout = setTimeout(() => { void this.#cancel(job.receipt.id).catch(() => {}); }, GJC_DELEGATION_LIMITS.runtimeMs); try { this.#checkOwner(job.owner, job.controller.signal); @@ -332,7 +334,7 @@ export class GjcDelegationExecutor { ? { kind: 'id' as const, value: String(selectedRow) } : authStorage?.hasSessionCredentialAuto(model.provider, parent.credentialSessionId) ? undefined : base.credentialSelector?.selector); - const settings = await parent.settings.cloneForCwd(parent.sessionManager.getCwd()); + settings = await parent.settings.cloneForCwd(parent.sessionManager.getCwd()); // Delegated work never starts independent goal loops or background model roles. settings.override('goal.enabled', false); settings.override('memory.enabled', false); @@ -342,7 +344,10 @@ export class GjcDelegationExecutor { settings.override('mcp.enableProjectConfig', false); settings.override('astEdit.enabled', false); settings.override('task.eager', false); - settings.setModelRole('default', `${model.provider}/${model.id}`); + // Delegation pins the child's default role for this run only. Updating + // the global role here would enqueue a debounced config.yml write even + // though the child is not allowed to change the user's model defaults. + settings.overrideModelRoles({ default: `${model.provider}/${model.id}` }); const directory = join(this.options.parent.getSessionDir(), '.app-delegation', job.receipt.root, job.receipt.owner); let manager: SessionManager; if (resume) { @@ -353,11 +358,13 @@ export class GjcDelegationExecutor { const contained = relative(canonicalRoot, canonicalFile); if (contained.startsWith(`..${sep}`) || contained === '..' || resolve(canonicalFile) !== file) throw new Error('Invalid child transcript.'); manager = await SessionManager.open(file, directory); + job.manager = manager; if (manager.getSessionId() !== job.receipt.childSessionId || manager.getCwd() !== parent.sessionManager.getCwd()) { throw new Error('Child transcript identity mismatch.'); } } else { manager = SessionManager.create(parent.sessionManager.getCwd(), directory); + job.manager = manager; job.receipt.childSessionId = manager.getSessionId(); job.receipt.file = basename(manager.getSessionFile()!); } @@ -417,6 +424,7 @@ export class GjcDelegationExecutor { toolNames: allowed.filter((name) => !GJC_APP_DELEGATION_TOOL_NAMES.includes(name as 'task' | 'subagent')), customTools, spawns: 'deny', taskDepth: childOwner.depth, currentAgentType: job.receipt.agent, enableMcpAutoload: false, disableExtensionDiscovery: true, + sdkHostModeSupported: false, extensions: [enforceAllowlist], additionalExtensionPaths: [], hookPaths: [], preloadedExtensions: undefined, discoverableToolAllowedNames: [], requireYieldTool: false, outputSchema: undefined, goalToolAllowedOps: [], masterModeContext: undefined, @@ -428,6 +436,8 @@ export class GjcDelegationExecutor { 'Use only the supplied tools. Return final text directly; yield and IRC are unavailable. Goal lifecycle remains owned by the root app session.'], }); job.session = output.session; + // AgentSession now owns this manager and closes it with the session. + job.manager = undefined; this.#checkOwner(job.owner, job.controller.signal); checkSignal(job.controller.signal); checkSignal(this.#closed.signal); @@ -474,7 +484,14 @@ export class GjcDelegationExecutor { } try { await job.session.dispose(); } catch { this.#cleanupFailed = true; job.receipt.status = 'failed'; job.receipt.resultText = 'Delegated session cleanup failed.'; } + } else if (job.manager) { + try { await job.manager.close(); } + catch { this.#cleanupFailed = true; job.receipt.status = 'failed'; job.receipt.resultText = 'Delegated session cleanup failed.'; } } + // Settings clones share the parent's storage but own their pending-save + // queue; drain the clone after the child session's final writer. + try { await settings?.flushOrThrow(); } + catch { this.#cleanupFailed = true; job.receipt.status = 'failed'; job.receipt.resultText = 'Delegated session cleanup failed.'; } if (job.controller.signal.aborted || this.#closed.signal.aborted) job.receipt.status = 'cancelled'; job.owner.manager.appendCustomEntry(RECEIPT, { ...job.receipt }); await job.owner.manager.flush(); diff --git a/server/gjc-engine-manifest.json b/server/gjc-engine-manifest.json index 111f028a..3f4233cf 100644 --- a/server/gjc-engine-manifest.json +++ b/server/gjc-engine-manifest.json @@ -58,6 +58,7 @@ "server/gjc-sdk-bridge.test.ts", "server/gjc-sdk-client.test.ts", "server/gjc-sdk-contract.bun.test.ts", + "server/gjc-sdk-fixture-cleanup.ts", "server/gjc-sdk-workflow-identity.bun.test.ts", "server/gjc-session-state.test.ts", "server/gjc-session-worktree-contract.bun.test.ts", diff --git a/server/gjc-engine.ts b/server/gjc-engine.ts index b8edb708..39b0591e 100644 --- a/server/gjc-engine.ts +++ b/server/gjc-engine.ts @@ -74,8 +74,10 @@ export type { GjcPermissionMode, GjcRunPermissions } from './gjc-permission-poli // application instead of outliving it. export { createWindowsJobLaunch, + killWindowsJobGuard, GJC_WINDOWS_JOB_GUARD_ACK, GJC_WINDOWS_JOB_GUARD_READY, + type WindowsJobLaunch, } from './gjc-windows-job.js'; // The fixed failure surface for a run whose model cannot be paired with a diff --git a/server/gjc-runtime-manifest.json b/server/gjc-runtime-manifest.json index 169de93d..cafce00e 100644 --- a/server/gjc-runtime-manifest.json +++ b/server/gjc-runtime-manifest.json @@ -56,6 +56,30 @@ "sha256": "7332a76de7195891429bf759c00737aef4f0b158b727c3b81cb920defe867e1f" } ] + }, + "win32-x64": { + "files": [ + { + "package": "@gajae-code/natives-win32-x64", + "path": "native/pi_natives.win32-x64-baseline.node", + "sha256": "682f8e1a4c6e0239f89336d9e1cdb466d2ff1b13af3b5635119a43bbae7098b6" + }, + { + "package": "@gajae-code/natives", + "path": "native/embedded-addon.js", + "sha256": "0ee3be1ce9f174e0c3905bfda78f665d6c177b0e953303b35ff5c5b7735552db" + }, + { + "package": "@gajae-code/natives", + "path": "native/index.js", + "sha256": "516e4316618c77843058bc7f1c1c723ce8d24aa0e1c90fbea64ed20ace71a863" + }, + { + "package": "@gajae-code/natives", + "path": "native/loader-state.js", + "sha256": "7332a76de7195891429bf759c00737aef4f0b158b727c3b81cb920defe867e1f" + } + ] } } } diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 6af7cccf..e98248ea 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -15,7 +15,7 @@ import { registerCustomApi, unregisterCustomApis } from '@gajae-code/ai/api-regi import { AssistantMessageEventStream } from '@gajae-code/ai/utils/event-stream'; import type { AssistantMessage, Context } from '@gajae-code/ai/types'; - +import { removeSdkFixture } from './gjc-sdk-fixture-cleanup.js'; import { GJC_APP_BUILTIN_COMMANDS, GJC_APP_BUILTIN_COMMAND_ALIASES, @@ -132,6 +132,8 @@ test('runtime aliases with text handlers are dispatchable but not advertised', ( /** Scriptable SDK-shaped session; prompt owns the turn lifetime exactly as production does. */ class FakeAgentSession { + constructor(readonly sessionManager: SessionManager) {} + readonly sessionFile = 'fake-session.jsonl'; readonly promptStarted = deferred(); readonly abortStarted = deferred(); @@ -203,6 +205,7 @@ class FakeAgentSession { } async dispose(): Promise { this.disposed = true; + await this.sessionManager.close(); if (this.disposeError) throw this.disposeError; } async setModelTemporary(model: unknown, thinkingLevel: unknown, options: unknown): Promise { @@ -298,7 +301,8 @@ async function fixture( }; const factory = (async (input: Record) => { factoryOptions.push(input); - const session = new FakeAgentSession(); + assert.ok(input.sessionManager instanceof SessionManager); + const session = new FakeAgentSession(input.sessionManager); sessions.push(session); return { session, setToolUIContext: session.setToolUIContext.bind(session) }; }) as unknown as GjcAgentSessionFactory; @@ -311,6 +315,7 @@ async function fixture( getModelRole: () => defaultModel || undefined, override: (key: string, value: unknown) => { overrides.set(key, value); }, get: (key: string) => overrides.get(key), + flushOrThrow: async () => undefined, }); const settings = { getModelRole: () => defaultModel || undefined, @@ -345,6 +350,7 @@ async function fixture( function methods(frames: Array>): string[] { return frames.filter((frame) => frame.kind === 'event').map((frame) => frame.method as string); } function response(frames: Array>, id: string): Record { return frames.find((frame) => frame.kind === 'response' && frame.id === id)!; } + async function firstSession(sessions: FakeAgentSession[]): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { if (sessions[0]) return sessions[0]; @@ -359,7 +365,7 @@ type ProductionWorkerResult = { }; async function runProductionWorker(env: NodeJS.ProcessEnv = {}): Promise { - const bun = join(process.cwd(), 'dist-native', 'bun'); + const bun = join(process.cwd(), 'dist-native', process.platform === 'win32' ? 'bun.exe' : 'bun'); const worker = join(process.cwd(), 'server', 'gjc-bun-worker.ts'); const home = await mkdtemp(join(tmpdir(), 'gjc-worker-home-')); const agentDirectory = env.GJC_WORKER_AGENT_DIR ?? join(home, 'agent'); @@ -1299,7 +1305,9 @@ test('resume opens the sole exact session file and never re-emits session.create /** Real SDK construction; prompts are intercepted before any model transport can run. */ async function identityFixture() { - const root = await mkdtemp(join(tmpdir(), 'gjc-sdk-identity-')); + // SDK goal control and repository bindings use canonical directories. Match + // their identity before opening any stores, including Windows short TEMP paths. + const root = await realpath(await mkdtemp(join(tmpdir(), 'gjc-sdk-identity-'))); const cwd = join(root, 'project'); const agentDir = join(root, 'agent'); await mkdir(cwd); @@ -1376,15 +1384,16 @@ async function identityFixture() { }, async close() { for (const session of sessions) await session.dispose(); + await host.close(); await registry.dispose(); authStorage.close(); await settings.close(); - await rm(root, { recursive: true, force: true }); + await removeSdkFixture(root); }, }; } -test('goal-capable production sessions delegate safely and defer worktree abort to their owner', async () => { +test('goal-capable production sessions delegate safely and defer worktree abort to their owner', { timeout: 15_000 }, async () => { const f = await identityFixture(); Object.assign(f.options, { toolNames: ['read', 'task', 'subagent'], spawns: '*', goalUiVersion: 1, goalOwner: 'number:1' }); try { @@ -1514,10 +1523,14 @@ test('app-shaped real SDK sessions isolate async ownership and reject duplicate assert.notEqual(first.manager, second.manager); assert.equal(AsyncJobManager.forEndpoint(first.id), first.manager); const duplicate = await SessionManager.open(sessionA.sessionFile!, a.options.sessionRoot); - await assert.rejects( - createAgentSession({ ...a.factoryOptions[0], sessionManager: duplicate }), - /endpoint id is already held by another live async job manager/, - ); + try { + await assert.rejects( + createAgentSession({ ...a.factoryOptions[0], sessionManager: duplicate }), + /endpoint id is already held by another live async job manager/, + ); + } finally { + await duplicate.close(); + } assert.equal(AsyncJobManager.forEndpoint(first.id), first.manager); assert.equal(AsyncJobManager.forEndpoint(second.id), second.manager); assert.equal(AsyncJobManager.instance(), second.manager, @@ -1562,6 +1575,8 @@ test('app-shaped real SDK handoff rekeys logical ownership while retaining provi await f.run('identity-handoff-resume', async (session) => { assert.equal((await assertSdkIdentity(session)).id, successorId); }, successorId); + assert.ok(f.factoryOptions.every((options) => options?.sdkHostModeSupported === false)); + await assert.rejects(readFile(join(f.root, 'agent', 'sdk', 'broker.json')), { code: 'ENOENT' }); } finally { unregisterCustomApis(f.root); await f.close(); } }); @@ -1594,12 +1609,16 @@ async function rawSdkDelegationFixture() { model: registry.find('openai-codex', 'gpt-6-astra'), thinkingLevel: 'xhigh', sessionManager: SessionManager.create(cwd, join(root, 'sessions')), toolNames: ['bash', 'task', 'subagent'], spawns: 'executor', + sdkHostModeSupported: false, enableMcpAutoload: false, enableLsp: false, skipPythonPreflight: true, disableExtensionDiscovery: true, skills: [], rules: [], contextFiles: [], promptTemplates: [], slashCommands: [], }); return { root, session, async close() { - await session.dispose(); await registry.dispose(); authStorage.close(); await settings.close(); - await rm(root, { recursive: true, force: true }); + await session.dispose(); + await registry.dispose(); + authStorage.close(); + await settings.close(); + await removeSdkFixture(root); } }; } @@ -1773,6 +1792,7 @@ test('settings loader resolves the current default model role for each run', asy getModelRole: () => `contract-provider/${modelId}`, override: () => undefined, get: () => undefined, + flushOrThrow: async () => undefined, }), }); const f = await fixture( @@ -2233,6 +2253,108 @@ test('successful chat completion waits for SDK session cleanup', async () => { } finally { release.resolve(); await f.close(); } }); +test('successful chat completion waits for scoped settings writes', async () => { + const f = await fixture(); + const release = deferred(); + let flushing = false; + const run = f.host.handle(request('session.start', 'flush-before-complete', { message: 'hello', options: f.options })); + try { + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + const settings = f.factoryOptions[0]!.settings as Settings; + settings.flushOrThrow = async () => { flushing = true; await release.promise; }; + session.complete(); + await waitFor(() => flushing || undefined); + assert.equal(session.disposed, true, 'the final session writer stops before its settings drain'); + assert.equal(methods(f.frames).includes('turn.completed'), false); + release.resolve(); + await run; + assert.equal(methods(f.frames).filter(method => method === 'turn.completed').length, 1); + assert.equal((response(f.frames, 'flush-before-complete').payload as { ok: boolean }).ok, true); + } finally { + release.resolve(); + await run; + await f.close(); + } +}); + +for (const phase of ['construction', 'setup'] as const) { + test(`SDK ${phase} failure closes the manager exactly once and drains its clone`, async () => { + const f = await fixture(); + const originalFactory = f.adapter['options'].createSessionFactory!; + let closes = 0; + let flushes = 0; + f.adapter['options'].createSessionFactory = async (input) => { + assert.ok(input?.sessionManager); + assert.ok(input.settings); + const manager = input.sessionManager; + const close = manager.close.bind(manager); + manager.close = async () => { closes += 1; await close(); }; + input.settings.flushOrThrow = async () => { flushes += 1; }; + if (phase === 'construction') throw new Error('SDK construction failed'); + return { ...await originalFactory(input), modelFallbackMessage: 'Unexpected model fallback' }; + }; + try { + const id = `ownership-${phase}`; + await f.host.handle(request('session.start', id, { message: 'hello', options: f.options })); + assert.equal(closes, 1, 'the manager has one owner on either side of SDK construction'); + assert.equal(flushes, 1); + assert.equal(f.sessions.length, phase === 'setup' ? 1 : 0); + if (phase === 'setup') assert.equal(f.sessions[0]!.disposed, true); + const payload = response(f.frames, id).payload as { ok: boolean; error: { code: string } }; + assert.equal(payload.ok, false); + assert.notEqual(payload.error.code, GJC_CLEANUP_UNCONFIRMED_CODE, + 'successful teardown preserves an ordinary startup failure'); + assert.equal(methods(f.frames).includes('turn.completed'), false); + } finally { await f.close(); } + }); +} + +for (const phase of ['construction', 'prompt'] as const) { + test(`scoped settings flush failure after ${phase} fences worker reuse`, async () => { + const f = await fixture(); + const originalFactory = f.adapter['options'].createSessionFactory!; + const originalError = console.error; + const diagnostics: unknown[][] = []; + let factoryCalls = 0; + let flushCalls = 0; + console.error = (...args: unknown[]) => { diagnostics.push(args); }; + f.adapter['options'].createSessionFactory = async (input) => { + factoryCalls += 1; + assert.ok(input?.settings); + input.settings.flushOrThrow = async () => { + flushCalls += 1; + throw new Error('private settings failure detail'); + }; + if (phase === 'construction') throw new Error('SDK construction failed'); + return originalFactory(input); + }; + try { + const id = `flush-fails-${phase}`; + const run = f.host.handle(request('session.start', id, { message: 'hello', options: f.options })); + if (phase === 'prompt') { + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + session.complete(); + } + await run; + assert.equal(flushCalls, 1); + assert.equal(((response(f.frames, id).payload as Record).error as { code: string }).code, + GJC_CLEANUP_UNCONFIRMED_CODE); + assert.equal(methods(f.frames).includes('turn.completed'), false); + assert.equal(JSON.stringify(f.frames).includes('private settings failure detail'), false); + assert.deepEqual(diagnostics, phase === 'prompt' ? [['GJC SDK session disposal failed.']] : []); + await f.host.handle(request('session.start', `${id}-reuse`, { message: 'again', options: f.options })); + assert.equal(factoryCalls, 1, 'an unflushed owner prevents another SDK session from being created'); + assert.equal(((response(f.frames, `${id}-reuse`).payload as Record).error as { code: string }).code, + GJC_CLEANUP_UNCONFIRMED_CODE); + } finally { + console.error = originalError; + await f.close(); + } + }); +} + test('explicit SDK configuration rejects missing fields, unresolvable credentials, and model mismatches without invoking the factory', async () => { const f = await fixture(); try { @@ -2418,7 +2540,9 @@ test('the SDK runtime bootstrap initializes the theme before any session can ask // would leave every option-bearing ask crashing again with a passing suite. assert.match(bootstrap, /ensureSdkThemeInitialized\(\)/u); }); -test('production Bun worker verifies the manifest before accepting initialize and shuts down over stdio', async () => { +// Production initialization includes online model discovery (commonly 4-8 s), +// so Bun's default five-second test deadline is shorter than a healthy start. +test('production Bun worker verifies the manifest before accepting initialize and shuts down over stdio', { timeout: 65_000 }, async () => { const agentDirectory = await mkdtemp(join(tmpdir(), 'gjc-agent-')); try { const result = await runProductionWorker({ @@ -2434,7 +2558,7 @@ test('production Bun worker verifies the manifest before accepting initialize an } }); -test('production Bun worker rejects a tampered test-only manifest override', async () => { +test('production Bun worker rejects a tampered test-only manifest override', { timeout: 65_000 }, async () => { const directory = await mkdtemp(join(tmpdir(), 'gjc-manifest-')); const manifestPath = join(directory, 'gjc-runtime-manifest.json'); try { diff --git a/server/gjc-sdk-fixture-cleanup.ts b/server/gjc-sdk-fixture-cleanup.ts new file mode 100644 index 00000000..04bd7bd9 --- /dev/null +++ b/server/gjc-sdk-fixture-cleanup.ts @@ -0,0 +1,33 @@ +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { closeModelCache } from '@gajae-code/ai/model-cache'; + +/** Call only after every fixture session, registry, auth store and Settings owner has closed. */ +export async function removeSdkFixture(root: string): Promise { + // The model cache is process-scoped, not owned by ModelRegistry.dispose(). + // Never close another fixture's active cache when roots overlap in time. + const modelCacheClosed = closeModelCache(join(root, 'agent', 'models.db')); + if (process.platform === 'win32') { + const bun = (globalThis as typeof globalThis & { Bun?: { gc(force?: boolean): void } }).Bun; + if (!bun) throw new Error('Windows SDK fixtures require Bun.gc(true).'); + // Bun 1.4.0 defers close(false) until uncached SQLite statements finalize. + // Collect from a fresh task after all supported owner closes, not from a + // deep disposal stack. This is finalization, never a filesystem retry. + await new Promise((resolve, reject) => { + setTimeout(() => { + try { bun.gc(true); resolve(); } + catch (error) { reject(error); } + }, 0); + }); + } + try { + await rm(root, { recursive: true, force: true }); + } catch (error) { + const remaining = await readdir(root, { recursive: true }) + .catch((listingError: unknown) => [`Cannot list retained files: ${String(listingError)}`]); + throw new Error(`SDK fixture cleanup failed: ${JSON.stringify({ + modelCacheClosed, cwd: process.cwd(), remaining: remaining.slice(0, 50), remainingCount: remaining.length, + })}`, { cause: error }); + } +} diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index d97d8bdf..99b5f929 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -1,14 +1,31 @@ import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import { test } from 'node:test'; import { gunzipSync } from 'node:zlib'; import { createWindowsJobLaunch, + encodeWindowsPowerShellCommand, + killWindowsJobGuard, GJC_WINDOWS_JOB_GUARD_ACK, GJC_WINDOWS_JOB_GUARD_READY, quoteWindowsArgument, + windowsCodeDomCompileScript, + windowsCodeDomLabelValidationScript, + windowsCodeDomPathValidationScript, } from './gjc-windows-job.js'; +test('compressed PowerShell transport preserves large Unicode scripts below the Windows argv limit', () => { + const source = `${windowsCodeDomCompileScript('public class Probe {}', true)}\n# 가재\n`; + const encoded = encodeWindowsPowerShellCommand(source); + assert.ok(encoded.length < 30_000); + const loader = Buffer.from(encoded, 'base64').toString('utf16le'); + const compressed = loader.match(/FromBase64String\('([^']+)'\)/u)?.[1]; + assert.ok(compressed); + assert.equal(gunzipSync(Buffer.from(compressed, 'base64')).toString('utf8'), source); +}); + test('quotes Windows argv values without losing quotes or trailing slashes', () => { assert.equal(quoteWindowsArgument('plain'), 'plain'); assert.equal(quoteWindowsArgument(''), '""'); @@ -20,6 +37,71 @@ test('quotes Windows argv values without losing quotes or trailing slashes', () ); }); +test('CodeDom compilation uses explicit private temp files with the original elevated protections', () => { + const script = windowsCodeDomCompileScript('public class PrivateCompilerFixture {}'); + assert.match(script, /\[GajaeCodeDomFileApi\]::CreateDirectoryW\(\$compilerTemp, \$compilerAttributesPointer\)/); + assert.match(script, /RawSecurityDescriptor\]::new\(\$compilerSddl\)/); + assert.match(script, /GetField\('SetLastError'\)/); + assert.match(script, /CharSet\]::Unicode/); + assert.match(script, /GetFileSecurityW\(\$compilerTemp, 0x14/); + assert.match(script, /D:\(D;OI;SD;;;/); + assert.match(script, /\(A;OICI;FA;;;BA\)S:\(ML;OI;NW;;;HI\)/); + assert.match(script, /GenerateInMemory = \$true/); + assert.match(script, /'System.dll', 'System.Core.dll'/); + assert.match(script, /TempFileCollection\]::new\(\$compilerPath, \$false\)/); + assert.match(script, /Add-Type -CompilerParameters \$compilerParameters/); + assert.doesNotMatch(script, /DisableTempFileCollectionDirectoryFeature|SetSwitch|junction/i); + assert.ok(script.indexOf('GetBinaryForm($compilerDescriptorBytes, 0)') < script.indexOf('[GajaeCodeDomFileApi]::CreateDirectoryW')); + assert.ok(script.indexOf('[IO.Directory]::SetAccessControl') < script.indexOf('$compilerParameters.TempFiles.Delete()')); + assert.ok(script.indexOf('$compilerParameters.TempFiles.Delete()') < script.indexOf('[IO.Directory]::Delete')); +}); + +test('compiler filenames use a verified same-directory alias and restore process TEMP before workers launch', () => { + const script = windowsCodeDomCompileScript('public class CompilerPathFixture {}', true); + assert.ok(script.includes(windowsCodeDomPathValidationScript())); + assert.match(script, /GetShortPathNameW\(\$compilerTemp,/); + assert.match(script, /GetLongPathNameW\(\$compilerPath,/); + assert.match(script, /Assert-GajaeCompilerPath \$compilerTemp \$compilerPath \$compilerLongPath/); + assert.match(script, /OutputAssembly = \[IO.Path\]::Combine\(\$compilerPath,/); + assert.match(script, /TempFiles.AddFile\(\$compilerParameters.OutputAssembly, \$false\)/); + assert.match(script, /short-name generation may be disabled/); + assert.match(script, /SetEnvironmentVariable\('TEMP', \$compilerPath, 'Process'\)/); + assert.match(script, /SetEnvironmentVariable\('TMP', \$compilerPath, 'Process'\)/); + assert.match(script, /SetEnvironmentVariable\('TEMP', \$compilerOriginalTemp, 'Process'\)/); + assert.match(script, /SetEnvironmentVariable\('TMP', \$compilerOriginalTmp, 'Process'\)/); + assert.doesNotMatch(script, /SetEnvironmentVariable\([^\n]+, '(?:User|Machine)'\)/); + assert.ok(script.indexOf('$compilerElevated -and -not $compilerActualLabels.hasHighLabel') + < script.indexOf('$compilerPath = Assert-GajaeCompilerPath')); + assert.ok(script.indexOf('$compilerPath = Assert-GajaeCompilerPath') < script.indexOf('Add-Type -CompilerParameters')); + assert.ok(script.indexOf("SetEnvironmentVariable('TEMP', $compilerOriginalTemp, 'Process')") > script.indexOf('Add-Type -CompilerParameters')); + assert.match(script, /\[IO.Directory\]::Delete\(\$compilerTemp, \$true\)/); +}); + +test('generated PowerShell enforces raw mandatory ACE fields and reports diagnostics before rejection', () => { + const diagnosticScript = windowsCodeDomCompileScript('public class LabelRegexFixture {}', true); + const launch = createWindowsJobLaunch('node.exe', [], { SystemRoot: 'C:\\Windows' }, 'C:\\'); + const loader = Buffer.from(launch.args.at(-1)!, 'base64').toString('utf16le'); + const compressed = loader.match(/FromBase64String\('([^']+)'\)/u)?.[1]; + assert.ok(compressed); + const guardScript = gunzipSync(Buffer.from(compressed, 'base64')).toString('utf8'); + for (const script of [diagnosticScript, guardScript]) { + assert.ok(script.includes(windowsCodeDomLabelValidationScript())); + assert.match(script, /\$ace.AceType -eq 0x11/); + assert.match(script, /ToUInt32\(\$bytes, 4\)/); + assert.match(script, /SecurityIdentifier\]::new\(\$bytes, 8\)/); + assert.match(script, /\$entry.sid -eq 'S-1-16-12288'/); + assert.match(script, /\(\$entry.mask -band 1\) -ne 0/); + assert.match(script, /\(\$entry.flags -band 8\) -eq 0/); + assert.doesNotMatch(script, /\$compilerActualSddl -notmatch/); + assert.match(script, /GetFileSecurityW\(\$compilerTemp, 0x14/); + assert.match(script, /requestedCompilerSddl = \$compilerSddl; compilerSddl = \$compilerActualSddl/); + assert.match(script, /compilerSaclCount = \$compilerActualLabels.saclCount; compilerSaclAces = \$compilerActualLabels.aces/); + assert.match(script, /high-integrity label was not preserved\. ' \+ \$compilerSecurityReport/); + } + assert.ok(diagnosticScript.indexOf('[Console]::Out.WriteLine($compilerSecurityReport)') + < diagnosticScript.indexOf('$compilerElevated -and -not $compilerActualLabels.hasHighLabel')); +}); + test('builds a guard that atomically creates the worker inside a Windows job', () => { const launch = createWindowsJobLaunch( 'C:\\Program Files\\node.exe', @@ -52,6 +134,10 @@ test('builds a guard that atomically creates the worker inside a Windows job', ( assert.match(script, /UpdateProcThreadAttribute/); assert.match(script, /WaitForMultipleObjects/); assert.match(script, /ReadFile/); + assert.match(script, /CreateJobObject\(IntPtr.Zero, jobName\)/); + assert.match(script, /TerminateJobObject/); + assert.match(script, /QueryInformationJobObject/); + assert.match(script, /accounting.ActiveProcesses == 0/); assert.doesNotMatch(script, /Console\]::In/); assert.match(script, new RegExp(GJC_WINDOWS_JOB_GUARD_READY)); assert.match(script, new RegExp(GJC_WINDOWS_JOB_GUARD_ACK)); @@ -64,6 +150,8 @@ test('builds a guard that atomically creates the worker inside a Windows job', ( < script.indexOf('$exitCode = [GajaeWindowsJobGuard]::Run'), ); assert.equal(launch.env.KEEP_ME, 'yes'); + assert.match(launch.jobName, /^Local\\gajae-worker-[a-f0-9-]+$/); + assert.equal(launch.env.GAJAE_INTERNAL_JOB_NAME, launch.jobName); assert.equal( launch.env.GAJAE_INTERNAL_JOB_OWNER_PROCESS, String(process.pid), @@ -73,3 +161,123 @@ test('builds a guard that atomically creates the worker inside a Windows job', ( '"C:\\Program Files\\node.exe" "C:\\work dir\\gjc-worker.js"', ); }); + +test('each Windows worker owns a separate named job and accepts Windows environment casing', () => { + const first = createWindowsJobLaunch('node.exe', [], { SYSTEMROOT: 'C:\\Windows' }, 'C:\\work'); + const second = createWindowsJobLaunch('node.exe', [], { windir: 'C:\\Windows' }, 'C:\\work'); + assert.equal(first.command, second.command); + assert.notEqual(first.jobName, second.jobName); + assert.throws(() => createWindowsJobLaunch('node.exe', [], {}, 'C:\\work'), /SystemRoot/); +}); + +test('Windows reap barrier waits for guard exit and independent Job Object verification', async () => { + const launch = createWindowsJobLaunch('node.exe', [], { SystemRoot: 'C:\\Windows' }, 'C:\\work'); + const child = Object.assign(new EventEmitter(), { kill: (signal: string) => { + assert.equal(signal, 'SIGKILL'); + return true; + } }); + let queried = false; + let release!: () => void; + const verification = new Promise((resolve) => { release = resolve; }); + const reap = killWindowsJobGuard(child, launch, async (owned) => { + assert.equal(owned.jobName, launch.jobName); + queried = true; + await verification; + }); + let settled = false; + void reap.then(() => { settled = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(queried, false); + child.emit('close'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(queried, true); + assert.equal(settled, false); + release(); + await reap; +}); + +test('Windows reap barrier rejects termination and verification failures', async () => { + const launch = createWindowsJobLaunch('node.exe', [], { SystemRoot: 'C:\\Windows' }, 'C:\\work'); + const alive = Object.assign(new EventEmitter(), { kill: () => false }); + await assert.rejects(killWindowsJobGuard(alive, launch, async () => { assert.fail('guard is still alive'); }), /could not be terminated/); + const exited = Object.assign(new EventEmitter(), { exitCode: 0, kill: () => { assert.fail('already exited'); } }); + await assert.rejects(killWindowsJobGuard(exited, launch, async () => { throw new Error('job query failed'); }), /job query failed/); +}); + +for (const shutdown of ['guard', 'owner'] as const) { +test(`Windows Job Object kills detached descendants after ${shutdown} exit`, { + // Startup (15 s), owner exit (5 s), and two independent reaps (up to 20 s + // each) have separate bounds. Do not cancel a still-bounded reap on CI. + skip: process.platform !== 'win32', timeout: 65_000, +}, async () => { + const program = ` + const { spawn } = require('node:child_process'); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' }); + child.on('spawn', () => { + const frame = Buffer.from(JSON.stringify({ descendant: child.pid, marker: '가재 job fixture' }) + '\\n'); + const split = frame.indexOf(Buffer.from('가')) + 1; + process.stdout.write(frame.subarray(0, split)); + setTimeout(() => process.stdout.write(frame.subarray(split)), 10); + }); + setInterval(() => {}, 1000); + `; + const owner = shutdown === 'owner' + ? spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', windowsHide: true }) + : undefined; + const launch = createWindowsJobLaunch(process.execPath, ['-e', program], process.env, process.cwd()); + if (owner) launch.env.GAJAE_INTERNAL_JOB_OWNER_PROCESS = String(owner.pid); + const guard = spawn(launch.command, launch.args, { env: launch.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const guardClosed = new Promise((resolve) => guard.once('close', () => resolve())); + const ownerClosed = owner ? new Promise((resolve) => owner.once('close', () => resolve())) : Promise.resolve(); + let stderr = ''; + guard.stderr.setEncoding('utf8'); + guard.stderr.on('data', (chunk: string) => { stderr += chunk; }); + guard.stdout.setEncoding('utf8'); + let descendant: number | undefined; + try { + descendant = await new Promise((resolve, reject) => { + let buffer = ''; + const timer = setTimeout(() => reject(new Error(`Job guard startup timed out: ${stderr}`)), 15_000); + guard.once('error', (error) => { clearTimeout(timer); reject(error); }); + guard.once('exit', () => { clearTimeout(timer); reject(new Error(`Job guard exited: ${stderr}`)); }); + guard.stdout.on('data', (chunk: string) => { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop()!; + for (const raw of lines) { + const line = raw.replace(/\r$/u, ''); + if (line === GJC_WINDOWS_JOB_GUARD_READY) guard.stdin.write(`${GJC_WINDOWS_JOB_GUARD_ACK}\n`); + else { + try { + const frame = JSON.parse(line) as { descendant: number; marker: string }; + assert.ok(frame.descendant > 0); + assert.equal(frame.marker, '가재 job fixture'); + clearTimeout(timer); + resolve(frame.descendant); + } catch (error) { clearTimeout(timer); reject(error); } + } + } + }); + }); + process.kill(descendant, 0); + if (owner) { + // Killing the app owner must cause the guard itself to exit. Do not call + // the explicit reaper until that independent lifecycle has completed. + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Guard survived owner exit.')), 5_000); + guard.once('exit', () => { clearTimeout(timer); resolve(); }); + owner.kill('SIGKILL'); + }); + } + await killWindowsJobGuard(guard, launch); + assert.throws(() => process.kill(descendant!, 0), (error: unknown) => (error as NodeJS.ErrnoException).code === 'ESRCH'); + // Reaping a generation that already exited is idempotent. + await killWindowsJobGuard(guard, launch); + } finally { + if (guard.exitCode === null && guard.signalCode === null) guard.kill('SIGKILL'); + if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill('SIGKILL'); + if (descendant) { try { process.kill(descendant, 'SIGKILL'); } catch { /* already reaped */ } } + await Promise.all([guardClosed, ownerClosed]); + } +}); +} diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index 1191a95b..9ce49eb2 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -1,21 +1,228 @@ import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { gzipSync } from 'node:zlib'; const APPLICATION_ENV = 'GAJAE_INTERNAL_JOB_APPLICATION'; const COMMAND_LINE_ENV = 'GAJAE_INTERNAL_JOB_COMMAND_LINE'; const WORKING_DIRECTORY_ENV = 'GAJAE_INTERNAL_JOB_WORKING_DIRECTORY'; const OWNER_PROCESS_ENV = 'GAJAE_INTERNAL_JOB_OWNER_PROCESS'; +const JOB_NAME_ENV = 'GAJAE_INTERNAL_JOB_NAME'; +const REAP_ENV = 'GAJAE_INTERNAL_JOB_REAP'; export const GJC_WINDOWS_JOB_GUARD_READY = 'gajae-job-guard-ready-v1'; export const GJC_WINDOWS_JOB_GUARD_ACK = 'gajae-job-guard-ack-v1'; -const WINDOWS_JOB_GUARD_SCRIPT = String.raw` -$ErrorActionPreference = 'Stop' -$null = Add-Type -TypeDefinition @' +/** Inspect raw mandatory ACEs: GetSddlForm(All) does not request label output. */ +export function windowsCodeDomLabelValidationScript(): string { + return String.raw` +function Get-GajaeCompilerLabelState([Security.AccessControl.RawSecurityDescriptor]$security) { + $entries = @() + $count = 0 + $hasHighLabel = $false + $malformedLabel = $false + if ($null -ne $security.SystemAcl) { $count = $security.SystemAcl.Count } + foreach ($ace in $security.SystemAcl) { + $entry = @{ type = [int]$ace.AceType; size = $ace.BinaryLength; flags = [int]$ace.AceFlags } + if ([int]$ace.AceType -eq 0x11) { + try { + # SYSTEM_MANDATORY_LABEL_ACE: header at 0, mask at 4, SID at 8. + if ($ace.BinaryLength -lt 16) { throw 'Truncated mandatory-label ACE.' } + $bytes = [byte[]]::new($ace.BinaryLength) + $ace.GetBinaryForm($bytes, 0) + $entry.mask = [BitConverter]::ToUInt32($bytes, 4) + $entry.sid = [Security.Principal.SecurityIdentifier]::new($bytes, 8).Value + # Inherit-only ACEs do not protect this directory itself. + if ($entry.sid -eq 'S-1-16-12288' -and ($entry.mask -band 1) -ne 0 -and ($entry.flags -band 8) -eq 0) { $hasHighLabel = $true } + } catch { + $malformedLabel = $true + $entry.error = $_.Exception.Message + } + } + $entries += $entry + } + return @{ hasHighLabel = ($hasHighLabel -and -not $malformedLabel); saclCount = $count; aces = $entries } +} +`.trim(); +} + +/** An existing short name must resolve back to the protected compiler directory. */ +export function windowsCodeDomPathValidationScript(): string { + return String.raw` +function Assert-GajaeCompilerPath([string]$directory, [string]$alias, [string]$resolved) { + if ([String]::IsNullOrWhiteSpace($alias) -or $alias -match '[^\x20-\x7e]' -or -not [IO.Path]::IsPathRooted($alias) -or [IO.Path]::GetPathRoot($alias).Length -lt 3) { + throw ('No compiler-compatible ASCII 8.3 path is available for the protected Unicode directory; short-name generation may be disabled on this volume. Directory: ' + $directory) + } + if ([String]::IsNullOrWhiteSpace($resolved) -or -not ([StringComparer]::OrdinalIgnoreCase).Equals([IO.Path]::GetFullPath($directory), [IO.Path]::GetFullPath($resolved))) { + throw ('Compiler short path did not resolve to the same protected directory. Directory: ' + $directory + '; alias: ' + $alias + '; resolved: ' + $resolved) + } + return $alias +} +`.trim(); +} + +/** Compiles trusted constant C# without CodeDom's ANSI elevated-temp helper. */ +export function windowsCodeDomCompileScript(typeDefinition: string, diagnostics = false): string { + return String.raw` +${windowsCodeDomLabelValidationScript()} +${windowsCodeDomPathValidationScript()} +$compilerIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() +$compilerSid = $compilerIdentity.User.Value +$compilerPrincipal = [Security.Principal.WindowsPrincipal]::new($compilerIdentity) +$compilerElevated = $compilerPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +$compilerTemp = [IO.Path]::Combine([IO.Path]::GetTempPath(), ('gajae-code-dom-' + [Guid]::NewGuid().ToString('N'))) +$compilerParameters = $null +$compilerTempCreated = $false +$compilerOriginalTemp = [Environment]::GetEnvironmentVariable('TEMP', 'Process') +$compilerOriginalTmp = [Environment]::GetEnvironmentVariable('TMP', 'Process') +$compilerEnvironmentChanged = $false +try { + if ($compilerElevated) { + # Exact SDDL used by .NET TempFileCollection.CreateTempDirectoryWithAce: + # inherited deny-delete, administrator access, and a high-integrity label. + $compilerSddl = 'D:(D;OI;SD;;;' + $compilerSid + ')(A;OICI;FA;;;BA)S:(ML;OI;NW;;;HI)' + } else { + $compilerSddl = 'D:P(A;OICI;FA;;;' + $compilerSid + ')(A;OICI;FA;;;SY)' + } + # DirectorySecurity canonicalizes its SystemAcl as auditing ACEs and drops + # the mandatory label, producing an empty SACL that needs SeSecurityPrivilege. + # Preserve the raw descriptor and call the wide API directly instead. Emit + # imports without Add-Type, which is the compiler we are bootstrapping. + if (-not ('GajaeCodeDomFileApi' -as [type])) { + $compilerAssembly = [AppDomain]::CurrentDomain.DefineDynamicAssembly([Reflection.AssemblyName]::new('GajaeCodeDomFileApi'), [Reflection.Emit.AssemblyBuilderAccess]::Run) + $compilerModule = $compilerAssembly.DefineDynamicModule('GajaeCodeDomFileApi') + $compilerType = $compilerModule.DefineType('GajaeCodeDomFileApi', [Reflection.TypeAttributes]::Public -bor [Reflection.TypeAttributes]::Sealed -bor [Reflection.TypeAttributes]::Abstract) + function Add-GajaeCompilerImport($builder, [string]$name, [string]$library, [type[]]$parameters, [type]$returnType = [bool]) { + $method = $builder.DefineMethod($name, [Reflection.MethodAttributes]::Public -bor [Reflection.MethodAttributes]::Static -bor [Reflection.MethodAttributes]::PinvokeImpl, $returnType, $parameters) + $attributeType = [Runtime.InteropServices.DllImportAttribute] + $constructor = $attributeType.GetConstructor([type[]]@([string])) + $fields = [Reflection.FieldInfo[]]@($attributeType.GetField('EntryPoint'), $attributeType.GetField('CharSet'), $attributeType.GetField('ExactSpelling'), $attributeType.GetField('SetLastError'), $attributeType.GetField('CallingConvention')) + $values = [object[]]@($name, [Runtime.InteropServices.CharSet]::Unicode, $true, $true, [Runtime.InteropServices.CallingConvention]::Winapi) + $method.SetCustomAttribute([Reflection.Emit.CustomAttributeBuilder]::new($constructor, [object[]]@($library), $fields, $values)) + $method.SetImplementationFlags([Reflection.MethodImplAttributes]::PreserveSig) + if ($name -eq 'GetFileSecurityW') { $null = $method.DefineParameter(3, [Reflection.ParameterAttributes]::Out, 'securityDescriptor') } + if ($name -eq 'GetShortPathNameW' -or $name -eq 'GetLongPathNameW') { $null = $method.DefineParameter(2, [Reflection.ParameterAttributes]::Out, 'pathBuffer') } + } + Add-GajaeCompilerImport $compilerType 'CreateDirectoryW' 'kernel32.dll' ([type[]]@([string], [IntPtr])) + Add-GajaeCompilerImport $compilerType 'GetFileSecurityW' 'advapi32.dll' ([type[]]@([string], [uint32], [byte[]], [uint32], [uint32].MakeByRefType())) + Add-GajaeCompilerImport $compilerType 'GetShortPathNameW' 'kernel32.dll' ([type[]]@([string], [Text.StringBuilder], [uint32])) ([uint32]) + Add-GajaeCompilerImport $compilerType 'GetLongPathNameW' 'kernel32.dll' ([type[]]@([string], [Text.StringBuilder], [uint32])) ([uint32]) + $null = $compilerType.CreateType() + } + $compilerSecurity = [Security.AccessControl.RawSecurityDescriptor]::new($compilerSddl) + $compilerDescriptorBytes = [byte[]]::new($compilerSecurity.BinaryLength) + $compilerSecurity.GetBinaryForm($compilerDescriptorBytes, 0) + $compilerDescriptorPointer = [IntPtr]::Zero + $compilerAttributesPointer = [IntPtr]::Zero + try { + $compilerDescriptorPointer = [Runtime.InteropServices.Marshal]::AllocHGlobal($compilerDescriptorBytes.Length) + [Runtime.InteropServices.Marshal]::Copy($compilerDescriptorBytes, 0, $compilerDescriptorPointer, $compilerDescriptorBytes.Length) + # SECURITY_ATTRIBUTES has pointer-aligned length, descriptor and BOOL + # fields: 24 bytes on x64, 12 on x86. Zero padding and handle inheritance. + $compilerAttributesLength = 3 * [IntPtr]::Size + $compilerAttributesPointer = [Runtime.InteropServices.Marshal]::AllocHGlobal($compilerAttributesLength) + [Runtime.InteropServices.Marshal]::Copy([byte[]]::new($compilerAttributesLength), 0, $compilerAttributesPointer, $compilerAttributesLength) + [Runtime.InteropServices.Marshal]::WriteInt32($compilerAttributesPointer, $compilerAttributesLength) + [Runtime.InteropServices.Marshal]::WriteIntPtr($compilerAttributesPointer, [IntPtr]::Size, $compilerDescriptorPointer) + if (-not [GajaeCodeDomFileApi]::CreateDirectoryW($compilerTemp, $compilerAttributesPointer)) { + throw [ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) + } + } finally { + if ($compilerAttributesPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeHGlobal($compilerAttributesPointer) } + if ($compilerDescriptorPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeHGlobal($compilerDescriptorPointer) } + } + $compilerTempCreated = $true + # Query DACL + LABEL, not auditing SACL: label-only access requires no + # SeSecurityPrivilege, and RawSecurityDescriptor retains the mandatory ACE. + [uint32]$compilerSecurityLength = 0 + $null = [GajaeCodeDomFileApi]::GetFileSecurityW($compilerTemp, 0x14, $null, 0, [ref]$compilerSecurityLength) + if ($compilerSecurityLength -eq 0 -or $compilerSecurityLength -gt 65536) { throw 'Could not size compiler directory security descriptor.' } + $compilerActualBytes = [byte[]]::new($compilerSecurityLength) + if (-not [GajaeCodeDomFileApi]::GetFileSecurityW($compilerTemp, 0x14, $compilerActualBytes, $compilerActualBytes.Length, [ref]$compilerSecurityLength)) { + throw [ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) + } + $compilerActualSecurity = [Security.AccessControl.RawSecurityDescriptor]::new($compilerActualBytes, 0) + $compilerActualSddl = $compilerActualSecurity.GetSddlForm([Security.AccessControl.AccessControlSections]::All) + $compilerRequestedLabels = Get-GajaeCompilerLabelState $compilerSecurity + $compilerActualLabels = Get-GajaeCompilerLabelState $compilerActualSecurity + $compilerSecurityReport = (@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; requestedCompilerSddl = $compilerSddl; compilerSddl = $compilerActualSddl; requestedSaclCount = $compilerRequestedLabels.saclCount; requestedSaclAces = $compilerRequestedLabels.aces; compilerSaclCount = $compilerActualLabels.saclCount; compilerSaclAces = $compilerActualLabels.aces; hasHighLabel = $compilerActualLabels.hasHighLabel } | ConvertTo-Json -Compress -Depth 4) + ${diagnostics ? '[Console]::Out.WriteLine($compilerSecurityReport)' : ''} + # GetSddlForm(All) serializes auditing SACL flags, not LABEL_SECURITY_INFORMATION. + # Enforce the label from the returned raw ACE fields instead of its SDDL text. + if ($compilerElevated -and -not $compilerActualLabels.hasHighLabel) { + throw ('Compiler directory high-integrity label was not preserved. ' + $compilerSecurityReport) + } + # Keep the directory, ACL and profile intact. Compiler filenames use an + # existing 8.3 spelling of that same protected directory. + $compilerPath = $compilerTemp + $compilerLongPath = $compilerTemp + if ($compilerTemp -match '[^\x20-\x7e]') { + $shortLength = [GajaeCodeDomFileApi]::GetShortPathNameW($compilerTemp, $null, 0) + if ($shortLength -eq 0 -or $shortLength -gt 32768) { throw ('Could not obtain a compiler short-name alias for: ' + $compilerTemp) } + $shortBuffer = [Text.StringBuilder]::new([int]$shortLength) + $shortWritten = [GajaeCodeDomFileApi]::GetShortPathNameW($compilerTemp, $shortBuffer, $shortBuffer.Capacity) + if ($shortWritten -eq 0 -or $shortWritten -ge $shortBuffer.Capacity) { throw ('Invalid compiler short-name alias for: ' + $compilerTemp) } + $compilerPath = $shortBuffer.ToString() + $longLength = [GajaeCodeDomFileApi]::GetLongPathNameW($compilerPath, $null, 0) + if ($longLength -eq 0 -or $longLength -gt 32768) { throw 'Could not verify the compiler short-name alias.' } + $longBuffer = [Text.StringBuilder]::new([int]$longLength) + $longWritten = [GajaeCodeDomFileApi]::GetLongPathNameW($compilerPath, $longBuffer, $longBuffer.Capacity) + if ($longWritten -eq 0 -or $longWritten -ge $longBuffer.Capacity) { throw 'Invalid compiler short-name round trip.' } + $compilerLongPath = $longBuffer.ToString() + } + $compilerPath = Assert-GajaeCompilerPath $compilerTemp $compilerPath $compilerLongPath + $compilerParameters = [CodeDom.Compiler.CompilerParameters]::new() + $compilerParameters.GenerateInMemory = $true + $compilerParameters.ReferencedAssemblies.AddRange([string[]]@('System.dll', 'System.Core.dll')) + # Explicit TempDir is retained as BasePath; GetFullPath is used only for its + # permission demand. OutputAssembly prevents a later implicit long filename. + $compilerParameters.TempFiles = [CodeDom.Compiler.TempFileCollection]::new($compilerPath, $false) + $compilerParameters.OutputAssembly = [IO.Path]::Combine($compilerPath, 'gajae-code-dom.dll') + $compilerParameters.TempFiles.AddFile($compilerParameters.OutputAssembly, $false) + $compilerBasePath = $compilerParameters.TempFiles.BasePath + if ($compilerBasePath -match '[^\x20-\x7e]') { throw ('CodeDom did not retain its explicit short-name temp path: ' + $compilerBasePath) } + ${diagnostics ? `[Console]::Out.WriteLine((@{ compilerPath = $compilerPath; compilerLongPath = $compilerLongPath; compilerBasePath = $compilerBasePath; compilerOutputAssembly = $compilerParameters.OutputAssembly; compilerPathVerified = $true } | ConvertTo-Json -Compress))` : ''} + # The native metadata writer may consult TEMP independently of OutputAssembly. + # Scope the alias to this guard process during compilation; children must + # inherit the original application environment after this helper returns. + $compilerEnvironmentChanged = $true + [Environment]::SetEnvironmentVariable('TEMP', $compilerPath, 'Process') + [Environment]::SetEnvironmentVariable('TMP', $compilerPath, 'Process') + $null = Add-Type -CompilerParameters $compilerParameters -TypeDefinition @' +${typeDefinition} +'@ +} finally { + try { + if ($compilerEnvironmentChanged) { + [Environment]::SetEnvironmentVariable('TEMP', $compilerOriginalTemp, 'Process') + [Environment]::SetEnvironmentVariable('TMP', $compilerOriginalTmp, 'Process') + } + if ($compilerTempCreated) { + # Restore deletion rights only after compilation. Change the DACL + # alone so high integrity remains in force until removal completes. + $compilerCleanupSecurity = [Security.AccessControl.DirectorySecurity]::new() + $compilerCleanupSecurity.SetSecurityDescriptorSddlForm(('D:(A;OICI;FA;;;' + $compilerSid + ')(A;OICI;FA;;;BA)'), [Security.AccessControl.AccessControlSections]::Access) + [IO.Directory]::SetAccessControl($compilerTemp, $compilerCleanupSecurity) + if ($null -ne $compilerParameters) { $compilerParameters.TempFiles.Delete() } + [IO.Directory]::Delete($compilerTemp, $true) + } + } finally { + $compilerIdentity.Dispose() + } +} +$compilerEnvironmentRestored = ($compilerOriginalTemp -ceq [Environment]::GetEnvironmentVariable('TEMP', 'Process')) -and ($compilerOriginalTmp -ceq [Environment]::GetEnvironmentVariable('TMP', 'Process')) +if (-not $compilerEnvironmentRestored) { throw 'The compiler did not restore its original process TEMP/TMP.' } +${diagnostics ? `[Console]::Out.WriteLine((@{ compilerEnvironmentRestored = $compilerEnvironmentRestored; compilerRestoredTemp = [Environment]::GetEnvironmentVariable('TEMP', 'Process'); compilerRestoredTmp = [Environment]::GetEnvironmentVariable('TMP', 'Process') } | ConvertTo-Json -Compress))` : ''} +`.trim(); +} + +const WINDOWS_JOB_GUARD_SCRIPT = `$ErrorActionPreference = 'Stop' +${windowsCodeDomCompileScript(String.raw` using System; using System.ComponentModel; using System.Runtime.InteropServices; using System.Text; +using System.Threading; public static class GajaeWindowsJobGuard { @@ -30,6 +237,19 @@ public static class GajaeWindowsJobGuard private const uint WAIT_OBJECT_0 = 0x00000000; private const uint SYNCHRONIZE = 0x00100000; + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + [StructLayout(LayoutKind.Sequential)] private struct JOBOBJECT_BASIC_LIMIT_INFORMATION { @@ -108,6 +328,50 @@ public static class GajaeWindowsJobGuard [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr CreateJobObject(IntPtr jobAttributes, string name); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr OpenJobObject(uint access, bool inheritHandle, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateJobObject(IntPtr job, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool QueryInformationJobObject( + IntPtr job, int informationClass, + ref JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, IntPtr returnLength); + + public static void Reap(string name) + { + // Called only after the guard has exited, so it cannot create a job + // after this lookup. A job survives until all handles and processes + // are gone; ERROR_FILE_NOT_FOUND therefore also proves termination. + IntPtr job = OpenJobObject(0x0004 | 0x0008, false, name); + if (job == IntPtr.Zero) + { + int error = Marshal.GetLastWin32Error(); + if (error == 2) return; + throw new Win32Exception(error, "OpenJobObject failed."); + } + try + { + if (!TerminateJobObject(job, 1)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "TerminateJobObject failed."); + for (int attempt = 0; attempt < 200; attempt++) + { + var accounting = new JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(); + if (!QueryInformationJobObject(job, 1, ref accounting, + (uint)Marshal.SizeOf(), IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "QueryInformationJobObject failed."); + if (accounting.ActiveProcesses == 0) return; + Thread.Sleep(25); + } + throw new TimeoutException("Windows job termination timed out."); + } + finally { CloseHandle(job); } + } + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetInformationJobObject( @@ -230,9 +494,10 @@ public static class GajaeWindowsJobGuard string application, string commandLine, string workingDirectory, + string jobName, IntPtr owner) { - IntPtr job = CreateJobObject(IntPtr.Zero, null); + IntPtr job = CreateJobObject(IntPtr.Zero, jobName); if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed."); @@ -333,8 +598,17 @@ public static class GajaeWindowsJobGuard } } } -'@ - +`.trim())} +${String.raw` +$jobName = [Environment]::GetEnvironmentVariable('${JOB_NAME_ENV}', 'Process') +$reap = [Environment]::GetEnvironmentVariable('${REAP_ENV}', 'Process') +[Environment]::SetEnvironmentVariable('${JOB_NAME_ENV}', $null, 'Process') +[Environment]::SetEnvironmentVariable('${REAP_ENV}', $null, 'Process') +if ([String]::IsNullOrWhiteSpace($jobName)) { throw 'Missing Windows job name.' } +if ($reap -eq '1') { + [GajaeWindowsJobGuard]::Reap($jobName) + exit 0 +} $application = [Environment]::GetEnvironmentVariable('${APPLICATION_ENV}', 'Process') $commandLine = [Environment]::GetEnvironmentVariable('${COMMAND_LINE_ENV}', 'Process') $workingDirectory = [Environment]::GetEnvironmentVariable('${WORKING_DIRECTORY_ENV}', 'Process') @@ -354,16 +628,16 @@ try { if (![GajaeWindowsJobGuard]::ReadAcknowledgement('${GJC_WINDOWS_JOB_GUARD_ACK}')) { throw 'Invalid job guard acknowledgement.' } - $exitCode = [GajaeWindowsJobGuard]::Run($application, $commandLine, $workingDirectory, $ownerHandle) + $exitCode = [GajaeWindowsJobGuard]::Run($application, $commandLine, $workingDirectory, $jobName, $ownerHandle) exit $exitCode } finally { [GajaeWindowsJobGuard]::CloseOwner($ownerHandle) } -`.trim(); +`.trim()}`; -const WINDOWS_JOB_GUARD_COMMAND = (() => { +export function encodeWindowsPowerShellCommand(source: string): string { const compressed = gzipSync( - Buffer.from(WINDOWS_JOB_GUARD_SCRIPT, 'utf8'), + Buffer.from(source, 'utf8'), { level: 9 }, ).toString('base64'); const loader = [ @@ -374,7 +648,9 @@ const WINDOWS_JOB_GUARD_COMMAND = (() => { '& ([ScriptBlock]::Create($r.ReadToEnd()))', ].join(';'); return Buffer.from(loader, 'utf16le').toString('base64'); -})(); +} + +const WINDOWS_JOB_GUARD_COMMAND = encodeWindowsPowerShellCommand(WINDOWS_JOB_GUARD_SCRIPT); /** Quotes one argv value using the Windows CommandLineToArgvW-compatible rules. */ export function quoteWindowsArgument(value: string): string { @@ -402,6 +678,7 @@ export type WindowsJobLaunch = { command: string; args: string[]; env: NodeJS.ProcessEnv; + jobName: string; }; /** @@ -414,10 +691,14 @@ export function createWindowsJobLaunch( environment: NodeJS.ProcessEnv, workingDirectory: string, ): WindowsJobLaunch { - const systemRoot = environment.SystemRoot ?? environment.WINDIR; + const systemRootKey = Object.keys(environment).find((key) => key.toLowerCase() === 'systemroot') + ?? Object.keys(environment).find((key) => key.toLowerCase() === 'windir'); + const systemRoot = systemRootKey ? environment[systemRootKey] : undefined; if (!systemRoot) throw new Error('Windows SystemRoot is unavailable.'); + const jobName = `Local\\gajae-worker-${randomUUID()}`; return { + jobName, command: path.win32.join( systemRoot, 'System32', @@ -440,6 +721,55 @@ export function createWindowsJobLaunch( [COMMAND_LINE_ENV]: [application, ...args].map(quoteWindowsArgument).join(' '), [WORKING_DIRECTORY_ENV]: workingDirectory, [OWNER_PROCESS_ENV]: String(process.pid), + [JOB_NAME_ENV]: jobName, + [REAP_ENV]: '0', }, }; } + +type WindowsJobChild = { + exitCode?: number | null; + signalCode?: NodeJS.Signals | null; + kill(signal: NodeJS.Signals): boolean; + on(event: string, listener: (...args: any[]) => void): unknown; +}; + +function confirmWindowsJobTermination(launch: WindowsJobLaunch): Promise { + return new Promise((resolve, reject) => { + execFile(launch.command, launch.args, { + env: { ...launch.env, [REAP_ENV]: '1' }, + windowsHide: true, + timeout: 15_000, + maxBuffer: 64 * 1024, + }, (error) => { + if (error) reject(new Error('Windows job termination could not be verified.', { cause: error })); + else resolve(); + }); + }); +} + +/** Kill the owner handle, then verify the named job has no remaining processes. */ +export async function killWindowsJobGuard( + child: WindowsJobChild, + launch: WindowsJobLaunch, + confirmTermination = confirmWindowsJobTermination, +): Promise { + await new Promise((resolve, reject) => { + const exited = () => child.exitCode != null || child.signalCode != null; + const timer = setTimeout(() => reject(new Error('Windows job guard termination timed out.')), 5_000); + timer.unref?.(); + const finish = () => { clearTimeout(timer); resolve(); }; + child.on('close', finish); + if (exited()) { finish(); return; } + try { + if (!child.kill('SIGKILL') && !exited()) { + clearTimeout(timer); + reject(new Error('Windows job guard could not be terminated.')); + } + } catch (error) { + clearTimeout(timer); + reject(error); + } + }); + await confirmTermination(launch); +} diff --git a/server/gjc-worker-client.test.ts b/server/gjc-worker-client.test.ts index 88df0f95..51dab0cb 100644 --- a/server/gjc-worker-client.test.ts +++ b/server/gjc-worker-client.test.ts @@ -205,6 +205,7 @@ class FakePeer { function runtime(child: FakeChild, scope = 'app-session-1') { return { + platform: 'linux' as const, spawn: () => child, corePath: '/test/gajae-core', workerPath: '/test/gjc-bun-worker.js', @@ -354,6 +355,7 @@ test('fails closed when the Windows job guard never proves app ownership', async platform: 'win32', environment: { SystemRoot: 'C:\\Windows' }, initializeTimeoutMs: 5, + killTree: (guard) => { guard.kill('SIGKILL'); }, }); await assert.rejects( @@ -361,7 +363,7 @@ test('fails closed when the Windows job guard never proves app ownership', async /GJC worker failed/, ); - assert.equal(child.killed, false); + assert.equal(child.killed, true); }); test('shares one handshake and sends one start request per concurrent run', async () => { @@ -393,7 +395,7 @@ test('shares one handshake and sends one start request per concurrent run', asyn assert.equal(starts.length, 2); assert.deepEqual(starts[0]?.payload, { message: 'first', options: { model: 'x' } }); assert.equal(peer.requests.some((request) => request.method === 'turn.start'), false); - assert.equal(detached, process.platform !== 'win32'); + assert.equal(detached, true); assert.equal(environmentExtendsProcessEnvWithAgentDir(launchEnvironment), true); assert.equal(command, '/test/gajae-core'); assert.deepEqual(args, ['--', '/test/bun', '/test/gjc-bun-worker.js']); @@ -482,6 +484,7 @@ test('wraps the source worker with Bun while only adding the injected agent dire test('fails safely when the native core cannot launch without a Node fallback', async () => { const commands: string[] = []; const supervisor = new GjcWorkerSupervisor({ + platform: 'linux', corePath: '/missing/gajae-core', workerPath: '/test/gjc-worker.js', compiled: true, @@ -1107,7 +1110,7 @@ test('rejecting option enrichment settles a pre-request run as not_started', asy assert.equal(await run.outcome, 'not_started'); assert.equal(supervisor.isActive('enrichment-failure'), false); }); -test('production POSIX terminator waits for direct-child close and process-group absence', async () => { +test('production POSIX terminator waits for direct-child close and process-group absence', { skip: process.platform === 'win32' }, async () => { const child = spawnChild(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { detached: true, stdio: ['pipe', 'pipe', 'pipe'], @@ -1119,10 +1122,10 @@ test('production POSIX terminator waits for direct-child close and process-group (error: unknown) => (error as NodeJS.ErrnoException).code === 'ESRCH', ); }); -test('Windows tree reaping is explicitly fail-closed while the v2 runtime is frozen', async () => { +test('Windows tree reaping rejects a child without an owned Job Object', async () => { await assert.rejects( killWorkerTree(new FakeChild(), 'win32'), - /unconfirmed on Windows/, + /no owned Job Object/, ); }); test('OAuth requests and chat runs share one supervised worker process', async () => { diff --git a/server/gjc-worker-client.ts b/server/gjc-worker-client.ts index 4543285d..cddcaad4 100644 --- a/server/gjc-worker-client.ts +++ b/server/gjc-worker-client.ts @@ -23,7 +23,9 @@ import { GjcWorkerProtocolError, GjcWorkerRequestTracker, createWindowsJobLaunch, + killWindowsJobGuard, serializeGjcWorkerFrame, + type WindowsJobLaunch, type GjcWorkerEventFrame, type GjcWorkerGlobalEventMethod, type GjcWorkerRequestFrame, @@ -39,6 +41,11 @@ import { getGjcLiveSessionRoot, registerGjcRuntimeModelCatalogLoader, } from './shared/utils.js'; +import { getBundledExecutablePath } from './utils/runtime-paths.js'; + +// Only processes created by our atomic Job Object guard can use the Windows +// reap barrier; an arbitrary child's exit is not evidence about descendants. +const windowsJobLaunches = new WeakMap(); type RunStoppedNotification = { userId: string | number | null; @@ -327,8 +334,10 @@ export function killWorkerTree( kill: (pid: number, signal: NodeJS.Signals | 0) => void = process.kill, ): Promise { if (platform === 'win32') { - // Windows runtime is frozen in v2; no verified tree-reap implementation exists. - return Promise.reject(new Error('GJC worker tree reaping is unconfirmed on Windows.')); + const launch = windowsJobLaunches.get(child); + return launch + ? killWindowsJobGuard(child, launch) + : Promise.reject(new Error('Windows worker has no owned Job Object.')); } return new Promise((resolve, reject) => { let closed = false; @@ -643,10 +652,7 @@ export class GjcWorkerSupervisor { if (this.starting) return this.starting; const compiled = this.runtime.compiled ?? !import.meta.url.endsWith('.ts'); const workerPath = this.runtime.workerPath ?? fileURLToPath(new URL(compiled ? './gjc-bun-worker.js' : './gjc-bun-worker.ts', import.meta.url)); - const bundledBunPath = fileURLToPath(new URL( - compiled ? '../../dist-native/bun' : '../dist-native/bun', - import.meta.url, - )); + const bundledBunPath = getBundledExecutablePath(import.meta.url, 'bun', this.runtime.platform); const bunPath = this.runtime.bunPath ?? (existsSync(bundledBunPath) ? bundledBunPath : undefined) ?? (!compiled && this.runtime.allowDevelopmentBun ? 'bun' : undefined); @@ -683,6 +689,7 @@ export class GjcWorkerSupervisor { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); + if ('jobName' in launch) windowsJobLaunches.set(child, launch); this.child = child; this.ready = false; this.decoder = new GjcWorkerNdjsonDecoder(); const usesWindowsJobGuard = this.runtime.platform === 'win32'; let guardSettled = !usesWindowsJobGuard; @@ -1179,8 +1186,8 @@ export class GjcWorkerSupervisor { terminations.push(Promise.reject(error)); } }; - // On frozen v2 Windows, tree reaping is deliberately unverified and fails closed. - // `guardedProcessExited` cannot establish descendant termination without a tested runtime. + // Direct-child exit alone does not prove descendant termination. The + // Windows reaper also checks the owned job before releasing this barrier. void guardedProcessExited; terminate('GJC worker tree termination failed.', () => this.runtime.killTree(child)); if (!usesWindowsJobGuard) { diff --git a/server/modules/automation/browser-sidecar-client.ts b/server/modules/automation/browser-sidecar-client.ts index aaa848bf..2fb885ee 100644 --- a/server/modules/automation/browser-sidecar-client.ts +++ b/server/modules/automation/browser-sidecar-client.ts @@ -5,6 +5,8 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getBundledExecutablePath } from '../../utils/runtime-paths.js'; + import { BROWSER_PROTOCOL_VERSION, BrowserNdjsonDecoder, @@ -133,7 +135,7 @@ export class BrowserSidecarClient { const compiled = !import.meta.url.endsWith('.ts'); const sidecarPath = this.options.sidecarPath ?? fileURLToPath(new URL(compiled ? './browser-sidecar.js' : './browser-sidecar.ts', import.meta.url)); - const bundledBun = fileURLToPath(new URL(compiled ? '../../../../dist-native/bun' : '../../../dist-native/bun', import.meta.url)); + const bundledBun = getBundledExecutablePath(import.meta.url, 'bun'); const bunPath = this.options.runtimePath ?? process.env.GAJAE_BROWSER_BUN_PATH ?? (existsSync(bundledBun) ? bundledBun : undefined) diff --git a/server/modules/websocket/services/shell-command.test.ts b/server/modules/websocket/services/shell-command.test.ts new file mode 100644 index 00000000..2267dda9 --- /dev/null +++ b/server/modules/websocket/services/shell-command.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { buildGjcShellCommand, buildShellEnvironment, buildShellLaunch } from './shell-command.js'; + +const windows = { + platform: 'win32' as const, + home: 'C:\\Users\\Test User', + execPath: 'C:\\Program Files\\Gajae\\node.exe', + isDirectory: () => false, +}; + +test('Windows PATH merges casing aliases, promotes npm, and preserves other search directories', () => { + const env = { + Path: 'C:\\Windows\\System32;"c:\\users\\test user\\appdata\\roaming\\npm\\";D:\\Tools', + PATH: 'D:\\Other;C:\\WINDOWS\\system32', + APPDATA: 'C:\\Users\\Test User\\AppData\\Roaming', + SYSTEMROOT: 'C:\\Windows', + KEEP_ME: 'value', + }; + const result = buildShellEnvironment(env, windows); + assert.equal(result.PATH, 'C:\\Users\\Test User\\AppData\\Roaming\\npm;C:\\Windows\\System32;D:\\Tools;D:\\Other'); + assert.deepEqual(Object.keys(result).filter((key) => key.toLowerCase() === 'path'), ['PATH']); + assert.equal(result.KEEP_ME, 'value'); + assert.equal(env.PATH, 'D:\\Other;C:\\WINDOWS\\system32', 'the server environment must not be mutated'); +}); + +test('Windows GUI launches recover existing npm, node and system directories absent from PATH', () => { + const directories = new Set(['D:\\Npm Prefix', 'C:\\Users\\Test User\\AppData\\Roaming\\npm', 'C:\\Program Files\\Gajae', 'C:\\Windows\\System32']); + const result = buildShellEnvironment({ npm_config_prefix: 'D:\\Npm Prefix', Path: 'D:\\Other' }, { + ...windows, isDirectory: (directory) => directories.has(directory), + }); + assert.equal(result.PATH, [...directories, 'D:\\Other'].join(';')); + assert.ok(!result.PATH.includes('D:\\Npm Prefix\\bin'), 'Windows npm puts its shims in the prefix itself'); + assert.deepEqual([result.TERM, result.COLORTERM, result.FORCE_COLOR], ['xterm-256color', 'truecolor', '3']); +}); + +test('Windows PATH repairs an empty environment without adding missing or relative npm directories', () => { + for (const env of [{}, { Path: '' }, { PATH: '', Path: 'D:\\Tools' }]) { + const result = buildShellEnvironment({ ...env, NPM_CONFIG_PREFIX: 'relative-prefix' }, { + ...windows, isDirectory: (directory) => directory === 'C:\\Program Files\\Gajae', + }); + assert.equal(result.PATH, ['C:\\Program Files\\Gajae', ...('Path' in env && env.Path ? [env.Path] : [])].join(';')); + } +}); + +test('POSIX PATH is case-sensitive and uses colon-separated npm bin directories', () => { + const result = buildShellEnvironment({ Path: 'do-not-use', PATH: '/usr/bin:/opt/npm/bin:/extra', npm_config_prefix: '/opt/npm' }, { + platform: 'linux', home: '/home/test', isDirectory: () => { throw new Error('must not probe Windows directories'); }, + }); + assert.equal(result.PATH, '/opt/npm/bin:/usr/bin:/extra'); + assert.equal(result.Path, 'do-not-use'); + const unchanged = { PATH: '/usr/bin::/bin:/usr/bin' }; + assert.equal(buildShellEnvironment(unchanged, { platform: 'linux', home: '/home/test' }).PATH, unchanged.PATH); +}); + +test('Windows provider launch selects the npm cmd shim and keeps its path outside PowerShell syntax', () => { + const directory = "C:\\Users\\O'Brien & ‘한글’\\AppData\\Roaming\\npm"; + const shim = path.win32.join(directory, 'gjc.cmd'); + const command = buildGjcShellCommand('native-session.1:2', { PATH: directory }, { platform: 'win32', isFile: (file) => file === shim }); + const literals = [...command.matchAll(/FromBase64String\('([A-Za-z0-9+/=]+)'\)/g)]; + assert.equal(literals.length, 2); + for (const match of literals) assert.equal(Buffer.from(match[1], 'base64').toString('utf8'), shim); + assert.match(command, / --resume 'native-session\.1:2'; if \(-not \$\?\) \{ & /); + assert.doesNotMatch(command, /\|\||\.ps1|LASTEXITCODE/); +}); + +test('Windows provider executable resolution respects PATH order and avoids relative directories', () => { + const seen: string[] = []; + const command = buildGjcShellCommand('', { PATH: '.;relative;"D:\\First";D:\\Second' }, { + platform: 'win32', isFile: (file) => { seen.push(file); return file.endsWith('.cmd'); }, + }); + assert.deepEqual(seen, ['D:\\First\\gjc.exe', 'D:\\First\\gjc.cmd']); + const match = command.match(/FromBase64String\('([^']+)'\)/); + assert.ok(match); + assert.equal(Buffer.from(match[1], 'base64').toString('utf8'), 'D:\\First\\gjc.cmd'); + assert.doesNotMatch(command, /resume|if \(/); +}); + +test('provider-generated resume commands reject executable syntax in native IDs', () => { + for (const platform of ['win32', 'linux'] as const) { + for (const sessionId of ["id'; calc; '", 'id$(calc)', 'id&calc', 'id%PATH%', 'id\ncalc', 'id"']) { + assert.throws(() => buildGjcShellCommand(sessionId, {}, { platform }), /Invalid provider session ID/); + } + } + assert.equal(buildGjcShellCommand('native-id', {}, { platform: 'linux' }), 'gjc --resume "native-id" || gjc'); + assert.equal(buildGjcShellCommand('', {}, { platform: 'darwin' }), 'gjc'); +}); + +test('Windows shell commands survive argv transport with Unicode, quotes and PowerShell expressions', () => { + const command = '& "C:\\Program Files\\tool.exe" "한글"; Write-Output \'$env:PATH & literal\''; + const launch = buildShellLaunch(command, { systemroot: 'D:\\Windows' }, 'win32'); + assert.equal(launch.executable, 'D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + assert.deepEqual(launch.args.slice(0, -1), ['-NoLogo', '-NoProfile', '-EncodedCommand']); + assert.equal(Buffer.from(launch.args.at(-1)!, 'base64').toString('utf16le'), command); +}); + +test('empty shell requests open an interactive prompt on Windows and POSIX', () => { + for (const command of ['', ' \t\r\n']) { + assert.deepEqual(buildShellLaunch(command, {}, 'win32').args, ['-NoLogo', '-NoProfile']); + assert.deepEqual(buildShellLaunch(command, {}, 'linux'), { executable: 'bash', args: ['-i'] }); + } + assert.deepEqual(buildShellLaunch('printf "%s" "$HOME"', {}, 'linux'), { executable: 'bash', args: ['-c', 'printf "%s" "$HOME"'] }); +}); + +test('native Windows PowerShell runs npm cmd shims and falls back only after a failed resume', { skip: process.platform !== 'win32' }, () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'gajae-shell-')); + const bin = path.join(directory, "O'Brien & 한글"); + mkdirSync(bin); + const log = path.join(directory, 'calls.txt'); + writeFileSync(path.join(bin, 'gjc.cmd'), '@echo off\r\necho [%*]>>"%GAJAE_SHELL_TEST_LOG%"\r\nif "%~1"=="--resume" exit /b %GAJAE_SHELL_RESUME_STATUS%\r\nexit /b 0\r\n'); + writeFileSync(path.join(bin, 'gjc.ps1'), 'throw "The npm PowerShell shim must not run"'); + try { + const env = buildShellEnvironment({ ...process.env, npm_config_prefix: bin, GAJAE_SHELL_TEST_LOG: log }); + for (const code of ['0', '7']) { + writeFileSync(log, ''); + const launch = buildShellLaunch(buildGjcShellCommand('native-id', env), env); + execFileSync(launch.executable, launch.args, { env: { ...env, GAJAE_SHELL_RESUME_STATUS: code }, timeout: 15000 }); + assert.deepEqual(readFileSync(log, 'utf8').trim().split(/\r?\n/), code === '0' ? ['[--resume native-id]'] : ['[--resume native-id]', '[]']); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/server/modules/websocket/services/shell-command.ts b/server/modules/websocket/services/shell-command.ts new file mode 100644 index 00000000..db9caf72 --- /dev/null +++ b/server/modules/websocket/services/shell-command.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function environmentValue(env: NodeJS.ProcessEnv, requested: string): string | undefined { + const key = Object.keys(env).find((entry) => entry.toLowerCase() === requested.toLowerCase()); + return key ? env[key] : undefined; +} + +function directoryExists(directory: string): boolean { + try { return fs.statSync(directory).isDirectory(); } catch { return false; } +} + +function fileExists(file: string): boolean { + try { return fs.statSync(file).isFile(); } catch { return false; } +} + +const unquotePath = (entry: string): string => entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry; + +export function buildShellEnvironment(env: NodeJS.ProcessEnv, { + platform = os.platform(), home = os.homedir(), execPath = process.execPath, isDirectory = directoryExists, +} = {}): NodeJS.ProcessEnv { + const windows = platform === 'win32'; + const paths = windows ? path.win32 : path.posix; + const result: NodeJS.ProcessEnv = { ...env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' }; + const pathKeys = windows ? Object.keys(env).filter((key) => key.toLowerCase() === 'path') : ['PATH']; + const entries = pathKeys.flatMap((key) => (env[key] ?? '').split(paths.delimiter)).filter(Boolean); + const keyFor = (entry: string): string => windows ? paths.normalize(unquotePath(entry)).replace(/[\\/]+$/, '').toLowerCase() : entry; + const existing = new Set(entries.map(keyFor)); + const prefix = windows ? environmentValue(env, 'npm_config_prefix') : env.npm_config_prefix; + const appData = windows ? environmentValue(env, 'APPDATA') : undefined; + const candidates = windows ? [ + prefix, + appData ? paths.join(appData, 'npm') : paths.join(home, 'AppData', 'Roaming', 'npm'), + paths.join(home, '.npm-global', 'bin'), + paths.dirname(execPath), + paths.join(environmentValue(env, 'SystemRoot') || 'C:\\Windows', 'System32'), + ] : [prefix ? paths.join(prefix, 'bin') : undefined, paths.join(home, '.npm-global', 'bin')]; + const promoted = candidates.filter((candidate): candidate is string => Boolean(candidate && paths.isAbsolute(candidate) + && (existing.has(keyFor(candidate)) || (windows && isDirectory(candidate))))); + if (!windows && !promoted.length) return result; + const seen = new Set(); + const ordered = [...promoted, ...entries].filter((entry) => { + const key = keyFor(entry); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + // Windows environment keys are case-insensitive. Leaving both Path and PATH + // lets the subprocess launcher silently choose the unmodified value. + for (const key of pathKeys) delete result[key]; + if (ordered.length || pathKeys.some((key) => env[key] !== undefined)) result.PATH = ordered.join(paths.delimiter); + return result; +} + +export function buildGjcShellCommand(resumeId: string, env: NodeJS.ProcessEnv, { + platform = os.platform(), isFile = fileExists, +} = {}): string { + if (resumeId && !/^[a-zA-Z0-9_.\-:]+$/.test(resumeId)) throw new Error('Invalid provider session ID'); + if (platform !== 'win32') return resumeId ? `gjc --resume "${resumeId}" || gjc` : 'gjc'; + + // npm installs both .ps1 and .cmd shims. Prefer an executable or cmd shim so + // the default Windows PowerShell execution policy cannot block the provider. + const directories = (environmentValue(env, 'PATH') ?? '').split(';').map(unquotePath).filter((entry) => path.win32.isAbsolute(entry)); + const executable = directories.flatMap((directory) => ['gjc.exe', 'gjc.cmd', 'gjc.bat'].map((name) => path.win32.join(directory, name))).find(isFile) ?? 'gjc'; + const encoded = Buffer.from(executable, 'utf8').toString('base64'); + const invoke = `& ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')))`; + return resumeId ? `${invoke} --resume '${resumeId}'; if (-not $?) { ${invoke} }` : invoke; +} + +export function buildShellLaunch(command: string, env: NodeJS.ProcessEnv, platform = os.platform()): { executable: string; args: string[] } { + if (platform !== 'win32') return { executable: 'bash', args: command.trim() ? ['-c', command] : ['-i'] }; + const root = environmentValue(env, 'SystemRoot') || 'C:\\Windows'; + return { + executable: path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + // No command means a live prompt. EncodedCommand preserves quotes across + // node-pty's Windows argv serialization without disabling interactivity. + args: ['-NoLogo', '-NoProfile', ...(command.trim() ? ['-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')] : [])], + }; +} diff --git a/server/modules/websocket/services/shell-websocket.service.test.ts b/server/modules/websocket/services/shell-websocket.service.test.ts index 491c3118..d31664ba 100644 --- a/server/modules/websocket/services/shell-websocket.service.test.ts +++ b/server/modules/websocket/services/shell-websocket.service.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; import os from 'node:os'; -import test from 'node:test'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; -import pty, { type IPty } from 'node-pty'; +import pty, { type IPty, type IPtyForkOptions } from 'node-pty'; import { WebSocket } from 'ws'; import { handleShellConnection } from './shell-websocket.service.js'; @@ -35,6 +37,84 @@ class FakeSocket extends EventEmitter { output() { return this.frames.map(frame => frame.data ?? '').join(''); } } +function platformConnection(t: TestContext, platform: NodeJS.Platform, nativeId: string | null = 'provider-native-id') { + const socket = new FakeSocket(); + const projectPath = mkdtempSync(path.join(os.tmpdir(), 'gajae-shell-ws-')); + const calls: Array<{ executable: string; args: string[]; options: IPtyForkOptions }> = []; + const terminals: FakePty[] = []; + t.mock.method(os, 'platform', () => platform); + t.mock.method(pty, 'spawn', (executable: string, args: string[], options: IPtyForkOptions) => { + calls.push({ executable, args, options }); + const terminal = new FakePty(); + terminals.push(terminal); + return terminal as unknown as IPty; + }); + handleShellConnection(socket as unknown as WebSocket, { + resolveProviderSessionId: () => nativeId, + stripAnsiSequences: content => content, + normalizeDetectedUrl: () => null, + extractUrlsFromText: () => [], + shouldAutoOpenUrlFromOutput: () => false, + }); + t.after(() => { + terminals.forEach((terminal) => terminal.exit()); + socket.close(); + rmSync(projectPath, { recursive: true, force: true }); + }); + return { + socket, + calls, + projectPath, + init: (data: Record) => socket.receive({ type: 'init', projectPath, ...data }), + }; +} + +test('Windows websocket GJC resume reaches the PTY with PowerShell syntax and the mapped ID', (t) => { + const connection = platformConnection(t, 'win32'); + connection.init({ provider: 'gjc', sessionId: 'app-session-id', hasSession: true }); + assert.equal(connection.calls.length, 1); + const { executable, args, options } = connection.calls[0]!; + assert.match(executable, /\\System32\\WindowsPowerShell\\v1\.0\\powershell\.exe$/); + assert.deepEqual(args.slice(0, -1), ['-NoLogo', '-NoProfile', '-EncodedCommand']); + const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le'); + assert.match(script, / --resume 'provider-native-id'; if \(-not \$\?\)/); + assert.doesNotMatch(script, /app-session-id|\|\|/); + assert.equal(options.cwd, connection.projectPath); + assert.deepEqual(Object.keys(options.env ?? {}).filter((key) => key.toLowerCase() === 'path'), ['PATH']); +}); + +test('Windows websocket plain terminal stays interactive when no initial command is supplied', (t) => { + const connection = platformConnection(t, 'win32'); + connection.init({ provider: 'plain-shell', isPlainShell: true }); + assert.equal(connection.calls.length, 1); + assert.deepEqual(connection.calls[0]!.args, ['-NoLogo', '-NoProfile']); +}); + +test('Windows websocket preserves explicit provider/login command syntax through PTY argv', (t) => { + const connection = platformConnection(t, 'win32'); + const initialCommand = '& "C:\\Provider Tools\\cursor-agent.exe" login; Write-Output \'한글 $literal\''; + connection.init({ provider: 'cursor', initialCommand }); + assert.equal(connection.calls.length, 1); + assert.equal(Buffer.from(connection.calls[0]!.args.at(-1)!, 'base64').toString('utf16le'), initialCommand); +}); + +test('Windows websocket never interpolates a malformed provider session ID', (t) => { + const connection = platformConnection(t, 'win32', "native'; calc; '"); + connection.init({ provider: 'gjc', sessionId: 'app-session-id', hasSession: true }); + assert.equal(connection.calls.length, 1); + const script = Buffer.from(connection.calls[0]!.args.at(-1)!, 'base64').toString('utf16le'); + assert.doesNotMatch(script, /resume|calc|native/); + assert.match(script, /^& /); +}); + +test('POSIX websocket resume continues to use bash fallback syntax', (t) => { + const connection = platformConnection(t, 'linux'); + connection.init({ provider: 'gjc', sessionId: 'app-session-id', hasSession: true }); + assert.equal(connection.calls.length, 1); + assert.equal(connection.calls[0]!.executable, 'bash'); + assert.deepEqual(connection.calls[0]!.args, ['-c', 'gjc --resume "provider-native-id" || gjc']); +}); + function fixture(t: test.TestContext) { t.mock.timers.enable({ apis: ['setTimeout'] }); const timeout = t.mock.method(globalThis, 'setTimeout'); diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index ff6e6037..113a5909 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import pty, { type IPty } from 'node-pty'; @@ -8,6 +7,8 @@ import { WebSocket, type RawData } from 'ws'; import { parseIncomingJsonObject } from '@/shared/utils.js'; +import { buildGjcShellCommand, buildShellEnvironment, buildShellLaunch } from './shell-command.js'; + type ShellIncomingMessage = { type?: string; data?: string; cols?: number; rows?: number; projectPath?: string; sessionId?: string; hasSession?: boolean; provider?: string; initialCommand?: string; isPlainShell?: boolean; forceRestart?: boolean; }; type PtySessionEntry = { pty: IPty; ws: WebSocket | null; buffer: string[]; timeoutId: NodeJS.Timeout | null; projectPath: string; sessionId: string | null; urlText: string; reportedUrls: Set; }; type ShellWebSocketDependencies = { @@ -44,43 +45,14 @@ function nativeSession(message: ShellIncomingMessage, dependencies: ShellWebSock return result && SAFE_ID.test(result) ? result : ''; } -function shellCommand(message: ShellIncomingMessage, dependencies: ShellWebSocketDependencies): string { +function shellCommand(message: ShellIncomingMessage, dependencies: ShellWebSocketDependencies, env: NodeJS.ProcessEnv): string { const command = text(message.initialCommand); const provider = text(message.provider, 'gjc'); if (flag(message.isPlainShell) || (!!command && !flag(message.hasSession)) || provider === 'plain-shell') return command; if (provider !== 'gjc') return command; const resumeId = nativeSession(message, dependencies); - if (!resumeId) return command || 'gjc'; - return os.platform() === 'win32' - ? `gjc --resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { gjc }` - : `gjc --resume "${resumeId}" || gjc`; -} - -function environmentValue(env: NodeJS.ProcessEnv, requested: string): string | undefined { - const actualKey = Object.keys(env).find((key) => key.toLowerCase() === requested.toLowerCase()); - return actualKey ? env[actualKey] : undefined; -} - -function preferredPath(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } { - const key = Object.keys(env).find((entry) => entry.toLowerCase() === 'path') ?? 'PATH'; - const original = env[key]; - if (!original) return { key, value: original }; - const lowerCaseOnWindows = (entry: string): string => os.platform() === 'win32' ? entry.toLowerCase() : entry; - const entries = original.split(path.delimiter).filter(Boolean); - const npmPrefix = environmentValue(env, 'npm_config_prefix'); - const appData = environmentValue(env, 'APPDATA'); - const candidates = [ - npmPrefix ?? '', - npmPrefix ? path.join(npmPrefix, 'bin') : '', - appData ? path.join(appData, 'npm') : '', - path.join(os.homedir(), 'AppData', 'Roaming', 'npm'), - path.join(os.homedir(), '.npm-global', 'bin'), - ].filter(Boolean); - const existing = new Set(entries.map(lowerCaseOnWindows)); - const promoted = candidates.filter((candidate, index) => candidates.indexOf(candidate) === index && existing.has(lowerCaseOnWindows(candidate))); - if (!promoted.length) return { key, value: original }; - const promotedKeys = new Set(promoted.map(lowerCaseOnWindows)); - return { key, value: [...promoted, ...entries.filter((entry) => !promotedKeys.has(lowerCaseOnWindows(entry)))].join(path.delimiter) }; + if (!resumeId && command) return command; + return buildGjcShellCommand(resumeId, env); } function sessionKey(projectPath: string, sessionId: string | null, plain: boolean, command: string): string { @@ -184,13 +156,13 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke previous.buffer.forEach((data) => write({ type: 'output', data })); return; } - const executable = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; - const commandLine = shellCommand(data, dependencies); + const env = buildShellEnvironment(process.env); + const commandLine = shellCommand(data, dependencies, env); const resumeId = nativeSession(data, dependencies); - const npmPath = preferredPath(process.env); - activePty = pty.spawn(executable, os.platform() === 'win32' ? ['-Command', commandLine] : ['-c', commandLine], { + const { executable, args } = buildShellLaunch(commandLine, env); + activePty = pty.spawn(executable, args, { name: 'xterm-256color', cols: dimension(data.cols, 80), rows: dimension(data.rows, 24), cwd, - env: { ...process.env, [npmPath.key]: npmPath.value, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' }, + env, }); const child = activePty; sessions.set(key, { pty: child, ws, buffer: [], timeoutId: null, projectPath, sessionId, urlText: '', reportedUrls: new Set() }); diff --git a/server/routes/system.js b/server/routes/system.js index e8cef27b..567e6c33 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { isAbsolute } from 'node:path'; +import { isAbsolute, win32 } from 'node:path'; import express from 'express'; @@ -9,26 +9,45 @@ import { sessionsDb } from '../modules/database/repositories/sessions.db.js'; const PLATFORM_OPENERS = { darwin: { command: 'open', args: (target) => [target] }, - win32: { command: 'cmd', args: (target) => ['/c', 'start', '', target] }, linux: { command: 'xdg-open', args: (target) => [target] }, }; -function defaultOpener(target) { - const opener = PLATFORM_OPENERS[process.platform] ?? PLATFORM_OPENERS.linux; - return new Promise((resolve, reject) => { - execFile(opener.command, opener.args(target), (error) => { - if (error) reject(error); - else resolve(); +export function createSystemOpener({ platform = process.platform, env = process.env, execute = execFile } = {}) { + return (target) => { + const opener = PLATFORM_OPENERS[platform] ?? PLATFORM_OPENERS.linux; + let command = opener.command; + let args = opener.args(target); + if (platform === 'win32') { + const rootKey = Object.keys(env).find((key) => key.toLowerCase() === 'systemroot'); + command = win32.join(env[rootKey] || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + // cmd/start reinterprets &, %, quotes and other metacharacters in targets. + // ShellExecute opens the association directly. Encode the target as data, + // including Unicode quotes that PowerShell otherwise treats as delimiters. + const encodedTarget = Buffer.from(target, 'utf8').toString('base64'); + const script = [ + "$ErrorActionPreference = 'Stop'", + '$info = New-Object System.Diagnostics.ProcessStartInfo', + `$info.FileName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedTarget}'))`, + '$info.UseShellExecute = $true', + '[void][System.Diagnostics.Process]::Start($info)', + ].join('; '); + args = ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]; + } + return new Promise((resolve, reject) => { + execute(command, args, { windowsHide: true, shell: false }, (error) => { + if (error) reject(error); + else resolve(); + }); }); - }); + }; } -export function createSystemRouter({ opener = defaultOpener } = {}) { +export function createSystemRouter({ opener = createSystemOpener() } = {}) { const router = express.Router(); router.post('/open-file', async (req, res) => { const target = req.body?.path; - if (typeof target !== 'string' || !isAbsolute(target)) { + if (typeof target !== 'string' || target.includes('\0') || !isAbsolute(target)) { return res.status(400).json({ error: 'An absolute path is required.' }); } diff --git a/server/routes/system.test.js b/server/routes/system.test.js index ba91f22c..94820e45 100644 --- a/server/routes/system.test.js +++ b/server/routes/system.test.js @@ -7,7 +7,7 @@ import test from 'node:test'; import express from 'express'; -import { createSystemRouter } from './system.js'; +import { createSystemOpener, createSystemRouter } from './system.js'; async function serve(opener) { const app = express(); @@ -47,7 +47,7 @@ async function serve(opener) { test('open-file rejects relative and non-string paths', async () => { const server = await serve(async () => {}); try { - for (const body of [{ path: 'relative/file.txt' }, { path: 42 }, {}]) { + for (const body of [{ path: 'relative/file.txt' }, { path: 42 }, { path: `${tmpdir()}\0injected` }, {}]) { assert.equal((await server.postOpenFile(body)).status, 400); } } finally { @@ -129,6 +129,90 @@ test('open-url hands an https link to the OS opener and refuses everything else' } }); +function captureWindowsOpener(error = null) { + const calls = []; + const opener = createSystemOpener({ + platform: 'win32', env: { systemroot: 'D:\\Windows' }, + execute: (command, args, options, callback) => { + calls.push({ command, args, options }); + callback(error); + }, + }); + return { opener, calls }; +} + +function assertLiteralWindowsTarget(call, target) { + assert.equal(call.command, 'D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + assert.deepEqual(call.options, { windowsHide: true, shell: false }); + assert.deepEqual(call.args.slice(0, -1), ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand']); + assert.match(call.args.at(-1), /^[A-Za-z0-9+/=]+$/); + const script = Buffer.from(call.args.at(-1), 'base64').toString('utf16le'); + const match = script.match(/FromBase64String\('([A-Za-z0-9+/=]+)'\)/); + assert.ok(match, 'the target must be carried as data, never shell syntax'); + assert.equal(Buffer.from(match[1], 'base64').toString('utf8'), target); + assert.equal(script, [ + "$ErrorActionPreference = 'Stop'", + '$info = New-Object System.Diagnostics.ProcessStartInfo', + `$info.FileName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${match[1]}'))`, + '$info.UseShellExecute = $true', + '[void][System.Diagnostics.Process]::Start($info)', + ].join('; ')); +} + +test('Windows opener preserves paths, UNC shares and URL metacharacters without cmd expansion', async () => { + const { opener, calls } = captureWindowsOpener(); + const targets = [ + "C:\\Users\\O'Brien & Co\\%USERPROFILE% !x! ^ (한글)\\note.txt", + 'C:\\Users\\smart‘’quotes\\$(calc);note.txt', + '\\\\server\\shared files\\100% ready & done.txt', + 'https://example.com/oauth?code=a&state=%PATH%!x!^|echo&return=";$(calc)#fragment', + ]; + for (const target of targets) await opener(target); + assert.equal(calls.length, targets.length); + calls.forEach((call, index) => assertLiteralWindowsTarget(call, targets[index])); +}); + +test('open-file and open-url keep literal targets through the Windows process-launch boundary', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'gajae-system-windows-')); + const target = path.join(dir, "한글 O'Brien & %PATH% !test! ‘quoted’.txt"); + writeFileSync(target, 'hello'); + const { opener, calls } = captureWindowsOpener(); + const server = await serve(opener); + try { + assert.equal((await server.postOpenFile({ path: target })).status, 200); + const url = 'https://example.com/oauth?code=a&state=%PATH%!test!^|echo&return=%22%26calc#fragment'; + assert.equal((await server.postOpenUrl({ url })).status, 200); + assert.equal(calls.length, 2); + assertLiteralWindowsTarget(calls[0], target); + assertLiteralWindowsTarget(calls[1], new URL(url).href); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('Windows process-launch failures reach the HTTP error response', async () => { + const { opener } = captureWindowsOpener(new Error('ShellExecute failed')); + const server = await serve(opener); + try { + const response = await server.postOpenUrl({ url: 'https://example.com/' }); + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: 'Failed to open the link' }); + } finally { + await server.close(); + } +}); + +test('macOS and Linux openers still pass a single literal target without a shell', async () => { + for (const [platform, expected] of [['darwin', 'open'], ['linux', 'xdg-open']]) { + const calls = []; + const opener = createSystemOpener({ platform, execute: (...args) => { calls.push(args.slice(0, -1)); args.at(-1)(null); } }); + const target = '/tmp/note with spaces & $(echo injected).txt'; + await opener(target); + assert.deepEqual(calls, [[expected, [target], { windowsHide: true, shell: false }]]); + } +}); + test('debug-bundle carries the session row, the transcript tail and the log tails as text', async () => { const server = await serve(async () => {}); try { diff --git a/server/utils/runtime-paths.js b/server/utils/runtime-paths.js index bd7434a0..f949af39 100644 --- a/server/utils/runtime-paths.js +++ b/server/utils/runtime-paths.js @@ -5,6 +5,11 @@ export function getModuleDir(importMetaUrl) { return path.dirname(fileURLToPath(importMetaUrl)); } +export function getBundledExecutablePath(importMetaUrl, executable, platform = process.platform) { + return path.join(findAppRoot(getModuleDir(importMetaUrl)), 'dist-native', + platform === 'win32' ? `${executable}.exe` : executable); +} + function findServerRoot(startDir) { // Source files live under /server, while compiled files live under /dist-server/server. // Walking up to the nearest "server" folder gives every backend module one stable anchor diff --git a/server/utils/runtime-paths.test.js b/server/utils/runtime-paths.test.js new file mode 100644 index 00000000..edab3297 --- /dev/null +++ b/server/utils/runtime-paths.test.js @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { getBundledExecutablePath } from './runtime-paths.js'; + +test('bundled executables use Windows suffixes in source and packaged server layouts', () => { + const root = path.resolve('app with spaces'); + for (const source of ['server/gjc-worker-client.ts', 'dist-server/server/gjc-worker-client.js', + 'server/modules/automation/browser-sidecar-client.ts', 'dist-server/server/modules/automation/browser-sidecar-client.js']) { + const url = pathToFileURL(path.join(root, source)).href; + assert.equal(getBundledExecutablePath(url, 'bun', 'win32'), path.join(root, 'dist-native', 'bun.exe')); + assert.equal(getBundledExecutablePath(url, 'gajae-core', 'win32'), path.join(root, 'dist-native', 'gajae-core.exe')); + assert.equal(getBundledExecutablePath(url, 'bun', 'linux'), path.join(root, 'dist-native', 'bun')); + assert.equal(getBundledExecutablePath(url, 'bun', 'darwin'), path.join(root, 'dist-native', 'bun')); + } +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 13393b48..7756f981 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -56,6 +56,137 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "atk" version = "0.18.2" @@ -166,6 +297,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -346,6 +490,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-random" version = "0.1.18" @@ -776,6 +929,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -802,6 +982,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -943,6 +1143,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1003,9 +1216,11 @@ dependencies = [ "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-runtime", "tauri-runtime-wry", "tokio", + "windows-sys 0.59.0", ] [[package]] @@ -1335,6 +1550,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1728,6 +1949,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -2164,6 +2391,16 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2199,6 +2436,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2407,6 +2650,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2439,6 +2693,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2480,6 +2748,15 @@ dependencies = [ "toml_edit 0.20.2", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.4", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -2745,6 +3022,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3260,6 +3550,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3507,6 +3808,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b441b6d5d1a194e9fee0b358fe0d602ded845d0f580e1f8c8ef78ebc3c8b225d" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.7.0" @@ -3607,6 +3923,19 @@ dependencies = [ "toml 0.9.5", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -3819,6 +4148,18 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.7.0", + "toml_parser", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -3950,6 +4291,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4730,12 +5082,18 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "winreg" @@ -4818,6 +5176,76 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -4837,3 +5265,44 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f6c8b4d3..dfec50be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,3 +22,7 @@ tauri-plugin-deep-link = "=2.3.0" tokio = { version = "1", features = ["sync", "time"] } tauri-runtime = "=2.7.0" tauri-runtime-wry = "=2.7.0" + +[target.'cfg(windows)'.dependencies] +tauri-plugin-single-instance = "=2.3.0" +windows-sys = { version = "=0.59.0", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading"] } diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 00000000..7f464fae Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/scripts/generate-windows-icon.mjs b/src-tauri/scripts/generate-windows-icon.mjs new file mode 100644 index 00000000..db3c4fee --- /dev/null +++ b/src-tauri/scripts/generate-windows-icon.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Losslessly pack the existing RGBA PNGs into a Windows ICO. PNG-backed ICO +// entries are supported by the Windows versions supported by Tauri and NSIS. +// No resampling, metadata, timestamps, external tools, or new dependencies. +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const iconDirectory = join(dirname(dirname(fileURLToPath(import.meta.url))), 'icons'); +export const sourceIcons = ['32x32.png', '128x128.png', '128x128@2x.png']; +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +export function pngsToIco(pngs) { + if (pngs.length === 0 || pngs.length > 65535) throw new Error('ICO requires between 1 and 65535 PNG images'); + const header = Buffer.alloc(6 + pngs.length * 16); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(pngs.length, 4); + let offset = header.length; + const sizes = new Set(); + for (const [index, png] of pngs.entries()) { + if (png.length < 33 || !png.subarray(0, 8).equals(pngSignature) + || png.readUInt32BE(8) !== 13 || png.toString('ascii', 12, 16) !== 'IHDR') { + throw new Error('ICO input must be a PNG with an IHDR chunk'); + } + const width = png.readUInt32BE(16); + const height = png.readUInt32BE(20); + if (width !== height || width < 1 || width > 256) throw new Error('ICO PNGs must be square and at most 256 pixels'); + if (png[24] !== 8 || png[25] !== 6) throw new Error('ICO PNGs must use 8-bit RGBA'); + if (sizes.has(width)) throw new Error(`Duplicate ICO size: ${width}`); + sizes.add(width); + const entry = 6 + index * 16; + header[entry] = width === 256 ? 0 : width; + header[entry + 1] = height === 256 ? 0 : height; + header.writeUInt16LE(1, entry + 4); + header.writeUInt16LE(32, entry + 6); + header.writeUInt32LE(png.length, entry + 8); + header.writeUInt32LE(offset, entry + 12); + offset += png.length; + } + return Buffer.concat([header, ...pngs]); +} + +export async function generateWindowsIcon({ directory = iconDirectory, write = false } = {}) { + const pngs = await Promise.all(sourceIcons.map((name) => readFile(join(directory, name)))); + const ico = pngsToIco(pngs); + const destination = join(directory, 'icon.ico'); + if (write) { + await writeFile(destination, ico); + } else { + const existing = await readFile(destination).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!existing?.equals(ico)) { + throw new Error('Windows icon is missing or stale. Run node src-tauri/scripts/generate-windows-icon.mjs --write'); + } + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && !['--write', '--check'].includes(args[0]))) { + throw new Error('Usage: generate-windows-icon.mjs [--write | --check]'); + } + await generateWindowsIcon({ write: args[0] === '--write' }); +} diff --git a/src-tauri/scripts/generate-windows-icon.test.mjs b/src-tauri/scripts/generate-windows-icon.test.mjs new file mode 100644 index 00000000..2a94a768 --- /dev/null +++ b/src-tauri/scripts/generate-windows-icon.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { generateWindowsIcon, pngsToIco, sourceIcons } from './generate-windows-icon.mjs'; + +const iconDirectory = join(dirname(dirname(fileURLToPath(import.meta.url))), 'icons'); + +test('ICO directory describes three complete, byte-identical PNGs including the 256-pixel sentinel', async () => { + const pngs = await Promise.all(sourceIcons.map((name) => readFile(join(iconDirectory, name)))); + const ico = pngsToIco(pngs); + assert.equal(ico.readUInt16LE(0), 0); + assert.equal(ico.readUInt16LE(2), 1); + assert.equal(ico.readUInt16LE(4), 3); + let end = 6 + 3 * 16; + for (let index = 0; index < 3; index += 1) { + const entry = 6 + index * 16; + const dimension = [32, 128, 256][index]; + assert.equal(ico[entry] || 256, dimension); + assert.equal(ico[entry + 1] || 256, dimension); + assert.equal(ico[entry + 2], 0); + assert.equal(ico[entry + 3], 0); + assert.equal(ico.readUInt16LE(entry + 4), 1); + assert.equal(ico.readUInt16LE(entry + 6), 32); + const length = ico.readUInt32LE(entry + 8); + const offset = ico.readUInt32LE(entry + 12); + assert.equal(offset, end); + assert.equal(length, pngs[index].length); + assert.deepEqual(ico.subarray(offset, offset + length), pngs[index]); + end += length; + } + assert.equal(end, ico.length); + assert.deepEqual(pngsToIco(pngs), ico); + assert.deepEqual(await readFile(join(iconDirectory, 'icon.ico')), ico); + await generateWindowsIcon(); +}); + +test('ICO conversion rejects unsupported source formats and sizes', async () => { + const png = await readFile(join(iconDirectory, '32x32.png')); + assert.throws(() => pngsToIco([]), /requires/); + assert.throws(() => pngsToIco([Buffer.alloc(33)]), /PNG/); + assert.throws(() => pngsToIco([png.subarray(0, 32)]), /PNG/); + assert.throws(() => pngsToIco([png, png]), /Duplicate/); + const nonsquare = Buffer.from(png); + nonsquare.writeUInt32BE(31, 20); + assert.throws(() => pngsToIco([nonsquare]), /square/); + const oversized = await readFile(join(iconDirectory, '512x512.png')); + assert.throws(() => pngsToIco([oversized]), /256/); + const rgb = Buffer.from(png); + rgb[25] = 2; + assert.throws(() => pngsToIco([rgb]), /RGBA/); +}); + +test('check detects missing/stale assets and regeneration is deterministic without altering source PNGs', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'gajae ico tests ')); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const name of sourceIcons) await copyFile(join(iconDirectory, name), join(directory, name)); + await assert.rejects(generateWindowsIcon({ directory }), /missing or stale/); + await generateWindowsIcon({ directory, write: true }); + const first = await readFile(join(directory, 'icon.ico')); + await generateWindowsIcon({ directory, write: true }); + assert.deepEqual(await readFile(join(directory, 'icon.ico')), first); + await generateWindowsIcon({ directory }); + await writeFile(join(directory, 'icon.ico'), first.subarray(0, first.length - 1)); + await assert.rejects(generateWindowsIcon({ directory }), /missing or stale/); + for (const name of sourceIcons) { + assert.deepEqual(await readFile(join(directory, name)), await readFile(join(iconDirectory, name))); + } +}); diff --git a/src-tauri/scripts/tauri.mjs b/src-tauri/scripts/tauri.mjs index ccb6a12a..0facd114 100644 --- a/src-tauri/scripts/tauri.mjs +++ b/src-tauri/scripts/tauri.mjs @@ -1,53 +1,205 @@ -import { readFile, rm, writeFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; +import { readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { desktopBuildArgs, linuxDebDependencies } from '../../scripts/release/desktop-platforms.mjs'; + +import { desktopPlatform, linuxDebDependencies } from '../../scripts/release/desktop-platforms.mjs'; const srcTauriDir = dirname(dirname(fileURLToPath(import.meta.url))); -const rootDir = dirname(srcTauriDir); -const [packageJson, cargoToml, config] = await Promise.all([ - readFile(join(rootDir, 'package.json'), 'utf8').then(JSON.parse), - readFile(join(srcTauriDir, 'Cargo.toml'), 'utf8'), - readFile(join(srcTauriDir, 'tauri.conf.json'), 'utf8').then(JSON.parse), -]); - -if (typeof packageJson.desktopVersion !== 'string' || packageJson.desktopVersion.length === 0) { - throw new Error('package.json desktopVersion must be a non-empty string'); -} -if ('version' in config) { - throw new Error('src-tauri/tauri.conf.json must not declare version; it is overlaid from package.json desktopVersion'); -} - -const cargoVersion = cargoToml.match(/^version\s*=\s*"([^"]+)"\s*$/m)?.[1]; -if (cargoVersion !== packageJson.desktopVersion) { - throw new Error('src-tauri/Cargo.toml package.version must match package.json desktopVersion'); -} -const tauriArgs = desktopBuildArgs(process.argv.slice(2)); - -const overlayPath = join(srcTauriDir, `.tauri-config-${process.pid}.json`); -// Tauri merges tauri..conf.json itself. Do not overlay base macOS -// bundle targets onto Linux. Only Linux builds need the host libc floor. -const overlay = { version: packageJson.desktopVersion }; -if (process.platform === 'linux' && tauriArgs[0] === 'build') { - const linuxConfig = JSON.parse(await readFile(join(srcTauriDir, 'tauri.linux.conf.json'), 'utf8')); - overlay.bundle = { linux: { deb: { depends: linuxDebDependencies(linuxConfig.bundle?.linux?.deb?.depends || []) } } }; -} -await writeFile(overlayPath, `${JSON.stringify(overlay, null, 2)}\n`); - -try { - const command = process.platform === 'win32' ? 'tauri.cmd' : 'tauri'; - const subcommand = tauriArgs.length > 0 ? [tauriArgs[0]] : []; - const rest = tauriArgs.slice(subcommand.length); - const child = spawn(command, [...subcommand, '--config', overlayPath, ...rest], { - cwd: srcTauriDir, - stdio: 'inherit', - }); - const code = await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (exitCode) => resolve(exitCode ?? 1)); - }); - process.exitCode = code; -} finally { - await rm(overlayPath, { force: true }); +const require = createRequire(import.meta.url); +const appCommands = new Set(['dev', 'build', 'bundle']); +const windowsTarget = 'x86_64-pc-windows-msvc'; + +function commandIndex(args) { + return args.findIndex((argument) => !argument.startsWith('-')); +} + +function isHelp(args) { + const separator = args.indexOf('--'); + return args.slice(0, separator === -1 ? args.length : separator) + .some((argument) => ['--help', '-h', '--version', '-V'].includes(argument)); +} + +function buildTarget(platform, arch) { + if (platform === 'win32' && arch === 'x64') return windowsTarget; + try { + return desktopPlatform(platform, arch).target; + } catch (error) { + throw new Error( + `Tauri desktop packaging requires Linux x64, macOS arm64, or native Windows x64 MSVC; received ${platform}-${arch}`, + { cause: error }, + ); + } +} + +export function prepareTauriArgs(args, { + platform = process.platform, + arch = process.arch, + version, + config, +} = {}) { + const index = commandIndex(args); + const command = args[index]; + if (!appCommands.has(command) || isHelp(args)) return [...args]; + + const separator = args.indexOf('--'); + const tauriArgs = args.slice(0, separator === -1 ? args.length : separator); + const runnerArgs = separator === -1 ? [] : args.slice(separator); + if (command === 'build' || command === 'bundle') { + const target = buildTarget(platform, arch); + const targets = []; + for (let i = index + 1; i < tauriArgs.length; i += 1) { + const argument = tauriArgs[i]; + if (argument === '--target' || argument === '-t') { + targets.push(tauriArgs[++i]); + } else if (argument.startsWith('--target=')) { + targets.push(argument.slice('--target='.length)); + } else if (argument.startsWith('-t') && !argument.startsWith('--')) { + targets.push(argument.slice(2).replace(/^=/, '')); + } + } + if (targets.length > 1) throw new Error('Specify the Tauri target only once'); + if (targets.length && targets[0] !== target) { + throw new Error(`Tauri desktop packaging on ${platform}-${arch} only supports the ${target} target (require --target ${target})`); + } + if (!targets.length) tauriArgs.push('--target', target); + } + + // Tauri automatically merges tauri..conf.json. Overlay only the + // version (and, for Linux, the host libc floor) so platform bundle settings + // such as Windows NSIS or macOS DMG targets remain authoritative. + tauriArgs.splice(index + 1, 0, '--config', config ?? JSON.stringify({ version })); + return [...tauriArgs, ...runnerArgs]; +} + +async function desktopVersion(directory) { + const [packageJson, cargoToml, config] = await Promise.all([ + readFile(join(dirname(directory), 'package.json'), 'utf8').then(JSON.parse), + readFile(join(directory, 'Cargo.toml'), 'utf8'), + readFile(join(directory, 'tauri.conf.json'), 'utf8').then(JSON.parse), + ]); + if (typeof packageJson.desktopVersion !== 'string' || packageJson.desktopVersion.trim().length === 0) { + throw new Error('package.json desktopVersion must be a non-empty string'); + } + if ('version' in config) { + throw new Error('src-tauri/tauri.conf.json must not declare version; it is overlaid from package.json desktopVersion'); + } + const cargoPackage = cargoToml.split(/^\[package\][ \t]*\r?$/m)[1]?.split(/^\[/m)[0]; + const cargoVersion = cargoPackage?.match(/^version\s*=\s*"([^"]+)"\s*$/m)?.[1]; + if (cargoVersion !== packageJson.desktopVersion) { + throw new Error('src-tauri/Cargo.toml package.version must match package.json desktopVersion'); + } + return packageJson.desktopVersion; +} + +export async function checkWindowsPayload(directory) { + const inputs = [ + 'binaries/gajae-app-server-x86_64-pc-windows-msvc.exe', + 'resources/server-payload/dist-native/bun.exe', + 'resources/server-payload/dist-native/gajae-core.exe', + ]; + const missing = []; + for (const input of inputs) { + const file = await stat(join(directory, input)).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!file?.isFile() || file.size === 0) missing.push(input); + } + if (missing.length) { + throw new Error(`Missing Windows packaging inputs: ${missing.join(', ')}. Stage the Windows server payload and pinned Node sidecar before running Tauri.`); + } +} + +async function createOverlay(directory, platform, command, version, glibcVersion) { + const overlay = { version }; + if (platform !== 'linux' || (command !== 'build' && command !== 'bundle')) return overlay; + + const linuxConfig = await readFile(join(directory, 'tauri.linux.conf.json'), 'utf8').then(JSON.parse); + const dependencies = linuxConfig.bundle?.linux?.deb?.depends || []; + const hostGlibc = glibcVersion ?? (process.platform === 'linux' + ? process.report.getReport().header.glibcVersionRuntime + : null); + overlay.bundle = { linux: { deb: { depends: linuxDebDependencies(dependencies, hostGlibc) } } }; + return overlay; +} + +function resolveCliPath() { + try { + return require.resolve('@tauri-apps/cli/tauri.js'); + } catch { + return null; + } +} + +export async function runTauri(args, { + directory = srcTauriDir, + platform = process.platform, + arch = process.arch, + cliPath, + env = process.env, + glibcVersion, +} = {}) { + const command = args[commandIndex(args)]; + const needsConfig = appCommands.has(command) && !isHelp(args); + const version = needsConfig ? await desktopVersion(directory) : undefined; + let overlayPath; + + try { + let tauriArgs; + if (needsConfig) { + // Validate the native target before reading or writing platform overlays. + prepareTauriArgs(args, { platform, arch, version }); + const overlay = await createOverlay(directory, platform, command, version, glibcVersion); + overlayPath = join(directory, `.tauri-config-${process.pid}.json`); + tauriArgs = prepareTauriArgs(args, { + platform, + arch, + version, + config: overlayPath, + }); + if (platform === 'win32' && (command === 'build' || command === 'bundle')) { + await checkWindowsPayload(directory); + } + await writeFile(overlayPath, `${JSON.stringify(overlay, null, 2)}\n`); + } else { + tauriArgs = prepareTauriArgs(args, { + platform, + arch, + version, + }); + } + + const childEnv = { ...env }; + // clap expects a boolean, but CI providers commonly export CI=1. + if (childEnv.CI === '1') childEnv.CI = 'true'; + if (childEnv.CI === '0') childEnv.CI = 'false'; + + const resolvedCliPath = cliPath ?? resolveCliPath(); + const commandPath = process.platform === 'win32' ? 'tauri.cmd' : 'tauri'; + const child = resolvedCliPath + ? spawn(process.execPath, [resolvedCliPath, ...tauriArgs], { + cwd: directory, + stdio: 'inherit', + env: childEnv, + shell: false, + }) + : spawn(commandPath, tauriArgs, { + cwd: directory, + stdio: 'inherit', + env: childEnv, + shell: false, + }); + return await new Promise((resolveExit, reject) => { + child.once('error', reject); + child.once('close', (exitCode) => resolveExit(exitCode ?? 1)); + }); + } finally { + if (overlayPath) await rm(overlayPath, { force: true }); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = await runTauri(process.argv.slice(2)); } diff --git a/src-tauri/scripts/tauri.test.mjs b/src-tauri/scripts/tauri.test.mjs new file mode 100644 index 00000000..9c3841e0 --- /dev/null +++ b/src-tauri/scripts/tauri.test.mjs @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { checkWindowsPayload, prepareTauriArgs, runTauri } from './tauri.mjs'; + +const execute = promisify(execFile); +const directory = dirname(dirname(fileURLToPath(import.meta.url))); +const windows = { platform: 'win32', arch: 'x64', version: '0.2.2' }; +const mac = { ...windows, platform: 'darwin', arch: 'arm64' }; +const linux = { ...windows, platform: 'linux', arch: 'x64' }; +const windowsTarget = 'x86_64-pc-windows-msvc'; +const macTarget = 'aarch64-apple-darwin'; +const linuxTarget = 'x86_64-unknown-linux-gnu'; +const overlay = ['--config', '{"version":"0.2.2"}']; +const payloadInputs = [ + 'binaries/gajae-app-server-x86_64-pc-windows-msvc.exe', + 'resources/server-payload/dist-native/bun.exe', + 'resources/server-payload/dist-native/gajae-core.exe', +]; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'gajae tauri tests ')); + t.after(() => rm(root, { recursive: true, force: true })); + const fixtureDirectory = join(root, 'src-tauri'); + await mkdir(fixtureDirectory); + await writeFile(join(root, 'package.json'), JSON.stringify({ desktopVersion: '0.2.2' })); + await writeFile(join(fixtureDirectory, 'Cargo.toml'), '[package]\r\nname = "gajae-app"\r\nversion = "0.2.2"\r\n\r\n[dependencies]\r\n'); + await writeFile(join(fixtureDirectory, 'tauri.conf.json'), '{"bundle":{"targets":["dmg"]}}'); + await writeFile(join(fixtureDirectory, 'tauri.linux.conf.json'), JSON.stringify({ + bundle: { targets: ['deb', 'appimage'], linux: { deb: { depends: ['git', 'libc6 (>= 2.35)'] } } }, + })); + const cliPath = join(root, 'fake tauri cli.mjs'); + const outputPath = join(root, 'result.json'); + await writeFile(cliPath, ` + import { readFile, writeFile } from 'node:fs/promises'; + const args = process.argv.slice(2); + const configIndex = args.indexOf('--config'); + await writeFile(process.env.TAURI_TEST_RESULT, JSON.stringify({ + executable: process.execPath, args, cwd: process.cwd(), ci: process.env.CI, + configPath: configIndex === -1 ? null : args[configIndex + 1], + config: configIndex === -1 ? null : JSON.parse(await readFile(args[configIndex + 1], 'utf8')), + })); + process.exitCode = Number(process.env.TAURI_TEST_EXIT ?? 0); + `); + return { + directory: fixtureDirectory, cliPath, outputPath, root, + env: { ...process.env, TAURI_TEST_RESULT: outputPath, CI: '1' }, + }; +} + +async function stagePayload(fixtureDirectory) { + for (const input of payloadInputs) { + const destination = join(fixtureDirectory, input); + await mkdir(dirname(destination), { recursive: true }); + // The wrapper checks presence; the root payload builder owns runtime hashes + // and native smoke tests. These files are never executed. + await writeFile(destination, 'staged runtime'); + } +} + +test('native Linux, Windows, and macOS builds select their host targets', () => { + for (const command of ['build', 'bundle']) { + assert.deepEqual(prepareTauriArgs([command], windows), [command, ...overlay, '--target', windowsTarget]); + assert.deepEqual(prepareTauriArgs([command], mac), [command, ...overlay, '--target', macTarget]); + assert.deepEqual(prepareTauriArgs([command], linux), [command, ...overlay, '--target', linuxTarget]); + } + assert.deepEqual(prepareTauriArgs(['build', '--bundles', 'app'], mac), [ + 'build', ...overlay, '--bundles', 'app', '--target', macTarget, + ]); +}); + +test('all Tauri target flag forms are honored and incompatible targets rejected', () => { + for (const [options, target] of [[windows, windowsTarget], [mac, macTarget], [linux, linuxTarget]]) { + for (const value of [target, 'x86_64-pc-windows-gnu', options === windows ? macTarget : windowsTarget]) { + for (const flags of [['--target', value], [`--target=${value}`], ['-t', value], [`-t=${value}`], [`-t${value}`]]) { + if (value === target) { + assert.deepEqual(prepareTauriArgs(['build', ...flags], options), ['build', ...overlay, ...flags]); + } else { + assert.throws(() => prepareTauriArgs(['build', ...flags], options), /only supports/); + } + } + } + } +}); + +test('missing and repeated targets fail rather than silently selecting a sidecar', () => { + for (const flags of [['--target'], ['-t'], ['--target='], ['-t='], ['--target', '--debug']]) { + assert.throws(() => prepareTauriArgs(['build', ...flags], windows), /only supports/); + } + assert.throws(() => prepareTauriArgs(['build', '-t', windowsTarget, '--target', windowsTarget], windows), /only once/); +}); + +test('packaging rejects non-native architectures and unsupported hosts', () => { + for (const options of [ + { platform: 'win32', arch: 'arm64' }, + { platform: 'win32', arch: 'ia32' }, + { platform: 'linux', arch: 'arm64' }, + { platform: 'darwin', arch: 'x64' }, + { platform: 'freebsd', arch: 'x64' }, + ]) { + assert.throws(() => prepareTauriArgs(['build'], { ...windows, ...options }), /requires Linux x64.*native Windows x64 MSVC/); + } +}); + +test('version and default target stay before the Cargo argument separator', () => { + const args = ['-v', 'build', '--config', 'C:\\build checkout\\custom.json', '--', '--locked']; + const original = [...args]; + assert.deepEqual(prepareTauriArgs(args, windows), [ + '-v', 'build', ...overlay, '--config', 'C:\\build checkout\\custom.json', '--target', windowsTarget, '--', '--locked', + ]); + assert.deepEqual(args, original); + assert.deepEqual(prepareTauriArgs(['build', '--', '--help'], windows), [ + 'build', ...overlay, '--target', windowsTarget, '--', '--help', + ]); +}); + +test('dev overlays the version without forcing a packaging target', () => { + assert.deepEqual(prepareTauriArgs(['dev', '--no-watch'], windows), ['dev', ...overlay, '--no-watch']); +}); + +test('help, version, and unrelated commands are forwarded without build flags on any host', () => { + for (const args of [[], ['--help'], ['--version'], ['info'], ['icon', '--help'], ['build', '--help'], ['bundle', '-h']]) { + assert.deepEqual(prepareTauriArgs(args, { platform: 'linux', arch: 'x64' }), args); + } +}); + +test('actual Node subprocess works with spaces, preserves arguments, propagates exit status, and normalizes CI', async (t) => { + const f = await fixture(t); + await stagePayload(f.directory); + const env = { ...f.env, TAURI_TEST_EXIT: '17' }; + const args = ['build', '--config', join(f.root, 'custom config.json'), '--', '--locked']; + assert.equal(await runTauri(args, { ...f, ...windows, env }), 17); + const result = JSON.parse(await readFile(f.outputPath, 'utf8')); + assert.equal(result.executable, process.execPath); + assert.equal(result.cwd, f.directory); + assert.equal(result.args[result.args.indexOf('--config') + 1], result.configPath); + assert.deepEqual(result.config, { version: '0.2.2' }); + assert.deepEqual(result.args, [ + 'build', '--config', result.configPath, '--config', join(f.root, 'custom config.json'), + '--target', windowsTarget, '--', '--locked', + ]); + assert.equal(result.ci, 'true'); + assert.equal(env.CI, '1'); + assert.equal((await readdir(f.directory)).some((file) => file.startsWith('.tauri-config-')), false); +}); + +test('macOS invokes the same Node CLI and keeps macOS bundle overrides', async (t) => { + const f = await fixture(t); + const args = ['build', '--bundles', 'app']; + assert.equal(await runTauri(args, { ...f, ...mac, env: { ...f.env, CI: '0' } }), 0); + const result = JSON.parse(await readFile(f.outputPath, 'utf8')); + assert.deepEqual(result.config, { version: '0.2.2' }); + assert.deepEqual(result.args, [ + 'build', '--config', result.configPath, '--bundles', 'app', '--target', macTarget, + ]); + assert.equal(result.ci, 'false'); +}); + +test('Linux overlays the host libc floor without replacing Linux bundle configuration', async (t) => { + const f = await fixture(t); + const args = ['build', '--bundles', 'deb,appimage', '--', '--locked']; + assert.equal(await runTauri(args, { + ...f, ...linux, glibcVersion: '2.39', env: { ...f.env, CI: '0' }, + }), 0); + const result = JSON.parse(await readFile(f.outputPath, 'utf8')); + assert.deepEqual(result.config, { + version: '0.2.2', + bundle: { linux: { deb: { depends: ['git', 'libc6 (>= 2.35)', 'libc6 (>= 2.39)'] } } }, + }); + assert.deepEqual(result.args, [ + 'build', '--config', result.configPath, '--bundles', 'deb,appimage', '--target', linuxTarget, '--', '--locked', + ]); + assert.equal(result.ci, 'false'); +}); + +test('Windows packaging fails before starting Tauri when sidecar or payload executables are absent', async (t) => { + const f = await fixture(t); + await assert.rejects(runTauri(['build'], { ...f, ...windows }), (error) => { + for (const input of payloadInputs) assert.ok(error.message.includes(input)); + return true; + }); + await assert.rejects(readFile(f.outputPath), { code: 'ENOENT' }); + await stagePayload(f.directory); + await checkWindowsPayload(f.directory); + await writeFile(join(f.directory, payloadInputs[1]), ''); + await assert.rejects(checkWindowsPayload(f.directory), /bun\.exe/); + await rm(join(f.directory, payloadInputs[2])); + await mkdir(join(f.directory, payloadInputs[2])); + await assert.rejects(checkWindowsPayload(f.directory), /gajae-core\.exe/); +}); + +test('version drift fails before starting Tauri', async (t) => { + const f = await fixture(t); + await writeFile(join(f.directory, 'Cargo.toml'), '[package]\nname = "test"\nversion = "9.9.9"\n'); + await assert.rejects(runTauri(['dev'], f), /package.version must match/); + await writeFile(join(f.directory, 'Cargo.toml'), '[package]\nversion = "0.2.2"\n'); + await writeFile(join(f.directory, 'tauri.conf.json'), '{"version":"0.2.2"}'); + await assert.rejects(runTauri(['dev'], f), /must not declare version/); + await writeFile(join(f.root, 'package.json'), '{"desktopVersion":""}'); + await assert.rejects(runTauri(['dev'], f), /desktopVersion must be a non-empty string/); + await assert.rejects(readFile(f.outputPath), { code: 'ENOENT' }); +}); + +test('a dependency version cannot masquerade as the Cargo package version', async (t) => { + const f = await fixture(t); + await writeFile(join(f.directory, 'Cargo.toml'), '[package]\nname = "test"\n[dependencies.example]\nversion = "0.2.2"\n'); + await assert.rejects(runTauri(['dev'], f), /package.version must match/); +}); + +test('spawn errors reject rather than reporting a successful build', async (t) => { + const f = await fixture(t); + await assert.rejects(runTauri(['--help'], { ...f, directory: join(f.root, 'missing') }), { code: 'ENOENT' }); +}); + +test('Windows overlay selects NSIS and ICO while preserving sidecar, payload layout, and macOS config', async () => { + const base = JSON.parse(await readFile(join(directory, 'tauri.conf.json'), 'utf8')); + const platformConfig = JSON.parse(await readFile(join(directory, 'tauri.windows.conf.json'), 'utf8')); + const linuxConfig = JSON.parse(await readFile(join(directory, 'tauri.linux.conf.json'), 'utf8')); + const merged = { ...base, ...platformConfig, bundle: { ...base.bundle, ...platformConfig.bundle } }; + assert.deepEqual(base.bundle.targets, ['dmg']); + assert.ok(base.bundle.icon.every((icon) => icon.endsWith('.png'))); + assert.equal(base.bundle.macOS.minimumSystemVersion, '11.0'); + assert.deepEqual(merged.bundle.targets, ['nsis']); + assert.deepEqual(merged.bundle.icon, ['icons/icon.ico']); + assert.deepEqual(merged.bundle.externalBin, ['binaries/gajae-app-server']); + assert.deepEqual(merged.bundle.resources, ['resources/server-payload/']); + assert.equal(merged.bundle.windows.nsis.installMode, 'currentUser'); + assert.equal(merged.bundle.windows.nsis.installerIcon, merged.bundle.windows.nsis.uninstallerIcon); + assert.deepEqual(merged.bundle.windows.webviewInstallMode, { type: 'downloadBootstrapper', silent: true }); + assert.equal('version' in platformConfig, false); + const configArgument = prepareTauriArgs(['build'], windows)[2]; + assert.deepEqual({ ...merged, ...JSON.parse(configArgument) }.bundle.targets, ['nsis']); + assert.deepEqual(JSON.parse(prepareTauriArgs(['build'], linux)[2]), { version: '0.2.2' }); + assert.deepEqual(linuxConfig.bundle.targets, ['deb', 'appimage']); +}); + +test('the installed Tauri CLI version and build help work through the wrapper without PATH shims', async () => { + const script = join(directory, 'scripts', 'tauri.mjs'); + const version = await execute(process.execPath, [script, '--version']); + assert.match(version.stdout, /tauri(?:-cli)? \d+\.\d+/); + const help = await execute(process.execPath, [script, 'build', '--help']); + assert.match(help.stdout, /--target/); +}); diff --git a/src-tauri/scripts/windows-server-bootstrap.test.mjs b/src-tauri/scripts/windows-server-bootstrap.test.mjs new file mode 100644 index 00000000..a0b54da9 --- /dev/null +++ b/src-tauri/scripts/windows-server-bootstrap.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +const bootstrap = await readFile(new URL('../src/windows-server-bootstrap.cjs', import.meta.url), 'utf8'); + +test('Windows bootstrap imports a Unicode path and delivers one graceful shutdown through stdin', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'gajae desktop 한글 ')); + t.after(() => rm(directory, { recursive: true, force: true })); + const entrypoint = join(directory, 'server with spaces.mjs'); + await writeFile(entrypoint, ` + let requests = 0; + process.on('SIGTERM', () => { + requests += 1; + setTimeout(() => { console.log('stopped:' + requests); process.exit(0); }, 50); + }); + console.log('ready:' + process.argv[1]); + setInterval(() => {}, 1000); + `); + const child = spawn(process.execPath, ['--eval', bootstrap, entrypoint], { stdio: ['pipe', 'pipe', 'pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); }); + const timer = setTimeout(() => child.kill('SIGKILL'), 5000); + t.after(() => clearTimeout(timer)); + const completed = once(child, 'close'); + let output = ''; + let errors = ''; + let sent = false; + child.stderr.setEncoding('utf8').on('data', (chunk) => { errors += chunk; }); + child.stdout.setEncoding('utf8').on('data', (chunk) => { + output += chunk; + if (!sent && output.includes('ready:')) { + sent = true; + child.stdin.write('ignored\ngajae-desktop-shut'); + child.stdin.write('down\ngajae-desktop-shutdown\n'); + } + }); + const [code, signal] = await completed; + assert.equal(code, 0, errors); + assert.equal(signal, null); + assert.ok(output.includes(`ready:${entrypoint}`), output); + assert.ok(output.includes('stopped:1'), output); + assert.equal(errors, ''); +}); + +test('Windows bootstrap reports an import failure without leaving its stdin listener alive', async () => { + const child = spawn(process.execPath, ['--eval', bootstrap, join(tmpdir(), 'missing-gajae-entrypoint.mjs')]); + child.stderr.resume(); + child.stdout.resume(); + const timer = setTimeout(() => child.kill('SIGKILL'), 5000); + try { + const [code, signal] = await once(child, 'close'); + assert.equal(code, 1); + assert.equal(signal, null); + } finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill('SIGKILL'); + } +}); diff --git a/src-tauri/src/lifecycle.rs b/src-tauri/src/lifecycle.rs index 26f86584..ac701436 100644 --- a/src-tauri/src/lifecycle.rs +++ b/src-tauri/src/lifecycle.rs @@ -1,13 +1,99 @@ use std::{ - sync::atomic::{AtomicBool, Ordering}, - time::Duration, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, + }, + time::{Duration, Instant}, }; use tauri::{AppHandle, Manager, Window}; use tokio::sync::Notify; +pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(windows)] +pub const FORCE_STOP_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct Sidecar { + pub pid: u32, + #[cfg(unix)] + child: Mutex>, + #[cfg(windows)] + process: Option>, +} + +impl Sidecar { + #[cfg(test)] + pub(crate) fn unmanaged(pid: u32) -> Self { + Self { + pid, + #[cfg(unix)] + child: Mutex::new(None), + #[cfg(windows)] + process: None, + } + } + + #[cfg(unix)] + pub fn unix_owned(child: tauri_plugin_shell::process::CommandChild) -> Self { + let pid = child.pid(); + Self { + pid, + child: Mutex::new(Some(child)), + } + } + + #[cfg(windows)] + pub fn windows(process: std::sync::Arc) -> Self { + Self { + pid: process.pid(), + process: Some(process), + } + } + + fn stop(&self, force: bool) -> Result<(), String> { + #[cfg(unix)] + { + if force { + let child = self + .child + .lock() + .expect("sidecar child lock poisoned") + .take(); + let child = child.ok_or_else(|| { + "desktop server has no owned child for forced shutdown".to_owned() + })?; + return child + .kill() + .map_err(|error| format!("could not force-stop desktop server: {error}")); + } + return signal_sidecar(self.pid, 15); + } + #[cfg(windows)] + { + let process = self + .process + .as_ref() + .ok_or_else(|| "server has no owned job".to_owned())?; + if force { + process.terminate() + } else { + process.request_shutdown() + } + } + } + + fn stopped(&self) -> bool { + #[cfg(unix)] + return !process_alive(self.pid); + #[cfg(windows)] + self.process + .as_ref() + .is_some_and(|process| process.tree_is_empty().unwrap_or(false)) + } +} + pub struct SidecarLifecycle { - pid: std::sync::Mutex>, + sidecar: Mutex>, shutting_down: AtomicBool, shutdown_waiting: AtomicBool, exited: Notify, @@ -16,7 +102,7 @@ pub struct SidecarLifecycle { impl Default for SidecarLifecycle { fn default() -> Self { Self { - pid: std::sync::Mutex::new(None), + sidecar: Mutex::new(None), shutting_down: AtomicBool::new(false), shutdown_waiting: AtomicBool::new(false), exited: Notify::new(), @@ -25,25 +111,42 @@ impl Default for SidecarLifecycle { } impl SidecarLifecycle { - /// Keep spawning and PID publication in the same critical section as Quit. - /// A repeated Retry must not replace the server whose exit we still await. + /// Spawn, tree ownership and publication share the Quit critical section. pub fn start( &self, - spawn: impl FnOnce() -> Result<(u32, T), String>, + spawn: impl FnOnce() -> Result<(Sidecar, T), String>, ) -> Result, String> { - let mut pid = self.pid.lock().expect("sidecar lifecycle lock poisoned"); - if pid.is_some() || self.is_shutting_down() { + let mut sidecar = self + .sidecar + .lock() + .expect("sidecar lifecycle lock poisoned"); + if sidecar.is_some() || self.is_shutting_down() { return Ok(None); } - let (started_pid, child) = spawn()?; - *pid = Some(started_pid); + let (started, child) = spawn()?; + *sidecar = Some(started); Ok(Some(child)) } pub fn exited(&self, exited_pid: u32) { - let mut pid = self.pid.lock().expect("sidecar lifecycle lock poisoned"); - if *pid == Some(exited_pid) { - *pid = None; + let mut sidecar = self + .sidecar + .lock() + .expect("sidecar lifecycle lock poisoned"); + if sidecar + .as_ref() + .is_some_and(|child| child.pid == exited_pid) + { + // A root exit is insufficient on Windows: descendants may still be + // exiting after TerminateJobObject. Retry must wait for an empty job. + #[cfg(windows)] + if sidecar + .as_ref() + .is_some_and(|child| child.process.is_some() && !child.stopped()) + { + return; + } + *sidecar = None; self.exited.notify_waiters(); } } @@ -51,42 +154,67 @@ impl SidecarLifecycle { pub fn is_shutting_down(&self) -> bool { self.shutting_down.load(Ordering::SeqCst) } + pub fn has_sidecar(&self) -> bool { - self.pid - .lock() - .expect("sidecar lifecycle lock poisoned") - .is_some() + self.current_pid().is_some() } - /// The final app.exit() must be allowed through ExitRequested, but only - /// after Quit has fenced off new spawns and the tracked server has exited. pub fn shutdown_complete(&self) -> bool { self.is_shutting_down() && !self.has_sidecar() } + fn current_pid(&self) -> Option { + self.sidecar + .lock() + .expect("sidecar lifecycle lock poisoned") + .as_ref() + .map(|child| child.pid) + } + pub fn begin_shutdown(&self) -> Option { - let pid = self.pid.lock().expect("sidecar lifecycle lock poisoned"); + let sidecar = self + .sidecar + .lock() + .expect("sidecar lifecycle lock poisoned"); if self.shutting_down.swap(true, Ordering::SeqCst) { return None; } - *pid + sidecar.as_ref().map(|child| child.pid) } - /// Signal only the currently tracked child, while Retry cannot replace it. - pub(crate) fn terminate(&self, expected_pid: u32) -> Result<(), String> { - let pid = self.pid.lock().expect("sidecar lifecycle lock poisoned"); - if *pid == Some(expected_pid) { - terminate_sidecar(expected_pid) - } else { - Ok(()) + pub fn stop(&self, pid: u32, force: bool) -> Result<(), String> { + let sidecar = self + .sidecar + .lock() + .expect("sidecar lifecycle lock poisoned"); + match sidecar.as_ref().filter(|child| child.pid == pid) { + Some(child) => child.stop(force), + None => Ok(()), // An old supervisor must never stop a replacement. } } - async fn wait_for_exit(&self) -> Result<(), String> { - tokio::time::timeout(Duration::from_secs(30), async { + pub fn reap_if_stopped(&self, pid: u32) -> bool { + let mut sidecar = self + .sidecar + .lock() + .expect("sidecar lifecycle lock poisoned"); + match sidecar.as_ref().filter(|child| child.pid == pid) { + Some(child) if !child.stopped() => false, + _ => { + if sidecar.as_ref().is_some_and(|child| child.pid == pid) { + *sidecar = None; + self.exited.notify_waiters(); + } + true + } + } + } + + async fn wait_for_exit(&self, timeout: Duration) -> Result<(), String> { + tokio::time::timeout(timeout, async { loop { - // Register before checking the durable state: exit can happen - // before this wait begins, or between the check and the await. + // Register before checking durable state: exit can happen + // before this wait begins or between the check and await. let exited = self.exited.notified(); if !self.has_sidecar() { return; @@ -98,48 +226,61 @@ impl SidecarLifecycle { .map_err(|_| "desktop server did not complete its graceful shutdown".to_owned()) } - fn wait_for_exit_blocking(&self, pid: u32, timeout: Duration) { - let deadline = std::time::Instant::now() + timeout; - while std::time::Instant::now() < deadline { - if !self.has_sidecar() || !process_alive(pid) { - return; + fn wait_for_exit_blocking(&self, pid: u32, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if self.reap_if_stopped(pid) { + return true; } - std::thread::sleep(Duration::from_millis(100)); + std::thread::sleep(Duration::from_millis(50)); + } + self.reap_if_stopped(pid) + } + + pub async fn stop_and_wait(&self, pid: u32, grace: Duration) -> Result<(), String> { + #[cfg(unix)] + { + // Unix has no process-tree ownership here. Never SIGKILL a ready + // server root: its workers and PTYs would be orphaned. Keep the + // sidecar tracked so the caller can report the error and retry. + self.stop(pid, false)?; + return self.wait_for_exit(grace).await; + } + #[cfg(windows)] + { + // The supervisor drains output concurrently. A closed stdin or + // failed signal skips directly to the bounded Job force-stop. + if self.stop(pid, false).is_ok() && self.wait_for_exit(grace).await.is_ok() { + return Ok(()); + } + self.stop(pid, true)?; + let deadline = Instant::now() + FORCE_STOP_TIMEOUT; + while Instant::now() < deadline { + if self.reap_if_stopped(pid) { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err("desktop server tree did not exit after forced shutdown".to_owned()) } } } #[cfg(unix)] -pub fn terminate_sidecar(pid: u32) -> Result<(), String> { +fn signal_sidecar(pid: u32, signal: i32) -> Result<(), String> { unsafe extern "C" { fn kill(pid: i32, signal: i32) -> i32; } - const SIGTERM: i32 = 15; - let target = i32::try_from(pid) - .ok() - .filter(|pid| *pid > 0) - .ok_or_else(|| format!("invalid desktop server PID {pid}"))?; - if unsafe { kill(target, SIGTERM) } == 0 { + if pid == 0 || pid > i32::MAX as u32 { + return Err("invalid desktop server PID".to_owned()); + } + if unsafe { kill(pid as i32, signal) } == 0 || !process_alive(pid) { Ok(()) } else { - let error = std::io::Error::last_os_error(); - // The plugin may already have reaped the child but still be draining - // inherited output pipes before it delivers Terminated. - if error.raw_os_error() == Some(3) { - Ok(()) // ESRCH: the child is already gone. - } else { - Err(format!( - "could not send SIGTERM to desktop server {pid}: {error}" - )) - } + Err(format!("could not signal desktop server {pid}")) } } -#[cfg(not(unix))] -pub fn terminate_sidecar(_pid: u32) -> Result<(), String> { - Err("graceful sidecar termination is unavailable on this platform".to_owned()) -} - #[cfg(unix)] pub(crate) fn process_alive(pid: u32) -> bool { unsafe extern "C" { @@ -156,36 +297,20 @@ pub(crate) fn process_alive(pid: u32) -> bool { } } -#[cfg(not(unix))] -pub(crate) fn process_alive(_pid: u32) -> bool { - false -} - -/// Last-resort synchronous shutdown for exit paths that cannot be prevented. -/// macOS delivers Quit Apple events (Cmd-Q, `osascript quit`) through -/// `applicationShouldTerminate`, which this Tauri version answers YES without -/// emitting a preventable ExitRequested — the process then exits without ever -/// signalling the sidecar, orphaning the server tree. Called from -/// `RunEvent::Exit`, this blocks the exiting thread until the sidecar's -/// graceful SIGTERM shutdown finishes (bounded at 30s). +/// macOS Apple-event Quit can bypass ExitRequested in this Tauri version. pub fn blocking_shutdown(app: &AppHandle) { let lifecycle = app.state::(); - match lifecycle.begin_shutdown() { - Some(pid) => { - let _ = terminate_sidecar(pid); - lifecycle.wait_for_exit_blocking(pid, Duration::from_secs(30)); - } - None => { - // A graceful shutdown is already in flight; wait for it to settle - // so exiting cannot outrun the sidecar's shutdown fence. - let pid = *lifecycle - .pid - .lock() - .expect("sidecar lifecycle lock poisoned"); - if let Some(pid) = pid { - lifecycle.wait_for_exit_blocking(pid, Duration::from_secs(30)); - } + if let Some(pid) = lifecycle.begin_shutdown() { + let _ = lifecycle.stop(pid, false); + } + if let Some(pid) = lifecycle.current_pid() { + #[cfg(windows)] + if !lifecycle.wait_for_exit_blocking(pid, SHUTDOWN_TIMEOUT) { + let _ = lifecycle.stop(pid, true); + lifecycle.wait_for_exit_blocking(pid, FORCE_STOP_TIMEOUT); } + #[cfg(unix)] + let _ = lifecycle.wait_for_exit_blocking(pid, SHUTDOWN_TIMEOUT); } } @@ -201,8 +326,15 @@ pub fn handle_close_request(window: &Window, event: &tauri::WindowEvent) { graceful_quit(window.app_handle().clone()); // Preserve macOS close-to-hide and its Dock/Reopen behavior. - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] let _ = window.hide(); + + // There is no Windows dock or tray from which to reopen a hidden app. + #[cfg(target_os = "windows")] + graceful_quit(window.app_handle().clone()); + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + graceful_quit(window.app_handle().clone()); } } @@ -218,19 +350,15 @@ pub fn graceful_quit(app: AppHandle) { if lifecycle.shutdown_waiting.swap(true, Ordering::SeqCst) { return; } - let pid = *lifecycle - .pid - .lock() - .expect("sidecar lifecycle lock poisoned"); - let signal = pid.map_or(Ok(()), |pid| lifecycle.terminate(pid)); + let pid = lifecycle.current_pid(); tauri::async_runtime::spawn(async move { let lifecycle = app.state::(); - let result = match signal { - Ok(()) => lifecycle.wait_for_exit().await, - Err(error) => Err(error), + let result = match pid { + Some(pid) => lifecycle.stop_and_wait(pid, SHUTDOWN_TIMEOUT).await, + None => Ok(()), }; // Keep the spawn fence, but let another Close/Quit retry a failed - // signal or wait. Previously every subsequent Quit became a no-op. + // signal or wait. The sidecar itself remains tracked on failure. lifecycle.shutdown_waiting.store(false, Ordering::SeqCst); if let Err(error) = result { show_shutdown_error(&app, &error); @@ -244,7 +372,7 @@ fn show_shutdown_error(app: &AppHandle, error: &str) { if let Some(window) = app.get_webview_window("main") { let escaped = serde_json::to_string(error).unwrap_or_else(|_| "\"Shutdown failed\"".to_owned()); - let _ = window.eval(format!("document.body.innerHTML='

Gajae Code App could not quit safely

';document.querySelector('pre').textContent={escaped};")); + let _ = window.eval(format!("document.body.innerHTML='

Gajae Code App could not quit safely

';document.querySelector('pre').textContent={escaped};")); let _ = window.show(); } } @@ -256,7 +384,9 @@ mod tests { #[test] fn shutdown_is_started_once() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(42)); assert_eq!(lifecycle.begin_shutdown(), None); } @@ -276,7 +406,9 @@ mod tests { #[test] fn repeated_close_cannot_release_the_shutdown_fence_early() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(); assert!(!lifecycle.shutdown_complete()); assert_eq!(lifecycle.begin_shutdown(), Some(42)); assert!(!lifecycle.shutdown_complete()); @@ -286,10 +418,7 @@ mod tests { lifecycle.exited(43); assert!(!lifecycle.shutdown_complete()); lifecycle.exited(42); - assert!( - lifecycle.shutdown_complete(), - "the final app.exit() must proceed" - ); + assert!(lifecycle.shutdown_complete()); assert_eq!( lifecycle.start::<()>(|| panic!("a completed shutdown must still reject Retry")), Ok(None) @@ -299,7 +428,9 @@ mod tests { #[test] fn unexpected_server_exit_does_not_count_as_a_requested_shutdown() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(); lifecycle.exited(42); assert!(!lifecycle.shutdown_complete()); assert_eq!(lifecycle.begin_shutdown(), None); @@ -309,14 +440,19 @@ mod tests { #[test] fn exit_before_waiting_completes_shutdown_immediately() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(42)); lifecycle.exited(42); tauri::async_runtime::block_on(async { - tokio::time::timeout(Duration::from_millis(100), lifecycle.wait_for_exit()) - .await - .expect("an already exited server must not wait for another notification") - .unwrap(); + tokio::time::timeout( + Duration::from_millis(100), + lifecycle.wait_for_exit(SHUTDOWN_TIMEOUT), + ) + .await + .expect("an already exited server must not wait for another notification") + .unwrap(); }); } @@ -324,7 +460,9 @@ mod tests { fn retry_does_not_spawn_another_server_until_the_previous_one_exits() { let lifecycle = SidecarLifecycle::default(); assert_eq!( - lifecycle.start(|| Ok((42, "first"))).unwrap(), + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), "first"))) + .unwrap(), Some("first") ); assert_eq!( @@ -333,7 +471,9 @@ mod tests { ); lifecycle.exited(42); assert_eq!( - lifecycle.start(|| Ok((43, "retry"))).unwrap(), + lifecycle + .start(|| Ok((Sidecar::unmanaged(43), "retry"))) + .unwrap(), Some("retry") ); lifecycle.exited(42); @@ -346,7 +486,12 @@ mod tests { assert!(lifecycle .start::<()>(|| Err("spawn failed".to_owned())) .is_err()); - assert_eq!(lifecycle.start(|| Ok((42, ()))).unwrap(), Some(())); + assert_eq!( + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(), + Some(()) + ); assert_eq!(lifecycle.begin_shutdown(), Some(42)); lifecycle.exited(42); assert_eq!( @@ -367,7 +512,7 @@ mod tests { let start = threads.spawn(move || { starting_lifecycle.start(|| { spawn_barrier.wait(); - Ok((42, ())) + Ok((Sidecar::unmanaged(42), ())) }) }); spawning.wait(); @@ -379,10 +524,12 @@ mod tests { #[test] fn shutdown_waits_for_the_tracked_server_to_exit() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(42)); tauri::async_runtime::block_on(async { - let mut waiting = Box::pin(lifecycle.wait_for_exit()); + let mut waiting = Box::pin(lifecycle.wait_for_exit(SHUTDOWN_TIMEOUT)); assert!( tokio::time::timeout(Duration::from_millis(20), &mut waiting) .await @@ -403,4 +550,67 @@ mod tests { assert!(lifecycle.shutdown_complete()); }); } + + #[cfg(unix)] + #[test] + fn unix_graceful_shutdown_timeout_keeps_the_root_alive() { + use std::io::{BufRead, BufReader}; + use std::process::{Child, Command, Stdio}; + + struct Fixture(Child); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let mut child = Fixture( + Command::new("/bin/sh") + .args(["-c", "trap '' TERM; printf 'ready\\n'; read line"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut ready = String::new(); + BufReader::new(child.0.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + assert_eq!(ready, "ready\n"); + let pid = child.0.id(); + let lifecycle = SidecarLifecycle::default(); + lifecycle + .start(|| Ok((Sidecar::unmanaged(pid), ()))) + .unwrap(); + + tauri::async_runtime::block_on(async { + let error = tokio::time::timeout( + Duration::from_secs(2), + lifecycle.stop_and_wait(pid, Duration::from_millis(50)), + ) + .await + .expect("graceful shutdown must report its own timeout") + .unwrap_err(); + assert!(error.contains("did not complete its graceful shutdown")); + }); + assert!( + child.0.try_wait().unwrap().is_none(), + "Unix graceful Quit must leave the server running, not an unreaped zombie" + ); + assert!(lifecycle.has_sidecar()); + } + + #[cfg(windows)] + #[test] + fn windows_unconfirmed_tree_exit_remains_owned_for_observation() { + let lifecycle = SidecarLifecycle::default(); + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(); + assert!( + lifecycle.has_sidecar(), + "a sidecar must remain owned until the Job tree is proven empty" + ); + assert!(!lifecycle.reap_if_stopped(42)); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a3e780a5..e3f69d66 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,6 +15,14 @@ mod navigation; #[cfg(any(target_os = "macos", test))] mod qa_profile; mod supervisor; +#[cfg(windows)] +mod windows_process; + +#[derive(Default)] +struct PendingDeepLink { + url: std::sync::Mutex>, + ui_ready: std::sync::atomic::AtomicBool, +} #[cfg(not(target_os = "linux"))] struct SingleInstanceLock { @@ -47,7 +55,14 @@ fn is_gajae_deep_link(url: &tauri::Url) -> bool { } fn deep_link_route(url: &tauri::Url) -> Option { - if !is_gajae_deep_link(url) || url.host_str() != Some("open") { + if !is_gajae_deep_link(url) + || url.host_str() != Some("open") + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.query().is_some() + || url.fragment().is_some() + { return None; } let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect(); @@ -159,8 +174,33 @@ fn route_startup_deep_links( } fn desktop_page_load(webview: &tauri::Webview, payload: &tauri::webview::PageLoadPayload<'_>) { + let app = webview.app_handle(); + if payload.event() == tauri::webview::PageLoadEvent::Started { + app.state::() + .ui_ready + .store(false, std::sync::atomic::Ordering::SeqCst); + } if payload.event() == tauri::webview::PageLoadEvent::Finished { supervisor::restore_recovery(webview); + if payload.url().host_str() == Some("127.0.0.1") + && payload.url().path() != "/desktop/bootstrap" + && app + .state::() + .permits(payload.url()) + { + app.state::() + .ui_ready + .store(true, std::sync::atomic::Ordering::SeqCst); + let pending = app + .state::() + .url + .lock() + .expect("deep-link lock poisoned") + .take(); + if let Some(url) = pending { + route_deep_link(app, url); + } + } } #[cfg(target_os = "linux")] route_startup_deep_links(webview, payload); @@ -197,6 +237,34 @@ fn route_deep_link(app: &tauri::AppHandle, url: tauri::Url) { if deep_link_route(&url).is_none() { return; } + if app.get_webview_window("main").is_none() { + *app.state::() + .url + .lock() + .expect("deep-link lock poisoned") = Some(url); + return; + } + if let Some(window) = app.get_webview_window("main") { + let on_server = app + .state::() + .ui_ready + .load(std::sync::atomic::Ordering::SeqCst) + && window.url().ok().is_some_and(|current| { + current.host_str() == Some("127.0.0.1") + && current.path() != "/desktop/bootstrap" + && app.state::().permits(¤t) + }); + if !on_server { + *app.state::() + .url + .lock() + .expect("deep-link lock poisoned") = Some(url); + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return; + } + } let _ = app.emit_to("main", "desktop://deep-link", url.as_str()); if let Some(window) = app.get_webview_window("main") { // The served UI is a remote loopback origin where Tauri IPC event @@ -268,6 +336,22 @@ fn main() { }; let builder = tauri::Builder::default() + .manage(PendingDeepLink::default()) + .manage(navigation::LoopbackOrigin::default()) + .manage(lifecycle::SidecarLifecycle::default()) + .manage(supervisor::RecoveryScreen::default()); + // Windows protocol activation starts a second process. Forward to the + // running instance before its setup lock can reject the activation. + #[cfg(windows)] + let builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + if args.len() == 2 { + if let Ok(url) = args[1].parse() { + route_deep_link(app, url); + } + } + focus_main_window(app); + })); + let builder = builder .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_deep_link::init()) .plugin(navigation::plugin()) @@ -307,9 +391,6 @@ fn main() { if let Some(profile) = qa_profile { app.manage(profile); } - app.manage(navigation::LoopbackOrigin::default()); - app.manage(lifecycle::SidecarLifecycle::default()); - app.manage(supervisor::RecoveryScreen::default()); #[cfg(target_os = "macos")] if let Some(profile) = app.try_state::() { profile.create_windows(app, &qa_windows)?; @@ -368,6 +449,13 @@ fn main() { app.deep_link().on_open_url(move |event| { receive_deep_links(&app_handle, event.urls()); }); + // The plugin captures cold-start arguments before this listener. + #[cfg(not(target_os = "linux"))] + if let Some(urls) = app.deep_link().get_current()? { + for url in urls { + route_deep_link(app.handle(), url); + } + } supervisor::start(app.handle().clone()); Ok(()) }); @@ -378,9 +466,8 @@ fn main() { |app: &tauri::AppHandle, event: tauri::RunEvent| match event { tauri::RunEvent::ExitRequested { api, .. } => { // graceful_quit finishes with app.exit(), which requests exit - // again on Linux. Let that request through only after the - // sidecar is gone; otherwise closing can never release the - // single-instance lock for the next launch. + // again on every supported platform. Let that request through + // only after the sidecar tree is gone. if !app .state::() .shutdown_complete() @@ -558,6 +645,10 @@ mod tests { "gajae-app://open/job/bad%20id", "gajae-app://open/job/a/b", "https://example.com/open/job/x", + "gajae-app://user@open/job/x", + "gajae-app://open:123/job/x", + "gajae-app://open/job/x?redirect=evil", + "gajae-app://open/job/x#evil", ] { assert_eq!( deep_link_route(&rejected.parse().unwrap()), diff --git a/src-tauri/src/navigation.rs b/src-tauri/src/navigation.rs index 6ddd9fda..bc5c2ff7 100644 --- a/src-tauri/src/navigation.rs +++ b/src-tauri/src/navigation.rs @@ -18,11 +18,17 @@ impl LoopbackOrigin { } pub(crate) fn permits(&self, url: &tauri::Url) -> bool { - if url.scheme() == "tauri" { + if !url.username().is_empty() || url.password().is_some() { + return false; + } + if url.scheme() == "tauri" && url.host_str() == Some("localhost") { return true; } - #[cfg(target_os = "windows")] - if url.scheme() == "http" && url.host_str() == Some("tauri.localhost") { + // WebView2 maps the local Tauri protocol to this HTTP origin. + if matches!(url.scheme(), "http" | "https") + && url.host_str() == Some("tauri.localhost") + && url.port().is_none() + { return true; } let origin = self.0.lock().expect("loopback origin lock poisoned"); @@ -66,6 +72,26 @@ mod tests { mod navigation_policy_tests { use super::*; + #[test] + fn recovery_origin_supports_webview2_without_accepting_lookalike_hosts() { + let origin = LoopbackOrigin::default(); + for url in [ + "tauri://localhost/", + "http://tauri.localhost/", + "https://tauri.localhost/", + ] { + assert!(origin.permits(&url.parse().unwrap())); + } + for url in [ + "tauri://evil/", + "http://tauri.localhost.evil/", + "http://tauri.localhost:8888/", + "http://user@tauri.localhost/", + ] { + assert!(!origin.permits(&url.parse().unwrap())); + } + } + #[test] fn navigation_allows_only_the_assigned_loopback_origin() { let origin = LoopbackOrigin::default(); diff --git a/src-tauri/src/qa_profile.rs b/src-tauri/src/qa_profile.rs index b3abdcb5..e719eab2 100644 --- a/src-tauri/src/qa_profile.rs +++ b/src-tauri/src/qa_profile.rs @@ -302,14 +302,22 @@ mod tests { for args in [ vec!["--qa-profile"], vec!["--qa-profile", "relative"], - vec!["--qa-profile", "/a", "--qa-profile", "/b"], vec!["--qa-profile=/tmp/a"], ] { assert!(requested_root(args.into_iter().map(str::to_owned)).is_err()); } + let root = Temp::new(); + let absolute_path = root.0.to_string_lossy().into_owned(); + assert!(requested_root(vec![ + "--qa-profile".into(), + absolute_path.clone(), + "--qa-profile".into(), + absolute_path.clone(), + ]) + .is_err()); assert_eq!( - requested_root(vec!["--qa-profile".into(), "/tmp/qa".into()]).unwrap(), - Some(PathBuf::from("/tmp/qa")) + requested_root(vec!["--qa-profile".into(), absolute_path]).unwrap(), + Some(root.0.clone()) ); for version in ["13.6.9", "11.0", "", "unknown"] { assert!(require_supported_os(version).is_err()); diff --git a/src-tauri/src/supervisor.rs b/src-tauri/src/supervisor.rs index 394f89c3..6e1275b5 100644 --- a/src-tauri/src/supervisor.rs +++ b/src-tauri/src/supervisor.rs @@ -1,3 +1,5 @@ +#[cfg(windows)] +use std::ffi::OsString; use std::fmt::Write as _; use std::{ collections::VecDeque, @@ -11,10 +13,9 @@ use std::{ use getrandom::getrandom; use serde::Deserialize; use tauri::{AppHandle, Manager, WebviewWindow}; -use tauri_plugin_shell::{ - process::{CommandChild, CommandEvent}, - ShellExt, -}; +use tauri_plugin_shell::process::CommandEvent; +#[cfg(unix)] +use tauri_plugin_shell::ShellExt; use tokio::time; const READY_KIND: &str = "gajae-desktop-ready"; @@ -98,7 +99,9 @@ fn payload_root(app: &AppHandle) -> Result { let root = ["server-payload", "resources/server-payload"] .iter() .map(|relative| resources.join(relative)) - .find_map(|candidate| candidate.canonicalize().ok()) + // Keep ordinary Windows paths: canonicalize adds a verbatim prefix + // that third-party Node tools do not consistently accept. + .find(|candidate| candidate.is_dir()) .ok_or_else(|| "server payload is missing".to_owned())?; for relative in [ "dist-server/server/index.js", @@ -114,6 +117,12 @@ fn payload_root(app: &AppHandle) -> Result { )); } } + #[cfg(windows)] + for relative in ["dist-native/bun.exe", "dist-native/gajae-core.exe"] { + if !root.join(relative).is_file() { + return Err(format!("server payload is incomplete (missing {relative})")); + } + } if !root.is_dir() { return Err("server payload root is not a directory".to_owned()); } @@ -266,29 +275,36 @@ pub(crate) fn restore_recovery(webview: &tauri::Webview) { } async fn wait_for_sidecar_exit( + lifecycle: &crate::lifecycle::SidecarLifecycle, pid: u32, events: &mut tauri::async_runtime::Receiver, timeout: Duration, ) -> bool { let deadline = Instant::now() + timeout; loop { + if lifecycle.reap_if_stopped(pid) { + return true; + } let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return lifecycle.reap_if_stopped(pid); + } let event = time::timeout(remaining.min(PROCESS_POLL_INTERVAL), events.recv()).await; - if matches!(event, Ok(Some(CommandEvent::Terminated(_)))) { + if matches!(event, Ok(Some(CommandEvent::Terminated(_)))) && lifecycle.reap_if_stopped(pid) + { return true; } // The plugin reaps before waiting for output readers, so inherited // pipes can delay Terminated even though the child is already gone. #[cfg(unix)] - if !crate::lifecycle::process_alive(pid) { + if !crate::lifecycle::process_alive(pid) && lifecycle.reap_if_stopped(pid) { return true; } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return false; - } if matches!(event, Ok(None)) { - time::sleep(remaining.min(PROCESS_POLL_INTERVAL)).await; + time::sleep( + PROCESS_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())), + ) + .await; } } } @@ -301,34 +317,32 @@ async fn stop_failed_sidecar( grace: Duration, kill_timeout: Duration, ) -> Result<(), String> { - let term_error = lifecycle.terminate(pid).err(); - if wait_for_sidecar_exit(pid, events, grace).await { - lifecycle.exited(pid); + let term_error = lifecycle.stop(pid, false).err(); + if wait_for_sidecar_exit(lifecycle, pid, events, grace).await { return Ok(()); } let Some(force_stop) = force_stop else { - return Err(format!("Desktop server {pid} did not complete graceful shutdown. Retry remains disabled until it exits.")); + return Err(format!( + "Desktop server {pid} did not complete graceful shutdown. Retry remains disabled until it exits." + )); }; - // Use the owned child handle, never a raw SIGKILL against a stale PID. - // The shell plugin's independent waiter remains responsible for reaping. let kill_error = force_stop().err(); - if wait_for_sidecar_exit(pid, events, kill_timeout).await { - lifecycle.exited(pid); + if wait_for_sidecar_exit(lifecycle, pid, events, kill_timeout).await { return Ok(()); } - // Signals and closed output are not proof of exit. Keep Retry fenced if - // force termination fails or the OS has not completed the reap deadline. let detail = kill_error .or(term_error) .unwrap_or_else(|| "exit was not confirmed".to_owned()); - Err(format!("Desktop server {pid} could not be stopped: {detail}. Retry remains disabled until it exits.")) + Err(format!( + "Desktop server {pid} could not be stopped: {detail}. Retry remains disabled until it exits." + )) } async fn handle_sidecar_failure( app: &AppHandle, window: &WebviewWindow, - child: CommandChild, - mut events: tauri::async_runtime::Receiver, + pid: u32, + events: &mut tauri::async_runtime::Receiver, message: String, was_ready: bool, ) { @@ -339,12 +353,11 @@ async fn handle_sidecar_failure( &format!("{message}\n\nStopping the previous server…"), false, ); - let pid = child.pid(); let result = stop_failed_sidecar( &lifecycle, pid, - &mut events, - (!was_ready).then_some(|| child.kill().map_err(|error| error.to_string())), + events, + (!was_ready).then_some(|| lifecycle.stop(pid, true)), if was_ready { SESSION_STOP_GRACE } else { @@ -357,12 +370,44 @@ async fn handle_sidecar_failure( show_error(window, &format!("{message}\n\n{error}"), false); // Cleanup has reported its bounded failure. Keep observing without // signalling again so a late exit can still release Quit and Retry. - while !wait_for_sidecar_exit(pid, &mut events, Duration::from_secs(1)).await {} - lifecycle.exited(pid); + while !wait_for_sidecar_exit(&lifecycle, pid, events, Duration::from_secs(1)).await {} } show_error(window, &message, true); } +/// Pipes are byte streams: JSON can be split across reads, including UTF-8. +/// An overlong line is discarded through its newline, never parsed as a suffix. +#[derive(Default)] +struct ReadyLines { + pending: Vec, + discarding: bool, +} + +impl ReadyLines { + fn push(&mut self, bytes: &[u8]) -> Vec { + let mut frames = Vec::new(); + for &byte in bytes { + if byte == b'\n' { + if !self.discarding { + if let Ok(frame) = serde_json::from_slice::(&self.pending) { + frames.push(frame); + } + } + self.pending.clear(); + self.discarding = false; + } else if !self.discarding { + if self.pending.len() == OUTPUT_LIMIT { + self.pending.clear(); + self.discarding = true; + } else { + self.pending.push(byte); + } + } + } + frames + } +} + fn navigate_and_show( app: &AppHandle, window: &WebviewWindow, @@ -437,15 +482,50 @@ pub fn start(app: AppHandle) { return; } }; - let home = env::var("HOME").unwrap_or_default(); - let path = env::var("PATH").unwrap_or_default(); let entrypoint = payload.join("dist-server/server/index.js"); + let requested_port = desktop_origin.requested_port().to_string(); + #[cfg(windows)] + let mut environment: Vec<(OsString, OsString)> = [ + ("HOST", "127.0.0.1"), + ("SERVER_PORT", requested_port.as_str()), + ("NODE_ENV", "production"), + ("GJC_DESKTOP", "1"), + ("GJC_DESKTOP_API_KEY", &api_key), + ("GJC_DESKTOP_BOOTSTRAP_NONCE", &nonce), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(); + // Preserve the user's inherited environment and Unicode home directory. + let home = app + .path() + .home_dir() + .ok() + .map(|path| path.into_os_string()) + .or_else(|| env::var_os("HOME")) + .unwrap_or_default(); + #[cfg(windows)] + environment.push(("HOME".into(), home)); + let native = payload.join("dist-native"); + let inherited_path = env::var_os("PATH").unwrap_or_default(); + let path = + env::join_paths(std::iter::once(native).chain(env::split_paths(&inherited_path))); + let path = match path { + Ok(path) => path, + Err(error) => { + show_error(&window, &format!("invalid server PATH: {error}"), true); + return; + } + }; + #[cfg(windows)] + environment.push(("PATH".into(), path)); let command = lifecycle.start(|| { reset_desktop_readiness(&app); *app.state::() .0 .lock() .expect("recovery screen lock poisoned") = None; + #[cfg(unix)] let command = app .shell() .sidecar("gajae-app-server") @@ -463,22 +543,55 @@ pub fn start(app: AppHandle) { .envs(profile.environment()) .current_dir(profile.home()) } else { - command.env("HOME", &home).env("PATH", &path) + command + .env("HOME", &home) + .env("PATH", &path) + .current_dir(&payload) }; - #[cfg(not(target_os = "macos"))] - let command = command.env("HOME", &home).env("PATH", &path); + #[cfg(all(unix, not(target_os = "macos")))] + let command = command + .env("HOME", &home) + .env("PATH", &path) + .current_dir(&payload); + #[cfg(unix)] let (events, child) = command .env("HOST", "127.0.0.1") - .env("SERVER_PORT", desktop_origin.requested_port().to_string()) + .env("SERVER_PORT", &requested_port) .env("NODE_ENV", "production") .env("GJC_DESKTOP", "1") - .env("GJC_DESKTOP_API_KEY", api_key) + .env("GJC_DESKTOP_API_KEY", &api_key) .env("GJC_DESKTOP_BOOTSTRAP_NONCE", &nonce) + .set_raw_out(true) .spawn() .map_err(|error| format!("could not start server sidecar: {error}"))?; - Ok((child.pid(), (events, child))) + #[cfg(unix)] + let sidecar_pid = child.pid(); + #[cfg(unix)] + let tracked = crate::lifecycle::Sidecar::unix_owned(child); + #[cfg(windows)] + let (events, child) = { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let directory = executable + .parent() + .ok_or_else(|| "desktop executable has no directory".to_owned())?; + crate::windows_process::spawn( + &directory.join("gajae-app-server.exe"), + &[ + "--eval".into(), + include_str!("windows-server-bootstrap.cjs").into(), + entrypoint.into_os_string(), + ], + &payload, + &environment, + )? + }; + #[cfg(windows)] + let sidecar_pid = child.pid(); + #[cfg(windows)] + let tracked = crate::lifecycle::Sidecar::windows(std::sync::Arc::clone(&child)); + Ok((tracked, (events, sidecar_pid))) }); - let (mut events, child) = match command { + let (mut events, sidecar_pid) = match command { Ok(Some(child)) => child, Ok(None) => return, Err(error) => { @@ -486,17 +599,17 @@ pub fn start(app: AppHandle) { return; } }; - let sidecar_pid = child.pid(); let deadline = Instant::now() + STARTUP_TIMEOUT; let mut output = OutputRing::default(); let mut ready = false; + let mut ready_lines = ReadyLines::default(); loop { if !ready && lifecycle.is_shutting_down() { handle_sidecar_failure( &app, &window, - child, - events, + sidecar_pid, + &mut events, "Desktop server startup was cancelled.".to_owned(), false, ) @@ -512,8 +625,8 @@ pub fn start(app: AppHandle) { handle_sidecar_failure( &app, &window, - child, - events, + sidecar_pid, + &mut events, format!( "Desktop server did not become ready before the startup timeout.\n\n{}", output.text() @@ -525,18 +638,18 @@ pub fn start(app: AppHandle) { } // Poll even after readiness: an inherited output pipe can delay // the plugin's Terminated event after its waiter reaps the server. - #[cfg(unix)] - if !crate::lifecycle::process_alive(sidecar_pid) { + if lifecycle.reap_if_stopped(sidecar_pid) { reset_desktop_readiness(&app); - lifecycle.exited(sidecar_pid); - show_error( - &window, - &format!( - "Desktop server exited before its output closed.\n\n{}", - output.text() - ), - true, - ); + if !lifecycle.is_shutting_down() { + show_error( + &window, + &format!( + "Desktop server exited before its output closed.\n\n{}", + output.text() + ), + true, + ); + } return; } let event = @@ -548,8 +661,8 @@ pub fn start(app: AppHandle) { handle_sidecar_failure( &app, &window, - child, - events, + sidecar_pid, + &mut events, format!( "Desktop server output closed unexpectedly.\n\n{}", output.text() @@ -560,15 +673,13 @@ pub fn start(app: AppHandle) { return; }; match event { - CommandEvent::Stdout(line) | CommandEvent::Stderr(line) => { + CommandEvent::Stderr(line) => output.push(&line), + CommandEvent::Stdout(line) => { output.push(&line); if ready || lifecycle.is_shutting_down() { continue; } - for raw_line in String::from_utf8_lossy(&line).lines() { - let Ok(ready_frame) = serde_json::from_str::(raw_line) else { - continue; - }; + for ready_frame in ready_lines.push(&line) { if !ready_frame.matches_sidecar(sidecar_pid) { continue; } @@ -576,7 +687,16 @@ pub fn start(app: AppHandle) { Ok(()) => { // Quit can arrive during the health request. if lifecycle.is_shutting_down() { - break; + handle_sidecar_failure( + &app, + &window, + sidecar_pid, + &mut events, + "Desktop server startup was cancelled.".to_owned(), + false, + ) + .await; + return; } if let Err(error) = desktop_origin .persist_verified_port(ready_frame.port) @@ -585,7 +705,12 @@ pub fn start(app: AppHandle) { }) { handle_sidecar_failure( - &app, &window, child, events, error, false, + &app, + &window, + sidecar_pid, + &mut events, + error, + false, ) .await; return; @@ -597,8 +722,8 @@ pub fn start(app: AppHandle) { handle_sidecar_failure( &app, &window, - child, - events, + sidecar_pid, + &mut events, format!("Desktop server did not pass identity verification: {error}\n\n{}", output.text()), false, ).await; @@ -610,22 +735,39 @@ pub fn start(app: AppHandle) { CommandEvent::Terminated(status) => { reset_desktop_readiness(&app); lifecycle.exited(sidecar_pid); - show_error( - &window, - &format!( - "Desktop server exited unexpectedly ({status:?}).\n\n{}", - output.text() - ), - true, - ); + if lifecycle.has_sidecar() { + handle_sidecar_failure( + &app, + &window, + sidecar_pid, + &mut events, + format!( + "Desktop server exited unexpectedly ({status:?}).\n\n{}", + output.text() + ), + false, + ) + .await; + return; + } + if !lifecycle.is_shutting_down() { + show_error( + &window, + &format!( + "Desktop server exited unexpectedly ({status:?}).\n\n{}", + output.text() + ), + true, + ); + } return; } CommandEvent::Error(error) => { handle_sidecar_failure( &app, &window, - child, - events, + sidecar_pid, + &mut events, format!("Desktop server failed: {error}\n\n{}", output.text()), ready, ) @@ -820,12 +962,14 @@ mod tests { } #[test] - fn hung_startup_is_killed_and_reaped_before_retry_can_spawn() { + fn hung_startup_cleanup_keeps_retry_fenced_until_exit() { tauri::async_runtime::block_on(async { for send_exit in [true, false] { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, send_exit); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) + .unwrap(); let mut stopping = Box::pin(stop_failed_sidecar( &lifecycle, child.pid, @@ -849,7 +993,12 @@ mod tests { assert_eq!(child.status().signal(), Some(9)); assert!(!crate::lifecycle::process_alive(child.pid)); let (mut retry, mut retry_events) = TestChild::spawn(false, true); - assert_eq!(lifecycle.start(|| Ok((retry.pid, ()))).unwrap(), Some(())); + assert_eq!( + lifecycle + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(retry.pid), ()))) + .unwrap(), + Some(()) + ); lifecycle.exited(child.pid); assert!( lifecycle.has_sidecar(), @@ -859,7 +1008,7 @@ mod tests { &lifecycle, retry.pid, &mut retry_events, - Some(|| panic!("a cooperative child must not be killed")), + Some(|| retry.kill()), Duration::from_secs(2), Duration::from_secs(1), ) @@ -875,7 +1024,9 @@ mod tests { tauri::async_runtime::block_on(async { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, true); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) + .unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(child.pid)); assert_eq!(lifecycle.begin_shutdown(), None); assert!(!lifecycle.shutdown_complete()); @@ -903,14 +1054,16 @@ mod tests { tauri::async_runtime::block_on(async { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, false); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) + .unwrap(); let error = time::timeout( Duration::from_secs(2), stop_failed_sidecar( &lifecycle, child.pid, &mut events, - Some(|| Err("kill denied".to_owned())), + Some(|| Err("injected kill failure".to_owned())), Duration::from_millis(30), Duration::from_millis(30), ), @@ -918,7 +1071,7 @@ mod tests { .await .unwrap() .unwrap_err(); - assert!(error.contains("kill denied")); + assert!(error.contains("injected kill failure")); assert!(lifecycle.has_sidecar()); assert!(crate::lifecycle::process_alive(child.pid)); assert_eq!( @@ -927,7 +1080,13 @@ mod tests { ); child.input.write_all(b"exit\n").unwrap(); assert!( - wait_for_sidecar_exit(child.pid, &mut events, Duration::from_secs(2)).await + wait_for_sidecar_exit( + &lifecycle, + child.pid, + &mut events, + Duration::from_secs(2) + ) + .await ); lifecycle.exited(child.pid); assert!(child.status().success()); @@ -940,7 +1099,9 @@ mod tests { tauri::async_runtime::block_on(async { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, true); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) + .unwrap(); let result = stop_failed_sidecar( &lifecycle, child.pid, @@ -955,7 +1116,13 @@ mod tests { assert!(lifecycle.has_sidecar()); child.input.write_all(b"exit\n").unwrap(); assert!( - wait_for_sidecar_exit(child.pid, &mut events, Duration::from_secs(2)).await + wait_for_sidecar_exit( + &lifecycle, + child.pid, + &mut events, + Duration::from_secs(2) + ) + .await ); lifecycle.exited(child.pid); assert!(child.status().success()); @@ -963,6 +1130,76 @@ mod tests { } } + #[test] + fn readiness_reassembles_fragmented_and_coalesced_crlf_frames() { + let frame = b"{\"kind\":\"gajae-desktop-ready\",\"pid\":1,\"host\":\"127.0.0.1\",\"port\":1234,\"protocolVersion\":1,\"version\":\"0.2.0\"}\r\n"; + for split in 0..frame.len() { + let mut lines = ReadyLines::default(); + assert!(lines.push(&frame[..split]).is_empty()); + let ready = lines.push(&frame[split..]); + assert_eq!(ready.len(), 1); + assert!(ready[0].matches_sidecar(1)); + } + let mut lines = ReadyLines::default(); + assert_eq!( + lines + .push(&[b"ordinary log\n".as_slice(), frame, frame].concat()) + .len(), + 2 + ); + assert!(lines.push(&vec![b'x'; OUTPUT_LIMIT + 1]).is_empty()); + assert!( + lines.push(frame).is_empty(), + "an oversized line must not yield a valid suffix" + ); + assert_eq!(lines.push(frame).len(), 1); + } + + #[cfg(unix)] + #[test] + fn failed_startup_cleanup_is_bounded_when_output_closes_and_sigterm_is_ignored() { + use std::io::BufRead; + use std::process::{Command, Stdio}; + tauri::async_runtime::block_on(async { + let mut child = Command::new("/bin/sh") + .args(["-c", "trap '' TERM; printf 'ready\\n'; read line"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + std::io::BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + let pid = child.id(); + let lifecycle = crate::lifecycle::SidecarLifecycle::default(); + lifecycle + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(pid), ()))) + .unwrap(); + let (sender, mut events) = tauri::async_runtime::channel(1); + drop(sender); + let cleanup = time::timeout( + Duration::from_secs(3), + stop_failed_sidecar( + &lifecycle, + pid, + &mut events, + None:: Result<(), String>>, + Duration::from_millis(100), + Duration::from_secs(2), + ), + ) + .await + .expect("closed output must not make cleanup loop forever") + .unwrap_err(); + assert!(cleanup.contains("did not complete graceful shutdown")); + child.kill().unwrap(); + assert!(!child.wait().unwrap().success()); + lifecycle.exited(pid); + assert!(!lifecycle.has_sidecar()); + }); + } + #[test] fn ready_frame_requires_loopback_contract() { let ready: ReadyFrame = serde_json::from_str(r#"{"kind":"gajae-desktop-ready","pid":1,"host":"127.0.0.1","port":1234,"protocolVersion":1,"version":"0.2.0"}"#).unwrap(); diff --git a/src-tauri/src/windows-server-bootstrap.cjs b/src-tauri/src/windows-server-bootstrap.cjs new file mode 100644 index 00000000..c1f44fcd --- /dev/null +++ b/src-tauri/src/windows-server-bootstrap.cjs @@ -0,0 +1,22 @@ +// This pipe belongs to the desktop parent. No TCP shutdown endpoint is exposed. +const { pathToFileURL } = require('node:url'); +const entrypoint = process.argv[1]; +let pending = ''; +let stopping = false; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + pending += chunk; + if (pending.length > 1024) process.exit(1); + const lines = pending.split('\n'); + pending = lines.pop(); + for (const line of lines) { + if (line !== 'gajae-desktop-shutdown' || stopping) continue; + stopping = true; + if (process.listenerCount('SIGTERM') > 0) process.emit('SIGTERM'); + else process.exit(0); // Startup has not installed the shutdown fence yet. + } +}); +import(pathToFileURL(entrypoint).href).catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src-tauri/src/windows_process.rs b/src-tauri/src/windows_process.rs new file mode 100644 index 00000000..e8edd4df --- /dev/null +++ b/src-tauri/src/windows_process.rs @@ -0,0 +1,557 @@ +//! A suspended spawn closes the race between starting Node and owning its tree. +//! The unnamed, non-inheritable job also reaps descendants if the shell crashes. +use std::{ + collections::BTreeMap, + ffi::{OsStr, OsString}, + fs::File, + io::{Read, Write}, + mem::{size_of, zeroed}, + os::windows::{ + ffi::OsStrExt, + io::{AsRawHandle, FromRawHandle, OwnedHandle}, + }, + path::Path, + ptr::{null, null_mut}, + sync::{Arc, Mutex}, +}; + +use tauri::async_runtime::{channel, Receiver, Sender}; +use tauri_plugin_shell::process::{CommandEvent, TerminatedPayload}; +use windows_sys::Win32::{ + Foundation::{SetHandleInformation, HANDLE_FLAG_INHERIT, WAIT_OBJECT_0, WAIT_TIMEOUT}, + Security::SECURITY_ATTRIBUTES, + System::{ + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + Pipes::CreatePipe, + Threading::{ + CreateProcessW, GetExitCodeProcess, ResumeThread, TerminateProcess, + WaitForSingleObject, CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, + INFINITE, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW, + }, + }, +}; + +pub struct OwnedProcess { + pid: u32, + process: OwnedHandle, + job: OwnedHandle, + stdin: Mutex, +} + +fn failure(context: &str) -> String { + format!("{context}: {}", std::io::Error::last_os_error()) +} + +fn wide(value: &OsStr) -> Result, String> { + let mut value: Vec = value.encode_wide().collect(); + if value.contains(&0) { + return Err("Windows process argument contains a NUL character".to_owned()); + } + value.push(0); + Ok(value) +} + +// CommandLineToArgvW/CRT quoting, including quotes and trailing backslashes. +fn quote(value: &OsStr) -> Result, String> { + let value = wide(value)?; + let mut result = vec![b'"' as u16]; + let mut slashes = 0; + for &unit in &value[..value.len() - 1] { + if unit == b'\\' as u16 { + slashes += 1; + continue; + } + result.extend( + std::iter::repeat(b'\\' as u16).take(if unit == b'"' as u16 { + slashes * 2 + 1 + } else { + slashes + }), + ); + slashes = 0; + result.push(unit); + } + result.extend(std::iter::repeat(b'\\' as u16).take(slashes * 2)); + result.push(b'"' as u16); + Ok(result) +} + +fn environment(overrides: &[(OsString, OsString)]) -> Result, String> { + // Windows environment names are case insensitive (notably Path vs PATH). + let mut entries = BTreeMap::new(); + for (key, value) in std::env::vars_os().chain(overrides.iter().cloned()) { + entries.insert(key.to_string_lossy().to_uppercase(), (key, value)); + } + // A bundled runtime must not execute an ambient Node preload. + entries.remove("NODE_OPTIONS"); + entries.remove("NODE_PATH"); + let mut block = Vec::new(); + for (_, (key, value)) in entries { + let mut entry = key; + entry.push("="); + entry.push(value); + block.extend(wide(&entry)?); + } + block.push(0); + if block.len() == 1 { + block.push(0); + } + Ok(block) +} + +fn pipe(parent_reads: bool) -> Result<(OwnedHandle, OwnedHandle), String> { + let security = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: null_mut(), + bInheritHandle: 1, + }; + let (mut read, mut write) = (null_mut(), null_mut()); + if unsafe { CreatePipe(&mut read, &mut write, &security, 0) } == 0 { + return Err(failure("could not create sidecar pipe")); + } + let read = unsafe { OwnedHandle::from_raw_handle(read) }; + let write = unsafe { OwnedHandle::from_raw_handle(write) }; + let (parent, child) = if parent_reads { + (read, write) + } else { + (write, read) + }; + if unsafe { SetHandleInformation(parent.as_raw_handle(), HANDLE_FLAG_INHERIT, 0) } == 0 { + return Err(failure("could not protect parent pipe handle")); + } + Ok((parent, child)) +} + +impl OwnedProcess { + pub fn pid(&self) -> u32 { + self.pid + } + + pub fn request_shutdown(&self) -> Result<(), String> { + self.stdin + .lock() + .map_err(|_| "sidecar stdin lock poisoned".to_owned())? + .write_all(b"gajae-desktop-shutdown\n") + .map_err(|error| format!("could not request server shutdown: {error}")) + } + + pub fn terminate(&self) -> Result<(), String> { + if unsafe { TerminateJobObject(self.job.as_raw_handle(), 1) } == 0 { + return Err(failure("could not terminate owned server job")); + } + Ok(()) + } + + pub fn tree_is_empty(&self) -> Result { + if self.is_alive()? { + return Ok(false); + } + let mut info: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = unsafe { zeroed() }; + if unsafe { + QueryInformationJobObject( + self.job.as_raw_handle(), + JobObjectBasicAccountingInformation, + (&mut info as *mut JOBOBJECT_BASIC_ACCOUNTING_INFORMATION).cast(), + size_of::() as u32, + null_mut(), + ) + } == 0 + { + return Err(failure("could not inspect owned server job")); + } + Ok(info.ActiveProcesses == 0) + } + + pub fn is_alive(&self) -> Result { + match unsafe { WaitForSingleObject(self.process.as_raw_handle(), 0) } { + WAIT_OBJECT_0 => Ok(false), + WAIT_TIMEOUT => Ok(true), + _ => Err(failure("could not inspect server process")), + } + } +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + let _ = self.terminate(); + } +} + +pub fn spawn( + program: &Path, + args: &[OsString], + cwd: &Path, + overrides: &[(OsString, OsString)], +) -> Result<(Receiver, Arc), String> { + let application = wide(program.as_os_str())?; + let cwd = wide(cwd.as_os_str())?; + let mut command_line = quote(program.as_os_str())?; + for arg in args { + command_line.push(b' ' as u16); + command_line.extend(quote(arg)?); + } + command_line.push(0); + let environment = environment(overrides)?; + let handle = unsafe { CreateJobObjectW(null(), null()) }; + if handle.is_null() { + return Err(failure("could not create server job")); + } + let job = unsafe { OwnedHandle::from_raw_handle(handle) }; + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if unsafe { + SetInformationJobObject( + job.as_raw_handle(), + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(failure("could not configure server job")); + } + let (stdout, child_stdout) = pipe(true)?; + let (stderr, child_stderr) = pipe(true)?; + let (stdin, child_stdin) = pipe(false)?; + let mut startup: STARTUPINFOW = unsafe { zeroed() }; + startup.cb = size_of::() as u32; + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = child_stdin.as_raw_handle(); + startup.hStdOutput = child_stdout.as_raw_handle(); + startup.hStdError = child_stderr.as_raw_handle(); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + if unsafe { + CreateProcessW( + application.as_ptr(), + command_line.as_mut_ptr(), + null(), + null(), + 1, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + environment.as_ptr().cast(), + cwd.as_ptr(), + &startup, + &mut info, + ) + } == 0 + { + return Err(failure("could not start suspended server")); + } + let process = unsafe { OwnedHandle::from_raw_handle(info.hProcess) }; + let thread = unsafe { OwnedHandle::from_raw_handle(info.hThread) }; + if unsafe { AssignProcessToJobObject(job.as_raw_handle(), process.as_raw_handle()) } == 0 { + let error = failure("could not assign server to owned job"); + unsafe { + TerminateProcess(process.as_raw_handle(), 1); + } + return Err(error); + } + let owned = Arc::new(OwnedProcess { + pid: info.dwProcessId, + process, + job, + stdin: Mutex::new(File::from(stdin)), + }); + if unsafe { ResumeThread(thread.as_raw_handle()) } == u32::MAX { + return Err(failure("could not resume owned server")); + } + drop((child_stdin, child_stdout, child_stderr)); + let (tx, rx) = channel(64); + pump(File::from(stdout), tx.clone(), CommandEvent::Stdout); + pump(File::from(stderr), tx.clone(), CommandEvent::Stderr); + let waiting = Arc::clone(&owned); + std::thread::spawn(move || { + if unsafe { WaitForSingleObject(waiting.process.as_raw_handle(), INFINITE) } + != WAIT_OBJECT_0 + { + let _ = tx.blocking_send(CommandEvent::Error(failure("could not wait for server"))); + return; + } + // A child can keep its parent's stdout open. Reap the job independently + // of pipe EOF and never let output readers hold the termination event. + let _ = waiting.terminate(); + let mut code = 1; + unsafe { + GetExitCodeProcess(waiting.process.as_raw_handle(), &mut code); + } + let _ = tx.blocking_send(CommandEvent::Terminated(TerminatedPayload { + code: Some(code as i32), + signal: None, + })); + }); + Ok((rx, owned)) +} + +fn pump(mut reader: File, tx: Sender, wrap: fn(Vec) -> CommandEvent) { + std::thread::spawn(move || { + let mut buffer = [0; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return, + Ok(count) => { + if tx.blocking_send(wrap(buffer[..count].to_vec())).is_err() { + return; + } + } + Err(error) => { + let _ = tx.blocking_send(CommandEvent::Error(error.to_string())); + return; + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestProcess(Arc); + + impl Drop for TestProcess { + fn drop(&mut self) { + let _ = self.0.terminate(); + } + } + + #[test] + fn quotes_empty_arguments_spaces_unicode_and_trailing_backslashes() { + for (argument, expected) in [ + ("", "\"\""), + ("한 글", "\"한 글\""), + ("a\"b", "\"a\\\"b\""), + ("C:\\a b\\", "\"C:\\a b\\\\\""), + ] { + assert_eq!( + String::from_utf16("e(OsStr::new(argument)).unwrap()).unwrap(), + expected + ); + } + assert!(quote(OsStr::new("bad\0argument")).is_err()); + } + + #[test] + fn environment_overrides_path_case_insensitively_and_removes_node_preloads() { + let block = environment(&[ + ("Path".into(), "owned runtime".into()), + ("NODE_OPTIONS".into(), "--require=untrusted".into()), + ]) + .unwrap(); + assert!(block.ends_with(&[0, 0])); + let text = String::from_utf16_lossy(&block); + assert_eq!( + text.split('\0') + .filter(|entry| entry.to_ascii_lowercase().starts_with("path=")) + .collect::>(), + vec!["Path=owned runtime"] + ); + assert!(!text.contains("NODE_OPTIONS=")); + } + + // Spawn this same test executable to avoid depending on Node, PowerShell, + // or a shell's quoting rules in the native process ownership regression. + #[test] + #[ignore = "fixture launched only by the job ownership test"] + fn process_tree_fixture() { + let role = std::env::var("GAJAE_DESKTOP_JOB_FIXTURE").expect("fixture role"); + if role == "parent" { + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "windows_process::tests::process_tree_fixture", + "--nocapture", + ]) + .env("GAJAE_DESKTOP_JOB_FIXTURE", "descendant") + .spawn() + .unwrap(); + println!("owned-descendant:{}", child.id()); + std::io::stdout().flush().unwrap(); + let _ = child.wait(); + } else { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + } + + #[test] + fn terminating_owned_job_reaps_descendants_with_inherited_output_handles() { + tauri::async_runtime::block_on(async { + let executable = std::env::current_exe().unwrap(); + let (mut events, process) = spawn( + &executable, + &[ + "--ignored".into(), + "--exact".into(), + "windows_process::tests::process_tree_fixture".into(), + "--nocapture".into(), + ], + executable.parent().unwrap(), + &[("GAJAE_DESKTOP_JOB_FIXTURE".into(), "parent".into())], + ) + .unwrap(); + let _cleanup = TestProcess(Arc::clone(&process)); + let mut output = String::new(); + let ready = tokio::time::timeout(std::time::Duration::from_secs(10), async { + while let Some(event) = events.recv().await { + if let CommandEvent::Stdout(bytes) = event { + output.push_str(&String::from_utf8_lossy(&bytes)); + if output.contains("owned-descendant:") { + return; + } + } + } + panic!("fixture exited before it spawned a descendant: {output}"); + }) + .await; + // Also clean up on a readiness failure so CI never leaves a fixture. + process.terminate().unwrap(); + ready.expect("fixture did not become ready"); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !process.tree_is_empty().unwrap() { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("the entire owned tree must exit, including the descendant"); + }); + } + + #[test] + fn node_graceful_shutdown_reaps_detached_descendant_in_owned_job() { + use std::{ + path::PathBuf, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE}; + + let node = std::env::var_os("npm_node_execpath") + .map(PathBuf::from) + .filter(|path| path.is_file()) + .unwrap_or_else(|| { + let found = std::process::Command::new("where.exe") + .arg("node.exe") + .output() + .expect("native Windows regression requires Node on PATH"); + assert!( + found.status.success(), + "native Windows regression requires Node on PATH" + ); + PathBuf::from( + String::from_utf8(found.stdout) + .unwrap() + .lines() + .next() + .unwrap() + .trim(), + ) + }); + let directory = std::env::temp_dir().join(format!( + "gajae job 한글 {}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&directory).unwrap(); + let entrypoint = directory.join("server fixture.cjs"); + std::fs::write( + &entrypoint, + r#" + const { spawn } = require('node:child_process'); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, stdio: 'ignore' + }); + child.unref(); + process.on('SIGTERM', () => { + process.stdout.write('graceful-shutdown\n', () => process.exit(0)); + }); + console.log('descendant:' + child.pid); + setInterval(() => {}, 1000); + "#, + ) + .unwrap(); + tauri::async_runtime::block_on(async { + let (mut events, process) = spawn( + &node, + &[ + "--eval".into(), + include_str!("windows-server-bootstrap.cjs").into(), + entrypoint.into_os_string(), + ], + &directory, + &[], + ) + .unwrap(); + let _cleanup = TestProcess(Arc::clone(&process)); + let mut output = String::new(); + let descendant_pid = tokio::time::timeout(Duration::from_secs(10), async { + while let Some(event) = events.recv().await { + if let CommandEvent::Stdout(bytes) = event { + output.push_str(&String::from_utf8_lossy(&bytes)); + for line in output + .split_inclusive('\n') + .filter(|line| line.ends_with('\n')) + { + if let Some(pid) = line.trim().strip_prefix("descendant:") { + return pid.parse::().expect("fixture must report a real PID"); + } + } + } + } + panic!("Node fixture exited before readiness: {output}"); + }) + .await + .expect("Node fixture startup exceeded deadline"); + let descendant = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, descendant_pid) }; + assert!( + !descendant.is_null(), + "detached child must be running before Quit" + ); + let descendant = unsafe { OwnedHandle::from_raw_handle(descendant) }; + assert_eq!( + unsafe { WaitForSingleObject(descendant.as_raw_handle(), 0) }, + WAIT_TIMEOUT + ); + assert!(process.is_alive().unwrap()); + process.request_shutdown().unwrap(); + let mut exit_code = None; + tokio::time::timeout(Duration::from_secs(10), async { + // Drain to EOF because root exit and output readers race. + while let Some(event) = events.recv().await { + match event { + CommandEvent::Stdout(bytes) => { + output.push_str(&String::from_utf8_lossy(&bytes)) + } + CommandEvent::Terminated(status) => exit_code = status.code, + _ => {} + } + } + while !process.tree_is_empty().unwrap() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("graceful shutdown must also reap the detached child"); + assert_eq!( + exit_code, + Some(0), + "server should complete its SIGTERM handler" + ); + assert!(output.contains("graceful-shutdown"), "{output}"); + // Job accounting can reach zero just before the kernel signals + // the last process handle. Require that signal within a bound. + assert_eq!( + unsafe { WaitForSingleObject(descendant.as_raw_handle(), 5_000) }, + WAIT_OBJECT_0 + ); + assert!(process.tree_is_empty().unwrap()); + }); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 00000000..9ed84db0 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["nsis"], + "icon": ["icons/icon.ico"], + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + }, + "nsis": { + "installMode": "currentUser", + "installerIcon": "icons/icon.ico", + "uninstallerIcon": "icons/icon.ico", + "languages": ["English", "Korean"], + "compression": "lzma" + } + } + } +}