From 7ae2cdb0132e3fddafc9aa2c30b6d0625eac6612 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 16:17:17 +0900 Subject: [PATCH 01/22] feat(windows): add native desktop preview and packaging --- .github/workflows/windows.yml | 118 ++++ AGENTS.md | 11 +- README.md | 6 +- docs/DESKTOP-TAURI-VERIFICATION.md | 3 + docs/WINDOWS-DESKTOP.md | 118 ++++ native/gajae-core/src/git.rs | 149 ++++- native/gajae-core/src/jobs.rs | 98 +++- native/gajae-core/src/lib.rs | 47 +- native/gajae-core/src/pty.rs | 19 +- native/gajae-core/tests/process_protocol.rs | 201 +++++++ package.json | 3 + scripts/check-audit.mjs | 5 +- scripts/fetch-bun.mjs | 127 ++-- scripts/fetch-bun.test.mjs | 136 +++++ scripts/fill-runtime-manifest.mjs | 14 +- scripts/lib/npm-cli.mjs | 23 + scripts/lib/npm-cli.test.mjs | 36 ++ .../release/build-windows-server-payload.mjs | 93 +++ scripts/release/smoke-windows-server.mjs | 92 +++ scripts/release/windows-payload.mjs | 143 +++++ scripts/release/windows-payload.test.mjs | 324 ++++++++++ .../release/windows-server-smoke-checks.mjs | 277 +++++++++ scripts/run-tests.mjs | 9 +- scripts/run-windows-tests.mjs | 41 ++ scripts/runtime-archive.mjs | 44 ++ scripts/start-isolated-dev.mjs | 5 +- server/gjc-cli-shim.test.ts | 32 +- server/gjc-cli-shim.ts | 29 +- server/gjc-core-host.test.ts | 112 +++- server/gjc-engine.ts | 2 + server/gjc-runtime-manifest.json | 24 + server/gjc-sdk-contract.bun.test.ts | 10 +- server/gjc-windows-job.test.ts | 116 ++++ server/gjc-windows-job.ts | 132 ++++- server/gjc-worker-client.test.ts | 13 +- server/gjc-worker-client.ts | 23 +- .../automation/browser-sidecar-client.ts | 4 +- .../websocket/services/shell-command.test.ts | 128 ++++ .../websocket/services/shell-command.ts | 80 +++ .../services/shell-websocket.service.test.ts | 95 +++ .../services/shell-websocket.service.ts | 48 +- server/routes/system.js | 41 +- server/routes/system.test.js | 88 ++- server/utils/runtime-paths.js | 5 + server/utils/runtime-paths.test.js | 18 + src-tauri/Cargo.lock | 469 +++++++++++++++ src-tauri/Cargo.toml | 4 + src-tauri/icons/icon.ico | Bin 0 -> 65180 bytes src-tauri/scripts/generate-windows-icon.mjs | 66 +++ .../scripts/generate-windows-icon.test.mjs | 72 +++ src-tauri/scripts/tauri.mjs | 164 ++++-- src-tauri/scripts/tauri.test.mjs | 210 +++++++ .../scripts/windows-server-bootstrap.test.mjs | 62 ++ src-tauri/src/lifecycle.rs | 300 +++++++--- src-tauri/src/main.rs | 122 +++- src-tauri/src/navigation.rs | 34 +- src-tauri/src/supervisor.rs | 250 ++++++-- src-tauri/src/windows-server-bootstrap.cjs | 22 + src-tauri/src/windows_process.rs | 555 ++++++++++++++++++ src-tauri/tauri.windows.conf.json | 20 + 60 files changed, 5098 insertions(+), 394 deletions(-) create mode 100644 .github/workflows/windows.yml create mode 100644 docs/WINDOWS-DESKTOP.md create mode 100644 native/gajae-core/tests/process_protocol.rs create mode 100644 scripts/fetch-bun.test.mjs create mode 100644 scripts/lib/npm-cli.mjs create mode 100644 scripts/lib/npm-cli.test.mjs create mode 100644 scripts/release/build-windows-server-payload.mjs create mode 100644 scripts/release/smoke-windows-server.mjs create mode 100644 scripts/release/windows-payload.mjs create mode 100644 scripts/release/windows-payload.test.mjs create mode 100644 scripts/release/windows-server-smoke-checks.mjs create mode 100644 scripts/run-windows-tests.mjs create mode 100644 scripts/runtime-archive.mjs create mode 100644 server/modules/websocket/services/shell-command.test.ts create mode 100644 server/modules/websocket/services/shell-command.ts create mode 100644 server/modules/websocket/services/shell-websocket.service.test.ts create mode 100644 server/utils/runtime-paths.test.js create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/scripts/generate-windows-icon.mjs create mode 100644 src-tauri/scripts/generate-windows-icon.test.mjs create mode 100644 src-tauri/scripts/tauri.test.mjs create mode 100644 src-tauri/scripts/windows-server-bootstrap.test.mjs create mode 100644 src-tauri/src/windows-server-bootstrap.cjs create mode 100644 src-tauri/src/windows_process.rs create mode 100644 src-tauri/tauri.windows.conf.json diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..a098bb0f --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,118 @@ +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: + build: + name: Windows x64 NSIS installer + 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 + 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: Verify Rust core + run: npm run check:core + + - name: Build payload and installer + run: npm run desktop:build:windows + + - name: Test Windows runtime + run: npm run test:windows + + - name: Test desktop lifecycle + run: | + cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc + + - name: Stage installer and checksum + 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 + 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 a08c8ecd..e4f0f391 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,10 @@ 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. ## Commands @@ -41,6 +45,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 ``` Run a single test file (match the runner's env): @@ -168,4 +174,5 @@ 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/SELF-HOST.md`, `CONTRIBUTING.md` — install/update lifecycle and PR rules. diff --git a/README.md b/README.md index 91b32af6..496b01a6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ sha256sum --check gajae-app-server-2.0.0-beta.7-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. + **From source — Node.js 22, Rust, Bun 1.4.0.** ```bash @@ -65,7 +67,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. Intel Mac, Windows and Linux 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. Windows x64 is available through the preview build path above; Intel Mac and Linux desktop builds are not available yet. ## Permission Modes @@ -93,7 +95,7 @@ A card answered in one tab closes in every other viewer. Always deny is offered ## Documentation - [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) +- [Desktop packaging, signing and notarization](docs/DESKTOP-TAURI-VERIFICATION.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 12a527db..873b2594 100644 --- a/docs/DESKTOP-TAURI-VERIFICATION.md +++ b/docs/DESKTOP-TAURI-VERIFICATION.md @@ -1,5 +1,8 @@ # 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. + > **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/WINDOWS-DESKTOP.md b/docs/WINDOWS-DESKTOP.md new file mode 100644 index 00000000..832261eb --- /dev/null +++ b/docs/WINDOWS-DESKTOP.md @@ -0,0 +1,118 @@ +# Windows desktop preview + +The Windows port builds an x64 NSIS installer from this branch. It includes +Node, Bun 1.4.0, 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. + +## 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. + +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. +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 the downloaded installer against the companion checksum: + +```powershell +Get-FileHash .\gajae-app-desktop-2.0.0-beta.8-windows-x64-setup.exe -Algorithm SHA256 +Get-Content .\gajae-app-desktop-2.0.0-beta.8-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. + +## Verification record — September 5, 2026 + +- Linux x64, Node 24.18.0: `npm run verify` passed, including 1,428 JavaScript + and Bun tests and 58 Rust unit tests plus three Rust process tests. +- The focused Windows contracts also pass on Linux; tests requiring actual + Windows APIs remain gated to the Windows runner. +- Tauri wrapper/bootstrap/icon tests and Rust formatting passed. The Windows + shell sources passed a cross-target compile/Clippy check in an isolated + harness; this did not build or run the installer. +- Native Windows CI and interactive acceptance are separate evidence. The + checklist above records what must still be verified before public release. diff --git a/native/gajae-core/src/git.rs b/native/gajae-core/src/git.rs index d86c6aef..27dadfc6 100644 --- a/native/gajae-core/src/git.rs +++ b/native/gajae-core/src/git.rs @@ -504,7 +504,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); } @@ -549,7 +552,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 { @@ -566,7 +573,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( @@ -584,13 +591,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) @@ -774,7 +796,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() }) @@ -866,7 +895,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() @@ -937,6 +966,99 @@ mod tests { frame } + #[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(); @@ -1067,7 +1189,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 77235f0f..6e2a0285 100644 --- a/native/gajae-core/src/jobs.rs +++ b/native/gajae-core/src/jobs.rs @@ -1940,6 +1940,7 @@ mod tests { .execute("INSERT INTO job_events VALUES('j',2,'e','{}')", []) .is_err() ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -1951,10 +1952,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(); @@ -1983,6 +1984,7 @@ mod tests { .unwrap(), event ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2002,6 +2004,7 @@ mod tests { ); assert_eq!(a.snapshot(id).unwrap().last_sequence, 0); } + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2038,6 +2041,7 @@ mod tests { .events .is_empty() ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2075,6 +2079,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] @@ -2113,6 +2118,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(); } @@ -2128,6 +2134,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] @@ -2245,6 +2252,7 @@ mod tests { a.append_event("run", &l, "stale", json!(1)), Err(AuthorityError::StaleLease) ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2345,6 +2353,7 @@ mod tests { .len(), 5 ); + drop(authority); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2451,6 +2460,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] @@ -2474,6 +2484,7 @@ mod tests { .prompt, None ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2575,6 +2586,7 @@ mod tests { }) .unwrap(); assert_eq!(archived_at, None); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2653,6 +2665,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] @@ -2714,6 +2727,7 @@ mod tests { ) .unwrap(); assert_eq!(normalized_count, 0); + drop(c); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2738,6 +2752,7 @@ mod tests { .get::<_, String>(0)) .is_ok() ); + drop(c); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2774,6 +2789,7 @@ mod tests { ) .is_err() ); + drop(c); std::fs::remove_dir_all(d).unwrap(); } } @@ -2824,6 +2840,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] @@ -2843,12 +2860,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(); @@ -2884,10 +2922,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(); @@ -2895,10 +2933,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") @@ -2909,9 +2947,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 @@ -2925,6 +2966,7 @@ mod tests { .unwrap(), 2 ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2982,10 +3024,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(); @@ -3002,6 +3044,7 @@ mod tests { .state, JobState::Failed ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -3014,10 +3057,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(); @@ -3032,6 +3075,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] @@ -3041,16 +3085,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(); @@ -3080,6 +3131,7 @@ mod tests { .provider_session_id, Some("provider".to_owned()) ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -3111,6 +3163,7 @@ mod tests { .len(), 0 ); + drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -3128,10 +3181,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(); @@ -3161,6 +3214,7 @@ 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(); } #[test] 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..1c431ad8 --- /dev/null +++ b/native/gajae-core/tests/process_protocol.rs @@ -0,0 +1,201 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::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) { + std::fs::remove_dir_all(&self.0).unwrap(); + } +} + +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) { + 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); + } + 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(); + } +} + +#[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 (sender, receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let frame = serde_json::from_str::(&line.unwrap()).unwrap(); + if sender.send(frame).is_err() { + break; + } + } + }); + let first = receiver + .recv_timeout(TIMEOUT) + .expect("PTY did not become ready"); + assert_eq!(first, json!({"protocolVersion": 1, "kind": "ready"})); + let mut stdin = core.0.stdin.take().unwrap(); + 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")}), + ] { + writeln!(stdin, "{request}").unwrap(); + } + let mut output = Vec::new(); + let deadline = Instant::now() + TIMEOUT; + loop { + let frame = receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .expect("PTY did not echo input"); + assert_eq!(frame["kind"], "output", "unexpected frame: {frame}"); + output.extend(STANDARD.decode(frame["data"].as_str().unwrap()).unwrap()); + let text = String::from_utf8_lossy(&output); + if text.contains(&expected_cwd(&directory)) + && text.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(); + assert!(receiver.try_iter().any(|frame| frame["kind"] == "exit")); +} diff --git a/package.json b/package.json index 1f3874ca..10001b8d 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", "tauri": "node src-tauri/scripts/tauri.mjs", "desktop:sign:macos": "node scripts/release/finalize-macos-app.mjs", "desktop:dmg:macos": "node scripts/release/make-macos-dmg.mjs", @@ -60,6 +62,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..e09fc278 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,37 @@ 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]; -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'); -} - -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); - } - } finally { - await handle.close(); +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; } -} - -async function extractBinary(archivePath, archiveBinaryPath, destinationPath) { const output = createWriteStream(destinationPath, { mode: 0o700 }); const child = spawn('unzip', ['-p', archivePath, archiveBinaryPath], { stdio: ['ignore', 'pipe', 'inherit'], @@ -90,36 +67,44 @@ 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); -} - -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`); - -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.'); +export async function fetchBun({ + root = rootDir, + platformKey = `${process.platform}-${process.arch}`, + download = downloadVerifiedArchive, + extract = extractBinary, + probe = versionOf, +} = {}) { + 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 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); + // Windows CreateProcess needs the executable suffix even before installation. + const temporaryBinary = path.join(path.dirname(destination), `.bun-${path.basename(temporaryDir)}.tmp${windows ? '.exe' : ''}`); - 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.'); + 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 fs.chmod(temporaryBinary, 0o755); + if (await probe(temporaryBinary) !== BUN_VERSION) { + throw new Error('Extracted Bun binary did not report the requested version.'); + } + await fs.rename(temporaryBinary, destination); + console.log(`Installed Bun ${BUN_VERSION} at ${destination}.`); + return destination; + } finally { + await fs.rm(temporaryBinary, { force: true }); + await fs.rm(temporaryDir, { recursive: true, force: true }); } - 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 }); } + +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..132fd1c7 --- /dev/null +++ b/scripts/fetch-bun.test.mjs @@ -0,0 +1,136 @@ +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('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/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/smoke-windows-server.mjs b/scripts/release/smoke-windows-server.mjs new file mode 100644 index 00000000..75c2760f --- /dev/null +++ b/scripts/release/smoke-windows-server.mjs @@ -0,0 +1,92 @@ +#!/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, windowsSmokeEnvironment } from './windows-payload.mjs'; + +export async function runGuardedSmoke({ nodePath, args, cwd, env, jobRuntime, timeoutMs = 120_000, stdout = process.stdout }) { + 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', 'inherit'], + }); + let timer; + let ready = false; + let buffered = Buffer.alloc(0); + 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}).`))); + }); + } 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. + await killWindowsJobGuard(child, launch); + } +} + +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 가재-')); + 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.cp(path.resolve(payloadDir), payloadCopy, { recursive: true, dereference: false, verbatimSymlinks: true }); + await fs.mkdir(runtimeDir, { recursive: 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 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 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, + }); + } 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)) { + 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..e3c191ee --- /dev/null +++ b/scripts/release/windows-payload.mjs @@ -0,0 +1,143 @@ +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 = {}; + for (const name of ['SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', 'SystemDrive', 'OS', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS']) { + const key = Object.keys(inherited).find(key => key.toLowerCase() === name.toLowerCase()); + if (key) env[name] = inherited[key]; + } + const systemRoot = env.SystemRoot || 'C:\\Windows'; + return { + ...env, + SystemRoot: systemRoot, + PATH: [nodeDirectory, path.win32.join(systemRoot, 'System32'), systemRoot].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', + }; +} diff --git a/scripts/release/windows-payload.test.mjs b/scripts/release/windows-payload.test.mjs new file mode 100644 index 00000000..d2c231d0 --- /dev/null +++ b/scripts/release/windows-payload.test.mjs @@ -0,0 +1,324 @@ +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'); + } +}); diff --git a/scripts/release/windows-server-smoke-checks.mjs b/scripts/release/windows-server-smoke-checks.mjs new file mode 100644 index 00000000..95a67e30 --- /dev/null +++ b/scripts/release/windows-server-smoke-checks.mjs @@ -0,0 +1,277 @@ +// 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.exit(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); + console.log('Windows payload smoke passed: Node, SQLite, ConPTY, core, ripgrep, Bun worker, supervised model catalog/Job chain, desktop bootstrap/auth, frontend and graceful shutdown.'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 6b04f709..9394f625 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -6,7 +6,9 @@ const TEST_FILE_PATTERN = /\.(?:test|spec)\.(?:js|mjs|ts|tsx)$/; // `.tsx` too: component tests that need a DOM run on Bun as well, under // `*.dom.bun.test.tsx`. const BUN_TEST_FILE_PATTERN = /\.bun\.(?:test|spec)\.tsx?$/; -const SKIPPED_DIRECTORIES = new Set(['dist', 'dist-server', 'node_modules', 'release']); +// Roots are source/test directories, so scripts/release is real tooling, not +// the repository's generated release/ output directory. +const SKIPPED_DIRECTORIES = new Set(['dist', 'dist-server', 'node_modules']); const [nodeMajor, nodeMinor, nodePatch] = process.versions.node.split('.').map(Number); const meetsMinimumNodeVersion = @@ -107,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)); @@ -123,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..16aee9fe --- /dev/null +++ b/scripts/run-windows-tests.mjs @@ -0,0 +1,41 @@ +import { readdirSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +// 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-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}`); + } + } +} + +for (const [files, tsconfig] of [[serverTests, 'server/tsconfig.json'], [scriptTests, null]]) { + const result = spawnSync(process.execPath, [ + ...(tsconfig ? ['--import', 'tsx'] : []), + '--test', '--test-concurrency=1', ...files, + ], { + cwd: root, + env: tsconfig ? { ...process.env, TSX_TSCONFIG_PATH: tsconfig } : process.env, + stdio: 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} 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-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..0681358e 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'; @@ -12,6 +13,10 @@ const WATCHER_FRAME_TIMEOUT_MS = 60_000; const WATCHER_PROCESS_TIMEOUT_MS = 90_000; const WATCHER_FRAME_POLL_INTERVAL_MS = 10; +// 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; signal: NodeJS.Signals | null; @@ -85,6 +90,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 +139,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,6 +166,7 @@ test('native core recursively watches multiple roots and filters non-transcript ); } finally { child.kill('SIGKILL'); + await closed; await rm(temporaryRoot, { recursive: true, force: true }); } }); @@ -175,6 +183,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 +210,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,6 +221,7 @@ test('native core reports transcripts a directory already held when it appeared' ); } finally { child.kill('SIGKILL'); + await closed; await rm(temporaryRoot, { recursive: true, force: true }); } }); @@ -258,7 +268,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); @@ -441,19 +451,75 @@ test('native job authority persists and reconciles state across process replacem } }); +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, { recursive: true, force: true }); + } +}); + test('native PTY relays bounded input, resize, output, and shutdown lifecycle', 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);", + '});', + ].join(''), ], { + cwd: temporaryRoot, stdio: ['pipe', 'pipe', 'pipe'], }); + const closed = new Promise((resolve) => child.once('close', () => resolve())); const frames: Array> = []; let buffered = ''; let output = ''; + const decoder = new StringDecoder('utf8'); let diagnostics = ''; let shutdownSent = false; child.stdout.setEncoding('utf8'); @@ -479,18 +545,18 @@ test('native PTY relays bounded input, resize, output, and shutdown lifecycle', child.stdin.write(`${JSON.stringify({ protocolVersion: 1, method: 'pty.resize', - cols: 100, + cols: 1000, rows: 30, })}\n`); child.stdin.write(`${JSON.stringify({ protocolVersion: 1, method: 'pty.write', - data: Buffer.from('native-pty-token\n').toString('base64'), + data: Buffer.from('native-pty-token\r').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) { + output += decoder.write(Buffer.from(frame.data, 'base64')); + if (output.includes('native-child-echo:native-pty-token') && output.includes(cwdMarker) && !shutdownSent) { shutdownSent = true; child.stdin.write(`${JSON.stringify({ protocolVersion: 1, @@ -501,18 +567,28 @@ test('native PTY relays bounded input, resize, output, and shutdown lifecycle', } } }); - child.once('error', reject); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); 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); + 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(output.includes(cwdMarker)); + assert.match(output, /native-child-echo:native-pty-token/u); + } finally { + child.kill('SIGKILL'); + await closed; + await rm(temporaryRoot, { recursive: true, force: true }); + } }); diff --git a/server/gjc-engine.ts b/server/gjc-engine.ts index ad9cc80b..66959809 100644 --- a/server/gjc-engine.ts +++ b/server/gjc-engine.ts @@ -71,8 +71,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 c7d6224e..e0d3fd39 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": "984ee1162a39edd4312c58f194866522117ff3a01d4ab86d7d41064ccae8a8c5" + }, + { + "package": "@gajae-code/natives", + "path": "native/embedded-addon.js", + "sha256": "0ee3be1ce9f174e0c3905bfda78f665d6c177b0e953303b35ff5c5b7735552db" + }, + { + "package": "@gajae-code/natives", + "path": "native/index.js", + "sha256": "fc427ab3a07197c24d690880cadea22c1b546f7d4121892000056f0ea4adb67f" + }, + { + "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 d514db16..c7c444d0 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -335,7 +335,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'); return new Promise((resolve, reject) => { const child = spawn(bun, [worker], { @@ -350,7 +350,7 @@ async function runProductionWorker(env: NodeJS.ProcessEnv = {}): Promise { child.kill(); reject(new Error('Production Bun worker timed out.')); - }, 10_000); + }, 60_000); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { @@ -1795,7 +1795,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({ @@ -1811,7 +1813,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-windows-job.test.ts b/server/gjc-windows-job.test.ts index d97d8bdf..c9a83916 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -1,9 +1,12 @@ 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, + killWindowsJobGuard, GJC_WINDOWS_JOB_GUARD_ACK, GJC_WINDOWS_JOB_GUARD_READY, quoteWindowsArgument, @@ -52,6 +55,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 +71,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 +82,110 @@ 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`, { + skip: process.platform !== 'win32', timeout: 30_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', () => process.stdout.write(JSON.stringify({ descendant: child.pid }) + '\\n')); + 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 }); + let stderr = ''; + guard.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + 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: Buffer) => { + buffer += chunk.toString(); + 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 }; + assert.ok(frame.descendant > 0); + 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 */ } } + } +}); +} diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index 1191a95b..f0c93ecb 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -1,10 +1,14 @@ 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'; @@ -16,6 +20,7 @@ using System; using System.ComponentModel; using System.Runtime.InteropServices; using System.Text; +using System.Threading; public static class GajaeWindowsJobGuard { @@ -30,6 +35,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 +126,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 +292,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."); @@ -335,6 +398,15 @@ public static class GajaeWindowsJobGuard } '@ +$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,7 +426,7 @@ 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) @@ -402,6 +474,7 @@ export type WindowsJobLaunch = { command: string; args: string[]; env: NodeJS.ProcessEnv; + jobName: string; }; /** @@ -414,10 +487,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 +517,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 ae576a84..76672e0d 100644 --- a/server/gjc-worker-client.test.ts +++ b/server/gjc-worker-client.test.ts @@ -204,6 +204,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', @@ -353,6 +354,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( @@ -360,7 +362,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 () => { @@ -392,7 +394,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']); @@ -481,6 +483,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, @@ -971,7 +974,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'], @@ -983,10 +986,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 39221d37..82c06cfb 100644 --- a/server/gjc-worker-client.ts +++ b/server/gjc-worker-client.ts @@ -20,7 +20,9 @@ import { GjcWorkerProtocolError, GjcWorkerRequestTracker, createWindowsJobLaunch, + killWindowsJobGuard, serializeGjcWorkerFrame, + type WindowsJobLaunch, type GjcWorkerEventFrame, type GjcWorkerGlobalEventMethod, type GjcWorkerRequestFrame, @@ -36,6 +38,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; @@ -322,8 +329,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; @@ -612,10 +621,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); @@ -652,6 +658,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; @@ -1122,8 +1129,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 new file mode 100644 index 00000000..079ea4dd --- /dev/null +++ b/server/modules/websocket/services/shell-websocket.service.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; + +import pty, { type IPty, type IPtyForkOptions } from 'node-pty'; +import { type WebSocket } from 'ws'; + +import { handleShellConnection } from './shell-websocket.service.js'; + +class FakeSocket extends EventEmitter { + readyState = 1; + sent: Array<{ type: string; data?: string; message?: string }> = []; + send(value: string): void { this.sent.push(JSON.parse(value)); } +} + +function connect(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 exits: Array<(status: { exitCode: number }) => void> = []; + t.mock.method(os, 'platform', () => platform); + t.mock.method(pty, 'spawn', (executable: string, args: string[], options: IPtyForkOptions) => { + calls.push({ executable, args, options }); + return { + onData() {}, + onExit(callback: (status: { exitCode: number }) => void) { exits.push(callback); }, + kill() {}, write() {}, resize() {}, + } as unknown as IPty; + }); + handleShellConnection(socket as unknown as WebSocket, { + resolveProviderSessionId: () => nativeId, + stripAnsiSequences: (content) => content, + normalizeDetectedUrl: () => null, + extractUrlsFromText: () => [], + shouldAutoOpenUrlFromOutput: () => false, + }); + t.after(() => { + exits.forEach((exit) => exit({ exitCode: 0 })); + socket.emit('close'); + rmSync(projectPath, { recursive: true, force: true }); + }); + return { + socket, calls, projectPath, + init: (data: Record) => socket.emit('message', JSON.stringify({ type: 'init', projectPath, ...data })), + }; +} + +test('Windows websocket GJC resume reaches the PTY with PowerShell syntax and the mapped ID', (t) => { + const connection = connect(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 = connect(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 = connect(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 = connect(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 = connect(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']); +}); diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 604fd8db..63c1ddae 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; }; type ShellWebSocketDependencies = { @@ -41,43 +42,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 { @@ -158,13 +130,13 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke write({ type: 'error', message: 'Invalid session ID' }); 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 }); diff --git a/server/routes/system.js b/server/routes/system.js index 7ba30a3b..1cf0b2a4 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 581aa683..0c9f707f 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(); @@ -42,7 +42,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 { @@ -107,6 +107,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 ff6a852a..fb7ffee3 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 f831803a..d3aeb01b 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 0000000000000000000000000000000000000000..7f464fae8f0d9f2a4ffb0417a6e5ed06191e0b96 GIT binary patch literal 65180 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 literal 0 HcmV?d00001 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 25e2816c..450b0a2d 100644 --- a/src-tauri/scripts/tauri.mjs +++ b/src-tauri/scripts/tauri.mjs @@ -1,60 +1,132 @@ -import { readFile, rm, writeFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; +import { readFile, stat } 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'; 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 = process.argv.slice(2); -if (tauriArgs[0] === 'build') { - const targetIndex = tauriArgs.findIndex((argument) => argument === '--target' || argument.startsWith('--target=')); - const configuredTarget = targetIndex === -1 - ? undefined - : tauriArgs[targetIndex].startsWith('--target=') - ? tauriArgs[targetIndex].slice('--target='.length) - : tauriArgs[targetIndex + 1]; - - if (configuredTarget !== undefined && configuredTarget !== 'aarch64-apple-darwin') { - throw new Error('Tauri desktop builds only support the aarch64-apple-darwin target'); +const require = createRequire(import.meta.url); +const appCommands = new Set(['dev', 'build', 'bundle']); + +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 === 'darwin') return 'aarch64-apple-darwin'; + if (platform === 'win32' && arch === 'x64') return 'x86_64-pc-windows-msvc'; + throw new Error(`Tauri desktop packaging requires macOS (arm64 target) or native Windows x64 MSVC; received ${platform}-${arch}`); +} + +export function prepareTauriArgs(args, { platform = process.platform, arch = process.arch, version } = {}) { + 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`); + } + if (!targets.length) tauriArgs.push('--target', target); } - if (configuredTarget === undefined) { - tauriArgs.push('--target', 'aarch64-apple-darwin'); + + // Tauri automatically merges tauri.windows.conf.json. Replaying the entire + // base config here would overwrite its NSIS target and ICO with macOS values. + tauriArgs.splice(index + 1, 0, '--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; } -const overlayPath = join(srcTauriDir, `.tauri-config-${process.pid}.json`); -await writeFile(overlayPath, `${JSON.stringify({ ...config, version: packageJson.desktopVersion }, null, 2)}\n`); +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.`); + } +} -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, +export async function runTauri(args, { + directory = srcTauriDir, + platform = process.platform, + arch = process.arch, + cliPath = require.resolve('@tauri-apps/cli/tauri.js'), + env = process.env, +} = {}) { + const command = args[commandIndex(args)]; + const needsConfig = appCommands.has(command) && !isHelp(args); + const version = needsConfig ? await desktopVersion(directory) : undefined; + const tauriArgs = prepareTauriArgs(args, { platform, arch, version }); + if (needsConfig && platform === 'win32' && (command === 'build' || command === 'bundle')) { + await checkWindowsPayload(directory); + } + 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 child = spawn(process.execPath, [cliPath, ...tauriArgs], { + cwd: directory, stdio: 'inherit', + env: childEnv, + shell: false, }); - const code = await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { child.once('error', reject); - child.once('exit', (exitCode) => resolve(exitCode ?? 1)); + child.once('close', (exitCode) => resolve(exitCode ?? 1)); }); - process.exitCode = code; -} finally { - 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..2e35015c --- /dev/null +++ b/src-tauri/scripts/tauri.test.mjs @@ -0,0 +1,210 @@ +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 windowsTarget = 'x86_64-pc-windows-msvc'; +const macTarget = 'aarch64-apple-darwin'; +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"]}}'); + const cliPath = join(root, 'fake tauri cli.mjs'); + const outputPath = join(root, 'result.json'); + await writeFile(cliPath, ` + import { writeFile } from 'node:fs/promises'; + await writeFile(process.env.TAURI_TEST_RESULT, JSON.stringify({ + executable: process.execPath, args: process.argv.slice(2), cwd: process.cwd(), ci: process.env.CI, + })); + 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 Windows builds and separate bundling select MSVC; macOS keeps arm64', () => { + 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(['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 of [windows, mac]) { + const target = options === windows ? windowsTarget : macTarget; + 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 Windows architectures and unsupported hosts', () => { + for (const options of [ + { platform: 'win32', arch: 'arm64' }, + { platform: 'win32', arch: 'ia32' }, + { platform: 'linux', arch: 'x64' }, + ]) { + assert.throws(() => prepareTauriArgs(['build'], { ...windows, ...options }), /requires macOS.*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 or a temporary config', () => { + 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.deepEqual(result.args, prepareTauriArgs(args, windows)); + 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.args, prepareTauriArgs(args, mac)); + 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 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']); +}); + +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 59e0de7e..8c83dc87 100644 --- a/src-tauri/src/lifecycle.rs +++ b/src-tauri/src/lifecycle.rs @@ -1,13 +1,75 @@ 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); +pub const FORCE_STOP_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct Sidecar { + pub pid: u32, + #[cfg(windows)] + process: Option>, +} + +impl Sidecar { + #[cfg(any(unix, test))] + fn unmanaged(pid: u32) -> Self { + Self { + pid, + #[cfg(windows)] + process: None, + } + } + + #[cfg(unix)] + pub fn unix(pid: u32) -> Self { + Self::unmanaged(pid) + } + + #[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)] + return signal_sidecar(self.pid, if force { 9 } else { 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, exited: Notify, } @@ -15,7 +77,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), exited: Notify::new(), } @@ -23,25 +85,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(); } } @@ -50,25 +129,61 @@ impl SidecarLifecycle { self.shutting_down.load(Ordering::SeqCst) } pub fn has_sidecar(&self) -> bool { - self.pid + self.current_pid().is_some() + } + pub fn may_exit(&self) -> bool { + self.is_shutting_down() && !self.has_sidecar() + } + fn current_pid(&self) -> Option { + self.sidecar .lock() .expect("sidecar lifecycle lock poisoned") - .is_some() + .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) + } + + 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. + } + } + + 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) -> Result<(), String> { - tokio::time::timeout(Duration::from_secs(30), async { + tokio::time::timeout(SHUTDOWN_TIMEOUT, async { loop { - // Register before checking the durable state: exit can happen - // before this wait begins, or between the check and the await. let exited = self.exited.notified(); if !self.has_sidecar() { return; @@ -80,72 +195,73 @@ 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) -> Result<(), String> { + // The supervisor drains output concurrently. A closed stdin or failed + // signal skips directly to the bounded force-stop fallback. + if self.stop(pid, false).is_ok() && self.wait_for_exit().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; - if unsafe { kill(pid as i32, 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 { - Err(format!("could not send SIGTERM to desktop server {pid}")) + 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" { fn kill(pid: i32, signal: i32) -> i32; } - unsafe { kill(pid as i32, 0) == 0 } + // EPERM means it exists but is not signalable. Never release ownership on + // an inspection error; PID 0 would address our own process group. + pid != 0 + && pid <= i32::MAX as u32 + && (unsafe { kill(pid as i32, 0) } == 0 + || std::io::Error::last_os_error().raw_os_error() != Some(3)) } -#[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() { + if !lifecycle.wait_for_exit_blocking(pid, SHUTDOWN_TIMEOUT) { + let _ = lifecycle.stop(pid, true); + lifecycle.wait_for_exit_blocking(pid, FORCE_STOP_TIMEOUT); } } } @@ -153,7 +269,11 @@ pub fn blocking_shutdown(app: &AppHandle) { pub fn hide_on_close(window: &Window, event: &tauri::WindowEvent) { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); + #[cfg(target_os = "macos")] let _ = window.hide(); + // There is no Windows dock or tray from which to reopen a hidden app. + #[cfg(not(target_os = "macos"))] + graceful_quit(window.app_handle().clone()); } } @@ -166,11 +286,9 @@ pub fn graceful_quit(app: AppHandle) { } return; }; - if let Err(error) = terminate_sidecar(pid) { - show_shutdown_error(&app, &error); - return; - } - if let Err(error) = lifecycle.wait_for_exit().await { + if let Err(error) = lifecycle.stop_and_wait(pid).await { + // Keep ownership, but allow the user to try Quit again. + lifecycle.shutting_down.store(false, Ordering::SeqCst); show_shutdown_error(&app, &error); return; } @@ -182,7 +300,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(); } } @@ -194,15 +312,32 @@ 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); } + #[test] + fn programmatic_exit_is_allowed_only_after_shutdown_finishes() { + let lifecycle = SidecarLifecycle::default(); + assert!(!lifecycle.may_exit()); + lifecycle + .start(|| Ok((Sidecar::unmanaged(42), ()))) + .unwrap(); + lifecycle.begin_shutdown(); + assert!(!lifecycle.may_exit()); + lifecycle.exited(42); + assert!(lifecycle.may_exit()); + } + #[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 { @@ -217,7 +352,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!( @@ -226,7 +363,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); @@ -239,7 +378,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!( @@ -260,7 +404,7 @@ mod tests { let start = threads.spawn(move || { starting_lifecycle.start(|| { spawn_barrier.wait(); - Ok((42, ())) + Ok((Sidecar::unmanaged(42), ())) }) }); spawning.wait(); @@ -272,7 +416,9 @@ 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 { assert!( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fb55853b..070520e8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,14 @@ use tauri::Manager; mod lifecycle; mod navigation; mod supervisor; +#[cfg(windows)] +mod windows_process; + +#[derive(Default)] +struct PendingDeepLink { + url: std::sync::Mutex>, + ui_ready: std::sync::atomic::AtomicBool, +} struct SingleInstanceLock { _file: std::fs::File, @@ -31,7 +39,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(); @@ -52,9 +67,37 @@ fn deep_link_route(url: &tauri::Url) -> Option { fn route_deep_link(app: &tauri::AppHandle, url: tauri::Url) { use tauri::{Emitter, Manager}; - if !is_gajae_deep_link(&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 @@ -65,6 +108,7 @@ fn route_deep_link(app: &tauri::AppHandle, url: tauri::Url) { "window.history.pushState({{}},'','{path}');window.dispatchEvent(new PopStateEvent('popstate'));" )); } + let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } @@ -78,11 +122,63 @@ fn retry_desktop_server(app: tauri::AppHandle) { fn main() { use tauri_plugin_deep_link::DeepLinkExt; - let app = tauri::Builder::default() + let builder = tauri::Builder::default() + .manage(PendingDeepLink::default()) + .manage(navigation::LoopbackOrigin::default()) + .manage(lifecycle::SidecarLifecycle::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); + } + } + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })); + let app = builder .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_deep_link::init()) .plugin(navigation::plugin()) .on_window_event(lifecycle::hide_on_close) + .on_page_load(|window, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + window + .app_handle() + .state::() + .ui_ready + .store(false, std::sync::atomic::Ordering::SeqCst); + } + if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) + && payload.url().host_str() == Some("127.0.0.1") + && payload.url().path() != "/desktop/bootstrap" + && window + .app_handle() + .state::() + .permits(payload.url()) + { + window + .app_handle() + .state::() + .ui_ready + .store(true, std::sync::atomic::Ordering::SeqCst); + let pending = window + .app_handle() + .state::() + .url + .lock() + .expect("deep-link lock poisoned") + .take(); + if let Some(url) = pending { + route_deep_link(window.app_handle(), url); + } + } + }) .invoke_handler(tauri::generate_handler![retry_desktop_server]) .setup(|app| { // A held lock means another instance is running. Setup errors @@ -97,14 +193,18 @@ fn main() { } }; app.manage(lock); - app.manage(navigation::LoopbackOrigin::default()); - app.manage(lifecycle::SidecarLifecycle::default()); let app_handle = app.handle().clone(); app.deep_link().on_open_url(move |event| { for url in event.urls() { route_deep_link(&app_handle, url); } }); + // The plugin captures cold-start arguments before this listener. + 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(()) }) @@ -113,8 +213,12 @@ fn main() { app.run( |app: &tauri::AppHandle, event: tauri::RunEvent| match event { tauri::RunEvent::ExitRequested { api, .. } => { - api.prevent_exit(); - lifecycle::graceful_quit(app.clone()); + // app.exit() emits ExitRequested again. Once the tree is gone, + // allow that request instead of endlessly preventing our Quit. + if !app.state::().may_exit() { + api.prevent_exit(); + lifecycle::graceful_quit(app.clone()); + } } tauri::RunEvent::Exit => { // macOS Quit Apple events (Cmd-Q, AppleScript quit) bypass a @@ -163,6 +267,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 40af682b..0e8814da 100644 --- a/src-tauri/src/navigation.rs +++ b/src-tauri/src/navigation.rs @@ -13,8 +13,18 @@ impl LoopbackOrigin { *self.0.lock().expect("loopback origin lock poisoned") = Some(origin); } - fn permits(&self, url: &tauri::Url) -> bool { - if url.scheme() == "tauri" { + pub(crate) fn permits(&self, url: &tauri::Url) -> bool { + if !url.username().is_empty() || url.password().is_some() { + return false; + } + if url.scheme() == "tauri" && url.host_str() == Some("localhost") { + return true; + } + // 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"); @@ -58,6 +68,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/supervisor.rs b/src-tauri/src/supervisor.rs index 58ee7cdd..2490a891 100644 --- a/src-tauri/src/supervisor.rs +++ b/src-tauri/src/supervisor.rs @@ -2,6 +2,7 @@ use std::fmt::Write as _; use std::{ collections::VecDeque, env, + ffi::OsString, io::{Read, Write}, net::TcpStream, path::PathBuf, @@ -11,7 +12,9 @@ use std::{ use getrandom::getrandom; use serde::Deserialize; use tauri::{AppHandle, Manager, WebviewWindow}; -use tauri_plugin_shell::{process::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"; @@ -86,7 +89,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", @@ -102,6 +107,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()); } @@ -165,28 +176,86 @@ fn show_error(window: &WebviewWindow, message: &str) { } async fn stop_failed_sidecar( + lifecycle: &crate::lifecycle::SidecarLifecycle, + pid: u32, + events: tauri::async_runtime::Receiver, +) { + stop_failed_sidecar_with_timeouts( + lifecycle, + pid, + events, + crate::lifecycle::SHUTDOWN_TIMEOUT, + crate::lifecycle::FORCE_STOP_TIMEOUT, + ) + .await; +} + +async fn stop_failed_sidecar_with_timeouts( lifecycle: &crate::lifecycle::SidecarLifecycle, pid: u32, mut events: tauri::async_runtime::Receiver, + grace: Duration, + force: Duration, ) { - let _ = crate::lifecycle::terminate_sidecar(pid); - // An output/readiness error is not a process exit. Keep the PID tracked - // and drain output until the child is gone, so Retry and Quit cannot - // abandon a still-running server or start a second one beside it. + let mut forced = lifecycle.stop(pid, false).is_err(); + if forced { + let _ = lifecycle.stop(pid, true); + } + let mut deadline = Instant::now() + if forced { force } else { grace }; loop { - let event = time::timeout(Duration::from_millis(100), events.recv()).await; - if matches!(event, Ok(Some(CommandEvent::Terminated(_)))) { - break; + if lifecycle.reap_if_stopped(pid) { + return; } - #[cfg(unix)] - if !crate::lifecycle::process_alive(pid) { - break; + if Instant::now() >= deadline { + if forced { + // Retain ownership and block Retry if exit cannot be verified. + // This task itself must not hang indefinitely on a closed pipe. + eprintln!("desktop server {pid} did not stop within the cleanup deadline"); + return; + } + let _ = lifecycle.stop(pid, true); + forced = true; + deadline = Instant::now() + force; } - if matches!(event, Ok(None)) { - time::sleep(Duration::from_millis(100)).await; + match time::timeout(Duration::from_millis(50), events.recv()).await { + Ok(Some(CommandEvent::Terminated(_))) => lifecycle.exited(pid), + Ok(None) => time::sleep(Duration::from_millis(50)).await, + _ => {} } } - lifecycle.exited(pid); +} + +/// 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( @@ -234,26 +303,68 @@ 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 mut environment: Vec<(OsString, OsString)> = [ + ("HOST", "127.0.0.1"), + ("SERVER_PORT", "0"), + ("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. + if let Ok(home) = app.path().home_dir() { + environment.push(("HOME".into(), home.into_os_string())); + } + 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}")); + return; + } + }; + environment.push(("PATH".into(), path)); let command = lifecycle.start(|| { + #[cfg(unix)] let (events, child) = app .shell() .sidecar("gajae-app-server") .map_err(|error| format!("could not prepare server sidecar: {error}"))? - .arg(entrypoint.to_string_lossy().as_ref()) - .env("HOST", "127.0.0.1") - .env("SERVER_PORT", "0") - .env("NODE_ENV", "production") - .env("GJC_DESKTOP", "1") - .env("GJC_DESKTOP_API_KEY", api_key) - .env("GJC_DESKTOP_BOOTSTRAP_NONCE", &nonce) - .env("HOME", home) - .env("PATH", path) + .arg(&entrypoint) + .current_dir(&payload) + .envs(environment) + .set_raw_out(true) .spawn() .map_err(|error| format!("could not start server sidecar: {error}"))?; - Ok((child.pid(), (events, child))) + #[cfg(unix)] + let tracked = crate::lifecycle::Sidecar::unix(child.pid()); + #[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 tracked = crate::lifecycle::Sidecar::windows(std::sync::Arc::clone(&child)); + Ok((tracked, (events, child))) }); let (mut events, child) = match command { Ok(Some(child)) => child, @@ -267,6 +378,7 @@ pub fn start(app: AppHandle) { let deadline = Instant::now() + STARTUP_TIMEOUT; let mut output = OutputRing::default(); let mut ready = false; + let mut ready_lines = ReadyLines::default(); loop { let event = if ready { events.recv().await @@ -306,20 +418,22 @@ 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 { 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; } match health_check(ready_frame.port, &ready_frame.version) { Ok(()) => { + if lifecycle.is_shutting_down() { + stop_failed_sidecar(&lifecycle, sidecar_pid, events).await; + return; + } if let Err(error) = navigate_and_show(&app, &window, ready_frame.port, &nonce) { @@ -340,6 +454,8 @@ pub fn start(app: AppHandle) { } CommandEvent::Terminated(status) => { lifecycle.exited(sidecar_pid); + #[cfg(windows)] + stop_failed_sidecar(&lifecycle, sidecar_pid, events).await; if !lifecycle.is_shutting_down() { show_error( &window, @@ -391,7 +507,9 @@ mod tests { .stdout(Stdio::piped()) .spawn() .unwrap(); - lifecycle.start(|| Ok((child.id(), ()))).unwrap(); + lifecycle + .start(|| Ok((crate::lifecycle::Sidecar::unix(child.id()), ()))) + .unwrap(); let mut ready = String::new(); std::io::BufReader::new(child.stdout.take().unwrap()) .read_line(&mut ready) @@ -440,6 +558,72 @@ 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::unix(pid), ()))) + .unwrap(); + let reaper = std::thread::spawn(move || child.wait().unwrap()); + let (sender, events) = tauri::async_runtime::channel(1); + drop(sender); + time::timeout( + Duration::from_secs(3), + stop_failed_sidecar_with_timeouts( + &lifecycle, + pid, + events, + Duration::from_millis(100), + Duration::from_secs(2), + ), + ) + .await + .expect("closed output must not make cleanup loop forever"); + assert!(!reaper.join().unwrap().success()); + 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..bed9d438 --- /dev/null +++ b/src-tauri/src/windows_process.rs @@ -0,0 +1,555 @@ +//! 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}"); + assert_eq!( + unsafe { WaitForSingleObject(descendant.as_raw_handle(), 0) }, + 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" + } + } + } +} From ca963540fab4a3151854554a985957c5615336d2 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 16:21:55 +0900 Subject: [PATCH 02/22] fix(windows): preserve source checksums across checkouts --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes 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 From 922b3a3c44770ab1bdc372e89cd7cfd966fd904c Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 16:30:52 +0900 Subject: [PATCH 03/22] fix(windows): normalize worktree paths for Git --- .github/workflows/windows.yml | 10 ++++ native/gajae-core/src/git.rs | 87 +++++++++++++++++++++++++++------- server/gjc-core-host.test.ts | 8 +++- server/gjc-windows-job.test.ts | 25 +++++++--- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index a098bb0f..2aefe3d6 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -56,6 +56,7 @@ jobs: run: npm run audit - name: Check source + id: source run: | npm run typecheck if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -70,18 +71,26 @@ jobs: run: npm run check:core - 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' }} run: npm run desktop:build:windows - name: Test Windows runtime + if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: npm run test: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 --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') @@ -95,6 +104,7 @@ jobs: "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 가재' diff --git a/native/gajae-core/src/git.rs b/native/gajae-core/src/git.rs index 27dadfc6..6eac5c57 100644 --- a/native/gajae-core/src/git.rs +++ b/native/gajae-core/src/git.rs @@ -235,17 +235,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); @@ -446,15 +439,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})) @@ -492,6 +478,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) @@ -785,6 +803,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, @@ -915,6 +941,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") @@ -966,6 +1000,23 @@ 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(); diff --git a/server/gjc-core-host.test.ts b/server/gjc-core-host.test.ts index 0681358e..a8b5194f 100644 --- a/server/gjc-core-host.test.ts +++ b/server/gjc-core-host.test.ts @@ -229,12 +229,16 @@ test('native core reports transcripts a directory already held when it appeared' 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); });", + // Windows pipe writes are asynchronous. Let both streams drain before + // exiting, otherwise the fixture itself can truncate a 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([ '--', diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index c9a83916..0ef98315 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -127,12 +127,19 @@ test('Windows reap barrier rejects termination and verification failures', async for (const shutdown of ['guard', 'owner'] as const) { test(`Windows Job Object kills detached descendants after ${shutdown} exit`, { - skip: process.platform !== 'win32', timeout: 30_000, + // 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', () => process.stdout.write(JSON.stringify({ descendant: child.pid }) + '\\n')); + 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' @@ -141,8 +148,12 @@ test(`Windows Job Object kills detached descendants after ${shutdown} exit`, { 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.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + 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) => { @@ -150,8 +161,8 @@ test(`Windows Job Object kills detached descendants after ${shutdown} exit`, { 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: Buffer) => { - buffer += chunk.toString(); + guard.stdout.on('data', (chunk: string) => { + buffer += chunk; const lines = buffer.split('\n'); buffer = lines.pop()!; for (const raw of lines) { @@ -159,8 +170,9 @@ test(`Windows Job Object kills detached descendants after ${shutdown} exit`, { 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 }; + 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); } @@ -186,6 +198,7 @@ test(`Windows Job Object kills detached descendants after ${shutdown} exit`, { 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]); } }); } From 2eb157614cbd86322b8c84fcc01eaa849c2e710a Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 16:51:01 +0900 Subject: [PATCH 04/22] fix(windows): handle console readiness and isolated runtime setup --- .github/workflows/windows.yml | 20 +- native/gajae-core/tests/process_protocol.rs | 171 ++++++++++++++++-- scripts/release/smoke-windows-server.mjs | 52 +++++- scripts/release/windows-payload.mjs | 74 +++++++- scripts/release/windows-payload.test.mjs | 31 ++++ .../windows-smoke-environment.test.mjs | 99 ++++++++++ server/GJC-LIVE-SPEC.md | 13 +- 7 files changed, 424 insertions(+), 36 deletions(-) create mode 100644 scripts/release/windows-smoke-environment.test.mjs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 2aefe3d6..3480f0e2 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -67,14 +67,30 @@ jobs: npm run check:licenses if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Windows packaging helpers + id: packaging + run: node --test scripts/fetch-bun.test.mjs scripts/release/windows-payload.test.mjs scripts/release/windows-smoke-environment.test.mjs + - name: Verify Rust core - run: npm run check: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' }} + if: ${{ !cancelled() && steps.source.outcome == 'success' && steps.packaging.outcome == 'success' }} run: npm run desktop:build:windows - name: Test Windows runtime diff --git a/native/gajae-core/tests/process_protocol.rs b/native/gajae-core/tests/process_protocol.rs index 1c431ad8..6265af8b 100644 --- a/native/gajae-core/tests/process_protocol.rs +++ b/native/gajae-core/tests/process_protocol.rs @@ -1,7 +1,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::path::PathBuf; use std::process::{Child, Command, ExitStatus, Stdio}; -use std::sync::mpsc; +use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose::STANDARD}; @@ -28,7 +28,16 @@ impl TestDirectory { impl Drop for TestDirectory { fn drop(&mut self) { - std::fs::remove_dir_all(&self.0).unwrap(); + // 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); } } @@ -52,6 +61,15 @@ impl CoreChild { 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(); } @@ -106,6 +124,10 @@ fn child_fixture() { 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 @@ -119,6 +141,73 @@ fn child_fixture() { } } +#[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"); @@ -153,37 +242,81 @@ 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 = serde_json::from_str::(&line.unwrap()).unwrap(); + 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 first = receiver - .recv_timeout(TIMEOUT) - .expect("PTY did not become ready"); + 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")}), ] { - writeln!(stdin, "{request}").unwrap(); + write_request(&mut stdin, request); } - let mut output = Vec::new(); let deadline = Instant::now() + TIMEOUT; loop { - let frame = receiver - .recv_timeout(deadline.saturating_duration_since(Instant::now())) - .expect("PTY did not echo input"); + let frame = receive_frame(&receiver, deadline, "input echo", &output, &diagnostics); assert_eq!(frame["kind"], "output", "unexpected frame: {frame}"); - output.extend(STANDARD.decode(frame["data"].as_str().unwrap()).unwrap()); - let text = String::from_utf8_lossy(&output); - if text.contains(&expected_cwd(&directory)) - && text.contains("fixture-input=native-pty-token") + 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; } @@ -197,5 +330,11 @@ fn pty_starts_in_project_directory_and_supports_resize_input_and_shutdown() { drop(stdin); assert!(core.wait().success()); reader.join().unwrap(); - assert!(receiver.try_iter().any(|frame| frame["kind"] == "exit")); + stderr_reader.join().unwrap(); + assert!( + receiver + .try_iter() + .any(|frame| frame.unwrap()["kind"] == "exit") + ); + assert!(diagnostics.lock().unwrap().is_empty()); } diff --git a/scripts/release/smoke-windows-server.mjs b/scripts/release/smoke-windows-server.mjs index 75c2760f..3315781a 100644 --- a/scripts/release/smoke-windows-server.mjs +++ b/scripts/release/smoke-windows-server.mjs @@ -9,17 +9,24 @@ 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, windowsSmokeEnvironment } from './windows-payload.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 }) { +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', 'inherit'], + 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); @@ -43,34 +50,51 @@ export async function runGuardedSmoke({ nodePath, args, cwd, env, jobRuntime, ti ? 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. - await killWindowsJobGuard(child, launch); + 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.cp(path.resolve(payloadDir), payloadCopy, { recursive: true, dereference: false, verbatimSymlinks: true }); 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 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 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)), @@ -80,9 +104,17 @@ export async function smokeWindowsServer({ payloadDir, nodePath }) { await runGuardedSmoke({ nodePath: nodeCopy, args: [checks, NODE_VERSION, BUN_VERSION], cwd: payloadCopy, env, jobRuntime, }); + } catch (error) { + failure = error; } finally { - await fs.rm(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + 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)) { diff --git a/scripts/release/windows-payload.mjs b/scripts/release/windows-payload.mjs index e3c191ee..734eeb5c 100644 --- a/scripts/release/windows-payload.mjs +++ b/scripts/release/windows-payload.mjs @@ -122,15 +122,31 @@ export function windowsBuildEnvironment(nodeDirectory, inherited = process.env) export function windowsSmokeEnvironment(nodeDirectory, stateDir, inherited = process.env) { const env = {}; - for (const name of ['SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', 'SystemDrive', 'OS', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS']) { + // 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 || 'C:\\Windows'; + const systemRoot = env.SystemRoot || env.WINDIR || 'C:\\Windows'; + const powershellDirectory = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0'); return { ...env, SystemRoot: systemRoot, - PATH: [nodeDirectory, path.win32.join(systemRoot, 'System32'), systemRoot].join(';'), + 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), @@ -141,3 +157,55 @@ export function windowsSmokeEnvironment(nodeDirectory, stateDir, inherited = pro 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) } = {}) { + 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) + Add-Type -TypeDefinition 'public static class GajaeSmokeEnvironmentProbe { public static int Value() { return 42; } }' + [Console]::Out.WriteLine(('{"compiled":' + [GajaeSmokeEnvironmentProbe]::Value() + '}')) +} catch { + [Console]::Error.WriteLine($_.Exception.ToString()) + exit 1 +} +`.trim(); + try { + const encoded = Buffer.from(source, 'utf16le').toString('base64'); + 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 records[0]; + } 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 index d2c231d0..af2536a5 100644 --- a/scripts/release/windows-payload.test.mjs +++ b/scripts/release/windows-payload.test.mjs @@ -322,3 +322,34 @@ test('outer smoke reaps its named Job after success, failure, invalid prelude an 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-smoke-environment.test.mjs b/scripts/release/windows-smoke-environment.test.mjs new file mode 100644 index 00000000..616cbb34 --- /dev/null +++ b/scripts/release/windows-smoke-environment.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +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 uses constant UTF-16LE encoded source and returns bounded 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')); + const source = Buffer.from(args.at(-1), 'base64').toString('utf16le'); + assert.match(source, /Add-Type -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('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(() => 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); + if (label === 'isolated Unicode') { + assert.equal(path.resolve(result.temp).toLowerCase(), path.resolve(env.TEMP).toLowerCase()); + 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/server/GJC-LIVE-SPEC.md b/server/GJC-LIVE-SPEC.md index 1de244ce..7b74d4f8 100644 --- a/server/GJC-LIVE-SPEC.md +++ b/server/GJC-LIVE-SPEC.md @@ -219,12 +219,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 From 121053533feaf976c704dae938abaa35700f2684 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 17:10:48 +0900 Subject: [PATCH 05/22] fix(windows): compile job guards in protected Unicode temp directories --- .github/workflows/windows.yml | 27 +++- scripts/release/windows-payload.mjs | 9 +- .../release/windows-server-smoke-checks.mjs | 2 +- .../windows-smoke-environment.test.mjs | 15 +- scripts/run-windows-tests.mjs | 10 +- server/gjc-core-host.test.ts | 140 ++++++++++++------ server/gjc-windows-job.test.ts | 16 ++ server/gjc-windows-job.ts | 60 +++++++- 8 files changed, 215 insertions(+), 64 deletions(-) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 3480f0e2..a8220355 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -67,9 +67,26 @@ jobs: npm run check:licenses if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Test Windows packaging helpers + - name: Test Windows build tooling id: packaging - run: node --test scripts/fetch-bun.test.mjs scripts/release/windows-payload.test.mjs scripts/release/windows-smoke-environment.test.mjs + 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 @@ -93,16 +110,12 @@ jobs: if: ${{ !cancelled() && steps.source.outcome == 'success' && steps.packaging.outcome == 'success' }} run: npm run desktop:build:windows - - name: Test Windows runtime - if: ${{ !cancelled() && steps.build.outcome == 'success' }} - run: npm run test: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 --locked --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc + 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' }} diff --git a/scripts/release/windows-payload.mjs b/scripts/release/windows-payload.mjs index 734eeb5c..9e172184 100644 --- a/scripts/release/windows-payload.mjs +++ b/scripts/release/windows-payload.mjs @@ -160,6 +160,11 @@ export function windowsSmokeEnvironment(nodeDirectory, stateDir, inherited = pro /** 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 } = 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' @@ -188,7 +193,7 @@ try { $probe = [System.IO.Path]::Combine($temporary, ('gajae-' + [Guid]::NewGuid().ToString() + '.tmp')) [System.IO.File]::WriteAllText($probe, 'isolated-temp-writable') [System.IO.File]::Delete($probe) - Add-Type -TypeDefinition 'public static class GajaeSmokeEnvironmentProbe { public static int Value() { return 42; } }' + ${windowsCodeDomCompileScript('public static class GajaeSmokeEnvironmentProbe { public static int Value() { return 42; } }', true)} [Console]::Out.WriteLine(('{"compiled":' + [GajaeSmokeEnvironmentProbe]::Value() + '}')) } catch { [Console]::Error.WriteLine($_.Exception.ToString()) @@ -202,7 +207,7 @@ try { }); 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 records[0]; + return { ...records[0], ...records.find(record => record.compilerTemp) }; } catch (error) { // Do not echo execFile's command field (production guards use huge encoded // commands). The bounded stdout/stderr contain the useful native evidence. diff --git a/scripts/release/windows-server-smoke-checks.mjs b/scripts/release/windows-server-smoke-checks.mjs index 95a67e30..ad8f2fa9 100644 --- a/scripts/release/windows-server-smoke-checks.mjs +++ b/scripts/release/windows-server-smoke-checks.mjs @@ -127,7 +127,7 @@ export function assertRuntimeCatalog(catalog) { 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.exit(0)'], { + 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 = ''; diff --git a/scripts/release/windows-smoke-environment.test.mjs b/scripts/release/windows-smoke-environment.test.mjs index 616cbb34..1e555679 100644 --- a/scripts/release/windows-smoke-environment.test.mjs +++ b/scripts/release/windows-smoke-environment.test.mjs @@ -38,7 +38,7 @@ test('Add-Type probe uses constant UTF-16LE encoded source and returns bounded n execute: async (_command, args, options) => { assert.ok(args.includes('-EncodedCommand')); const source = Buffer.from(args.at(-1), 'base64').toString('utf16le'); - assert.match(source, /Add-Type -TypeDefinition/); + assert.match(source, /Add-Type -CompilerParameters \$compilerParameters -TypeDefinition/); assert.match(source, /GetTempPath/); assert.match(source, /GetRuntimeDirectory/); assert.ok(!source.includes(cwd)); @@ -68,7 +68,9 @@ test('real Windows Add-Type works with baseline and isolated Unicode profile, cw // 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(() => fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })); + 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, @@ -86,8 +88,15 @@ test('real Windows Add-Type works with baseline and isolated Unicode profile, cw 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)); + if (result.elevated) { + assert.match(result.compilerSddl, /\(D;OI;SD;;;/); + assert.match(result.compilerSddl, /\(A;OICI;FA;;;BA\)/); + assert.match(result.compilerSddl, /S:\(ML;OI;NW;;;HI\)/); + } + await assert.rejects(fs.access(result.compilerTemp), { code: 'ENOENT' }); if (label === 'isolated Unicode') { - assert.equal(path.resolve(result.temp).toLowerCase(), path.resolve(env.TEMP).toLowerCase()); + 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) { diff --git a/scripts/run-windows-tests.mjs b/scripts/run-windows-tests.mjs index 16aee9fe..64b99bbc 100644 --- a/scripts/run-windows-tests.mjs +++ b/scripts/run-windows-tests.mjs @@ -4,6 +4,10 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; 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 = [ @@ -27,7 +31,11 @@ for (const directory of ['scripts', 'scripts/lib', 'scripts/release', 'src-tauri } } -for (const [files, tsconfig] of [[serverTests, 'server/tsconfig.json'], [scriptTests, null]]) { +const groups = [ + ...(!options.includes('--scripts-only') ? [[serverTests, 'server/tsconfig.json']] : []), + ...(!options.includes('--server-only') ? [[scriptTests, null]] : []), +]; +for (const [files, tsconfig] of groups) { const result = spawnSync(process.execPath, [ ...(tsconfig ? ['--import', 'tsx'] : []), '--test', '--test-concurrency=1', ...files, diff --git a/server/gjc-core-host.test.ts b/server/gjc-core-host.test.ts index a8b5194f..ce853300 100644 --- a/server/gjc-core-host.test.ts +++ b/server/gjc-core-host.test.ts @@ -229,8 +229,8 @@ test('native core reports transcripts a directory already held when it appeared' test('native core relays bytes and child diagnostics without a shell', async () => { const script = [ "process.stdin.on('data', (chunk) => process.stdout.write(chunk));", - // Windows pipe writes are asynchronous. Let both streams drain before - // exiting, otherwise the fixture itself can truncate a correct relay. + // 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)); @@ -501,7 +501,7 @@ test('native git manages worktrees under paths with spaces and Unicode', async ( } }); -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, [ @@ -514,18 +514,26 @@ test('native PTY relays bounded input, resize, output, and shutdown lifecycle', "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'], }); - const closed = new Promise((resolve) => child.once('close', () => resolve())); + 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) => { @@ -533,54 +541,92 @@ 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: 1000, - rows: 30, - })}\n`); - child.stdin.write(`${JSON.stringify({ - protocolVersion: 1, - method: 'pty.write', - data: Buffer.from('native-pty-token\r').toString('base64'), - })}\n`); - } - if (frame.kind === 'output' && typeof frame.data === 'string') { - output += decoder.write(Buffer.from(frame.data, 'base64')); - if (output.includes('native-child-echo:native-pty-token') && output.includes(cwdMarker) && !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(); + } } } - } - }); - child.once('error', (error) => { - clearTimeout(timer); - reject(error); + } catch (error) { fail(error); } }); + child.once('error', fail); + child.stdin.on('error', fail); child.once('close', (code, signal) => { clearTimeout(timer); resolve({ code, signal }); }); }); + 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, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } finally { + clearTimeout(forceStop); + clearTimeout(cleanupTimeout); + } + }; + let testFailed = false; try { const exit = await completed; assert.deepEqual(exit, { code: 0, signal: null }); @@ -588,11 +634,17 @@ test('native PTY relays bounded input, resize, output, and shutdown lifecycle', 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 { - child.kill('SIGKILL'); - await closed; - await rm(temporaryRoot, { recursive: true, force: true }); + await cleanup().catch((error) => { + if (!testFailed) throw error; + console.error('native PTY cleanup also failed:', error); + }); } }); diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index 0ef98315..9acc62da 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -10,6 +10,7 @@ import { GJC_WINDOWS_JOB_GUARD_ACK, GJC_WINDOWS_JOB_GUARD_READY, quoteWindowsArgument, + windowsCodeDomCompileScript, } from './gjc-windows-job.js'; test('quotes Windows argv values without losing quotes or trailing slashes', () => { @@ -23,6 +24,21 @@ 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, /\[IO.Directory\]::CreateDirectory\(\$compilerTemp, \$compilerSecurity\)/); + 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\(\$compilerTemp, \$false\)/); + assert.match(script, /Add-Type -CompilerParameters \$compilerParameters/); + assert.doesNotMatch(script, /DisableTempFileCollectionDirectoryFeature|SetSwitch|junction|ShortPath/i); + assert.ok(script.indexOf('SetSecurityDescriptorSddlForm($compilerSddl)') < script.indexOf('[IO.Directory]::CreateDirectory')); + 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('builds a guard that atomically creates the worker inside a Windows job', () => { const launch = createWindowsJobLaunch( 'C:\\Program Files\\node.exe', diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index f0c93ecb..5b260806 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -13,9 +13,57 @@ 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 @' +/** Compiles trusted constant C# without CodeDom's ANSI elevated-temp helper. */ +export function windowsCodeDomCompileScript(typeDefinition: string, diagnostics = false): string { + return String.raw` +$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 +try { + $compilerSecurity = [Security.AccessControl.DirectorySecurity]::new() + 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)' + } + $compilerSecurity.SetSecurityDescriptorSddlForm($compilerSddl) + # System.IO uses the wide API; the CodeDom helper uses an ANSI import. + $null = [IO.Directory]::CreateDirectory($compilerTemp, $compilerSecurity) + $compilerTempCreated = $true + ${diagnostics ? `[Console]::Out.WriteLine((@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; compilerSddl = $compilerSecurity.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::All) } | ConvertTo-Json -Compress))` : ''} + $compilerParameters = [CodeDom.Compiler.CompilerParameters]::new() + $compilerParameters.GenerateInMemory = $true + $compilerParameters.ReferencedAssemblies.AddRange([string[]]@('System.dll', 'System.Core.dll')) + $compilerParameters.TempFiles = [CodeDom.Compiler.TempFileCollection]::new($compilerTemp, $false) + $null = Add-Type -CompilerParameters $compilerParameters -TypeDefinition @' +${typeDefinition} +'@ +} finally { + try { + 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() + } +} +`.trim(); +} + +const WINDOWS_JOB_GUARD_SCRIPT = `$ErrorActionPreference = 'Stop' +${windowsCodeDomCompileScript(String.raw` using System; using System.ComponentModel; using System.Runtime.InteropServices; @@ -396,8 +444,8 @@ 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') @@ -431,7 +479,7 @@ try { } finally { [GajaeWindowsJobGuard]::CloseOwner($ownerHandle) } -`.trim(); +`.trim()}`; const WINDOWS_JOB_GUARD_COMMAND = (() => { const compressed = gzipSync( From da418ce6e4c7d5762c396497880c5f80c88bf691 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 17:23:34 +0900 Subject: [PATCH 06/22] fix(windows): preserve compiler integrity labels through native APIs --- .../windows-smoke-environment.test.mjs | 2 +- server/gjc-windows-job.test.ts | 8 ++- server/gjc-windows-job.ts | 62 +++++++++++++++++-- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/scripts/release/windows-smoke-environment.test.mjs b/scripts/release/windows-smoke-environment.test.mjs index 1e555679..74aec29b 100644 --- a/scripts/release/windows-smoke-environment.test.mjs +++ b/scripts/release/windows-smoke-environment.test.mjs @@ -92,7 +92,7 @@ test('real Windows Add-Type works with baseline and isolated Unicode profile, cw if (result.elevated) { assert.match(result.compilerSddl, /\(D;OI;SD;;;/); assert.match(result.compilerSddl, /\(A;OICI;FA;;;BA\)/); - assert.match(result.compilerSddl, /S:\(ML;OI;NW;;;HI\)/); + assert.match(result.compilerSddl, /\(ML;[^;]*;NW;;;HI\)/); } await assert.rejects(fs.access(result.compilerTemp), { code: 'ENOENT' }); if (label === 'isolated Unicode') { diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index 9acc62da..70ef0533 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -26,7 +26,11 @@ 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, /\[IO.Directory\]::CreateDirectory\(\$compilerTemp, \$compilerSecurity\)/); + 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/); @@ -34,7 +38,7 @@ test('CodeDom compilation uses explicit private temp files with the original ele assert.match(script, /TempFileCollection\]::new\(\$compilerTemp, \$false\)/); assert.match(script, /Add-Type -CompilerParameters \$compilerParameters/); assert.doesNotMatch(script, /DisableTempFileCollectionDirectoryFeature|SetSwitch|junction|ShortPath/i); - assert.ok(script.indexOf('SetSecurityDescriptorSddlForm($compilerSddl)') < script.indexOf('[IO.Directory]::CreateDirectory')); + 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')); }); diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index 5b260806..1367e68a 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -24,7 +24,6 @@ $compilerTemp = [IO.Path]::Combine([IO.Path]::GetTempPath(), ('gajae-code-dom-' $compilerParameters = $null $compilerTempCreated = $false try { - $compilerSecurity = [Security.AccessControl.DirectorySecurity]::new() if ($compilerElevated) { # Exact SDDL used by .NET TempFileCollection.CreateTempDirectoryWithAce: # inherited deny-delete, administrator access, and a high-integrity label. @@ -32,11 +31,64 @@ try { } else { $compilerSddl = 'D:P(A;OICI;FA;;;' + $compilerSid + ')(A;OICI;FA;;;SY)' } - $compilerSecurity.SetSecurityDescriptorSddlForm($compilerSddl) - # System.IO uses the wide API; the CodeDom helper uses an ANSI import. - $null = [IO.Directory]::CreateDirectory($compilerTemp, $compilerSecurity) + # 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) { + $method = $builder.DefineMethod($name, [Reflection.MethodAttributes]::Public -bor [Reflection.MethodAttributes]::Static -bor [Reflection.MethodAttributes]::PinvokeImpl, [bool], $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') } + } + Add-GajaeCompilerImport $compilerType 'CreateDirectoryW' 'kernel32.dll' ([type[]]@([string], [IntPtr])) + Add-GajaeCompilerImport $compilerType 'GetFileSecurityW' 'advapi32.dll' ([type[]]@([string], [uint32], [byte[]], [uint32], [uint32].MakeByRefType())) + $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 - ${diagnostics ? `[Console]::Out.WriteLine((@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; compilerSddl = $compilerSecurity.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::All) } | ConvertTo-Json -Compress))` : ''} + # 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) + if ($compilerElevated -and $compilerActualSddl -notmatch '\(ML;[^;]*;NW;;;HI\)') { throw 'Compiler directory high-integrity label was not preserved.' } + ${diagnostics ? `[Console]::Out.WriteLine((@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; compilerSddl = $compilerActualSddl } | ConvertTo-Json -Compress))` : ''} $compilerParameters = [CodeDom.Compiler.CompilerParameters]::new() $compilerParameters.GenerateInMemory = $true $compilerParameters.ReferencedAssemblies.AddRange([string[]]@('System.dll', 'System.Core.dll')) From 575cb39a5aad168c81e1e28bd8126c8a85b7d748 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 17:38:13 +0900 Subject: [PATCH 07/22] test(windows): expose native compiler security diagnostics early --- .github/workflows/windows.yml | 18 ++++++++++ scripts/release/probe-windows-compiler.mjs | 39 ++++++++++++++++++++++ server/gjc-core-host.test.ts | 13 +++++--- server/gjc-windows-job.test.ts | 24 +++++++++++++ server/gjc-windows-job.ts | 8 +++-- 5 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 scripts/release/probe-windows-compiler.mjs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index a8220355..a26b418e 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -18,8 +18,26 @@ concurrency: 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: diff --git a/scripts/release/probe-windows-compiler.mjs b/scripts/release/probe-windows-compiler.mjs new file mode 100644 index 00000000..c6a92ddb --- /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 { 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', Buffer.from(source, 'utf16le').toString('base64'), + ], { 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/server/gjc-core-host.test.ts b/server/gjc-core-host.test.ts index ce853300..ea54ee71 100644 --- a/server/gjc-core-host.test.ts +++ b/server/gjc-core-host.test.ts @@ -12,6 +12,9 @@ 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. @@ -167,7 +170,7 @@ test('native core recursively watches multiple roots and filters non-transcript } finally { child.kill('SIGKILL'); await closed; - await rm(temporaryRoot, { recursive: true, force: true }); + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); } }); @@ -222,7 +225,7 @@ test('native core reports transcripts a directory already held when it appeared' } finally { child.kill('SIGKILL'); await closed; - await rm(temporaryRoot, { recursive: true, force: true }); + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); } }); @@ -451,7 +454,7 @@ 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); } }); @@ -497,7 +500,7 @@ test('native git manages worktrees under paths with spaces and Unicode', async ( 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, { recursive: true, force: true }); + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); } }); @@ -620,7 +623,7 @@ test('native PTY relays bounded input, resize, output, and shutdown lifecycle', cleanupTimeout = setTimeout(() => reject(new Error('native PTY cleanup timed out')), 5_000); })]); } - await rm(temporaryRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + await rm(temporaryRoot, TEMP_ROOT_CLEANUP_OPTIONS); } finally { clearTimeout(forceStop); clearTimeout(cleanupTimeout); diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index 70ef0533..92a60fe4 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -43,6 +43,30 @@ test('CodeDom compilation uses explicit private temp files with the original ele assert.ok(script.indexOf('$compilerParameters.TempFiles.Delete()') < script.indexOf('[IO.Directory]::Delete')); }); +test('generated PowerShell label regex matches SDDL and diagnostics precede 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]) { + const pattern = script.match(/\$compilerActualSddl -notmatch '([^']+)'/u)?.[1]; + assert.equal(pattern, String.raw`\(ML;[^;]*;NW;;;HI\)`); + const regex = new RegExp(pattern!); + for (const high of ['S:(ML;OI;NW;;;HI)', 'D:(A;OICI;FA;;;BA)S:(ML;;NW;;;HI)', 'S:(ML;OICI;NW;;;HI)']) { + assert.equal(regex.test(high), true, high); + } + for (const rejected of ['S:(ML;OI;NW;;;ME)', 'D:(A;OICI;FA;;;BA)', 'S:(ML;OI;NR;;;HI)']) { + assert.equal(regex.test(rejected), false, rejected); + } + assert.match(script, /requestedCompilerSddl = \$compilerSddl; compilerSddl = \$compilerActualSddl/); + assert.match(script, /high-integrity label was not preserved\. ' \+ \$compilerSecurityReport/); + } + assert.ok(diagnosticScript.indexOf('[Console]::Out.WriteLine($compilerSecurityReport)') + < diagnosticScript.indexOf('$compilerActualSddl -notmatch')); +}); + test('builds a guard that atomically creates the worker inside a Windows job', () => { const launch = createWindowsJobLaunch( 'C:\\Program Files\\node.exe', diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index 1367e68a..ed6f17c2 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -87,8 +87,12 @@ try { } $compilerActualSecurity = [Security.AccessControl.RawSecurityDescriptor]::new($compilerActualBytes, 0) $compilerActualSddl = $compilerActualSecurity.GetSddlForm([Security.AccessControl.AccessControlSections]::All) - if ($compilerElevated -and $compilerActualSddl -notmatch '\(ML;[^;]*;NW;;;HI\)') { throw 'Compiler directory high-integrity label was not preserved.' } - ${diagnostics ? `[Console]::Out.WriteLine((@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; compilerSddl = $compilerActualSddl } | ConvertTo-Json -Compress))` : ''} + $compilerSecurityReport = (@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; requestedCompilerSddl = $compilerSddl; compilerSddl = $compilerActualSddl } | ConvertTo-Json -Compress) + ${diagnostics ? '[Console]::Out.WriteLine($compilerSecurityReport)' : ''} + # String.raw preserves the single backslash required by PowerShell/.NET. + if ($compilerElevated -and $compilerActualSddl -notmatch '\(ML;[^;]*;NW;;;HI\)') { + throw ('Compiler directory high-integrity label was not preserved. ' + $compilerSecurityReport) + } $compilerParameters = [CodeDom.Compiler.CompilerParameters]::new() $compilerParameters.GenerateInMemory = $true $compilerParameters.ReferencedAssemblies.AddRange([string[]]@('System.dll', 'System.Core.dll')) From 63622fa7dcc72be8c2af91ef4cfae4b63f0a6ff1 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 17:45:37 +0900 Subject: [PATCH 08/22] fix(windows): verify compiler integrity from raw security entries --- .../windows-smoke-environment.test.mjs | 39 ++++++++++++++++- server/gjc-windows-job.test.ts | 24 ++++++----- server/gjc-windows-job.ts | 43 +++++++++++++++++-- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/scripts/release/windows-smoke-environment.test.mjs b/scripts/release/windows-smoke-environment.test.mjs index 74aec29b..bff6017d 100644 --- a/scripts/release/windows-smoke-environment.test.mjs +++ b/scripts/release/windows-smoke-environment.test.mjs @@ -1,8 +1,10 @@ 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 { promisify } from 'node:util'; import { verifyWindowsSmokeEnvironment, windowsSmokeEnvironment } from './windows-payload.mjs'; @@ -61,6 +63,38 @@ test('Add-Type probe uses constant UTF-16LE encoded source and returns bounded n }); }); +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('real Windows Add-Type works with baseline and isolated Unicode profile, cwd and temp', { skip: process.platform !== 'win32', timeout: 140_000, }, async t => { @@ -92,7 +126,10 @@ test('real Windows Add-Type works with baseline and isolated Unicode profile, cw if (result.elevated) { assert.match(result.compilerSddl, /\(D;OI;SD;;;/); assert.match(result.compilerSddl, /\(A;OICI;FA;;;BA\)/); - assert.match(result.compilerSddl, /\(ML;[^;]*;NW;;;HI\)/); + 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') { diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index 92a60fe4..64be6bfa 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -11,6 +11,7 @@ import { GJC_WINDOWS_JOB_GUARD_READY, quoteWindowsArgument, windowsCodeDomCompileScript, + windowsCodeDomLabelValidationScript, } from './gjc-windows-job.js'; test('quotes Windows argv values without losing quotes or trailing slashes', () => { @@ -43,7 +44,7 @@ test('CodeDom compilation uses explicit private temp files with the original ele assert.ok(script.indexOf('$compilerParameters.TempFiles.Delete()') < script.indexOf('[IO.Directory]::Delete')); }); -test('generated PowerShell label regex matches SDDL and diagnostics precede rejection', () => { +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'); @@ -51,20 +52,21 @@ test('generated PowerShell label regex matches SDDL and diagnostics precede reje assert.ok(compressed); const guardScript = gunzipSync(Buffer.from(compressed, 'base64')).toString('utf8'); for (const script of [diagnosticScript, guardScript]) { - const pattern = script.match(/\$compilerActualSddl -notmatch '([^']+)'/u)?.[1]; - assert.equal(pattern, String.raw`\(ML;[^;]*;NW;;;HI\)`); - const regex = new RegExp(pattern!); - for (const high of ['S:(ML;OI;NW;;;HI)', 'D:(A;OICI;FA;;;BA)S:(ML;;NW;;;HI)', 'S:(ML;OICI;NW;;;HI)']) { - assert.equal(regex.test(high), true, high); - } - for (const rejected of ['S:(ML;OI;NW;;;ME)', 'D:(A;OICI;FA;;;BA)', 'S:(ML;OI;NR;;;HI)']) { - assert.equal(regex.test(rejected), false, rejected); - } + 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('$compilerActualSddl -notmatch')); + < diagnosticScript.indexOf('$compilerElevated -and -not $compilerActualLabels.hasHighLabel')); }); test('builds a guard that atomically creates the worker inside a Windows job', () => { diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index ed6f17c2..dc261a99 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -13,9 +13,43 @@ 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'; +/** 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(); +} + /** Compiles trusted constant C# without CodeDom's ANSI elevated-temp helper. */ export function windowsCodeDomCompileScript(typeDefinition: string, diagnostics = false): string { return String.raw` +${windowsCodeDomLabelValidationScript()} $compilerIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() $compilerSid = $compilerIdentity.User.Value $compilerPrincipal = [Security.Principal.WindowsPrincipal]::new($compilerIdentity) @@ -87,10 +121,13 @@ try { } $compilerActualSecurity = [Security.AccessControl.RawSecurityDescriptor]::new($compilerActualBytes, 0) $compilerActualSddl = $compilerActualSecurity.GetSddlForm([Security.AccessControl.AccessControlSections]::All) - $compilerSecurityReport = (@{ compilerTemp = $compilerTemp; elevated = $compilerElevated; requestedCompilerSddl = $compilerSddl; compilerSddl = $compilerActualSddl } | ConvertTo-Json -Compress) + $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)' : ''} - # String.raw preserves the single backslash required by PowerShell/.NET. - if ($compilerElevated -and $compilerActualSddl -notmatch '\(ML;[^;]*;NW;;;HI\)') { + # 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) } $compilerParameters = [CodeDom.Compiler.CompilerParameters]::new() From 648d4780dcf0f9b7dd16becf9d56c6185b92d026 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 17:57:15 +0900 Subject: [PATCH 09/22] fix(windows): use verified compiler path aliases for Unicode profiles --- scripts/release/windows-payload.mjs | 2 +- .../windows-smoke-environment.test.mjs | 39 +++++++++++ server/gjc-windows-job.test.ts | 26 ++++++- server/gjc-windows-job.ts | 67 ++++++++++++++++++- 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/scripts/release/windows-payload.mjs b/scripts/release/windows-payload.mjs index 9e172184..d6cd4690 100644 --- a/scripts/release/windows-payload.mjs +++ b/scripts/release/windows-payload.mjs @@ -207,7 +207,7 @@ try { }); 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 { ...records[0], ...records.find(record => record.compilerTemp) }; + 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. diff --git a/scripts/release/windows-smoke-environment.test.mjs b/scripts/release/windows-smoke-environment.test.mjs index bff6017d..bf8423cc 100644 --- a/scripts/release/windows-smoke-environment.test.mjs +++ b/scripts/release/windows-smoke-environment.test.mjs @@ -95,6 +95,37 @@ foreach ($case in ($env:GAJAE_LABEL_FIXTURES | ConvertFrom-Json)) { 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 => { @@ -123,6 +154,14 @@ test('real Windows Add-Type works with baseline and isolated Unicode profile, cw 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\)/); diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index 64be6bfa..778c1309 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -12,6 +12,7 @@ import { quoteWindowsArgument, windowsCodeDomCompileScript, windowsCodeDomLabelValidationScript, + windowsCodeDomPathValidationScript, } from './gjc-windows-job.js'; test('quotes Windows argv values without losing quotes or trailing slashes', () => { @@ -36,14 +37,35 @@ test('CodeDom compilation uses explicit private temp files with the original ele 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\(\$compilerTemp, \$false\)/); + assert.match(script, /TempFileCollection\]::new\(\$compilerPath, \$false\)/); assert.match(script, /Add-Type -CompilerParameters \$compilerParameters/); - assert.doesNotMatch(script, /DisableTempFileCollectionDirectoryFeature|SetSwitch|junction|ShortPath/i); + 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:\\'); diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index dc261a99..ae28c646 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -46,10 +46,26 @@ function Get-GajaeCompilerLabelState([Security.AccessControl.RawSecurityDescript `.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) @@ -57,6 +73,9 @@ $compilerElevated = $compilerPrincipal.IsInRole([Security.Principal.WindowsBuilt $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: @@ -73,8 +92,8 @@ try { $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) { - $method = $builder.DefineMethod($name, [Reflection.MethodAttributes]::Public -bor [Reflection.MethodAttributes]::Static -bor [Reflection.MethodAttributes]::PinvokeImpl, [bool], $parameters) + 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')) @@ -82,9 +101,12 @@ try { $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) @@ -130,15 +152,51 @@ try { 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')) - $compilerParameters.TempFiles = [CodeDom.Compiler.TempFileCollection]::new($compilerTemp, $false) + # 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. @@ -152,6 +210,9 @@ ${typeDefinition} $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(); } From 21ac3b665c09857be286e5bead3aa16df4476037 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 18:00:47 +0900 Subject: [PATCH 10/22] fix(windows): compress compiler probes within command-line limits --- scripts/release/probe-windows-compiler.mjs | 4 ++-- scripts/release/windows-payload.mjs | 4 ++-- scripts/release/windows-smoke-environment.test.mjs | 9 +++++++-- server/gjc-windows-job.test.ts | 11 +++++++++++ server/gjc-windows-job.ts | 8 +++++--- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/scripts/release/probe-windows-compiler.mjs b/scripts/release/probe-windows-compiler.mjs index c6a92ddb..b447a07e 100644 --- a/scripts/release/probe-windows-compiler.mjs +++ b/scripts/release/probe-windows-compiler.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { windowsCodeDomCompileScript } from '../../server/gjc-windows-job.ts'; +import { encodeWindowsPowerShellCommand, windowsCodeDomCompileScript } from '../../server/gjc-windows-job.ts'; import { assertWindowsHost, windowsSmokeEnvironment } from './windows-payload.mjs'; @@ -28,7 +28,7 @@ try { 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', Buffer.from(source, 'utf16le').toString('base64'), + '-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; diff --git a/scripts/release/windows-payload.mjs b/scripts/release/windows-payload.mjs index d6cd4690..9b991a9d 100644 --- a/scripts/release/windows-payload.mjs +++ b/scripts/release/windows-payload.mjs @@ -164,7 +164,7 @@ export async function verifyWindowsSmokeEnvironment(env, cwd, { execute = promis // 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 } = await tsImport(new URL('../../server/gjc-windows-job.ts', import.meta.url).href, import.meta.url); + 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' @@ -201,7 +201,7 @@ try { } `.trim(); try { - const encoded = Buffer.from(source, 'utf16le').toString('base64'); + 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, }); diff --git a/scripts/release/windows-smoke-environment.test.mjs b/scripts/release/windows-smoke-environment.test.mjs index bf8423cc..2abdc8e6 100644 --- a/scripts/release/windows-smoke-environment.test.mjs +++ b/scripts/release/windows-smoke-environment.test.mjs @@ -4,6 +4,7 @@ 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'; @@ -32,14 +33,18 @@ test('isolated Windows environment retains OS/compiler metadata and isolates all for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'NODE_OPTIONS']) assert.equal(env[key], undefined); }); -test('Add-Type probe uses constant UTF-16LE encoded source and returns bounded native evidence', async () => { +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')); - const source = Buffer.from(args.at(-1), 'base64').toString('utf16le'); + 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/); diff --git a/server/gjc-windows-job.test.ts b/server/gjc-windows-job.test.ts index 778c1309..99b5f929 100644 --- a/server/gjc-windows-job.test.ts +++ b/server/gjc-windows-job.test.ts @@ -6,6 +6,7 @@ import { gunzipSync } from 'node:zlib'; import { createWindowsJobLaunch, + encodeWindowsPowerShellCommand, killWindowsJobGuard, GJC_WINDOWS_JOB_GUARD_ACK, GJC_WINDOWS_JOB_GUARD_READY, @@ -15,6 +16,16 @@ import { 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(''), '""'); diff --git a/server/gjc-windows-job.ts b/server/gjc-windows-job.ts index ae28c646..9ce49eb2 100644 --- a/server/gjc-windows-job.ts +++ b/server/gjc-windows-job.ts @@ -635,9 +635,9 @@ try { } `.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 = [ @@ -648,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 { From f8f3bcb541fa5709da5d79c994f2b30e500deb7e Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 18:18:03 +0900 Subject: [PATCH 11/22] fix(windows): finish packaged smoke after verified shutdown --- scripts/release/windows-server-smoke-checks.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/release/windows-server-smoke-checks.mjs b/scripts/release/windows-server-smoke-checks.mjs index ad8f2fa9..1115bfd7 100644 --- a/scripts/release/windows-server-smoke-checks.mjs +++ b/scripts/release/windows-server-smoke-checks.mjs @@ -271,7 +271,16 @@ async function main() { 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); - console.log('Windows payload smoke passed: Node, SQLite, ConPTY, core, ripgrep, Bun worker, supervised model catalog/Job chain, desktop bootstrap/auth, frontend and graceful shutdown.'); + 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(); +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); +} From 2889326fffa0e48bdd34b786f4a1a94dc55d5574 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 18:44:55 +0900 Subject: [PATCH 12/22] test(windows): wait for descendant exit signal after job shutdown --- src-tauri/src/windows_process.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/windows_process.rs b/src-tauri/src/windows_process.rs index bed9d438..e8edd4df 100644 --- a/src-tauri/src/windows_process.rs +++ b/src-tauri/src/windows_process.rs @@ -544,8 +544,10 @@ mod tests { "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(), 0) }, + unsafe { WaitForSingleObject(descendant.as_raw_handle(), 5_000) }, WAIT_OBJECT_0 ); assert!(process.tree_is_empty().unwrap()); From b4e45c98b35952620faa608b75ab70f3f186df82 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 5 Sep 2026 19:12:16 +0900 Subject: [PATCH 13/22] docs(windows): record verified preview installer [skip ci] --- docs/WINDOWS-DESKTOP.md | 43 ++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/WINDOWS-DESKTOP.md b/docs/WINDOWS-DESKTOP.md index 832261eb..ca0f7a83 100644 --- a/docs/WINDOWS-DESKTOP.md +++ b/docs/WINDOWS-DESKTOP.md @@ -28,6 +28,13 @@ for the C++ toolchain and WebView2. 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 @@ -64,6 +71,8 @@ browser. 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. 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. @@ -107,12 +116,28 @@ terminal tools; this port does not add a Windows native computer-control driver. ## Verification record — September 5, 2026 -- Linux x64, Node 24.18.0: `npm run verify` passed, including 1,428 JavaScript - and Bun tests and 58 Rust unit tests plus three Rust process tests. -- The focused Windows contracts also pass on Linux; tests requiring actual - Windows APIs remain gated to the Windows runner. -- Tauri wrapper/bootstrap/icon tests and Rust formatting passed. The Windows - shell sources passed a cross-target compile/Clippy check in an isolated - harness; this did not build or run the installer. -- Native Windows CI and interactive acceptance are separate evidence. The - checklist above records what must still be verified before public release. +- 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. From a905af7d7a651bd662c919da0780ccc1720187e5 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:21:39 +0900 Subject: [PATCH 14/22] fix(desktop): preserve process ownership across integration Restore byte-stream readiness parsing and Unix graceful shutdown semantics. Keep Unix forced startup cleanup on owned handles and Windows failed-reap recovery observing the existing Job before allowing a retry. Retain forced-cleanup and failed-kill regression coverage, format the native core using its declared edition, and finalize closed Bun SQLite statements before removing Windows SDK test fixtures. --- native/gajae-core/src/git.rs | 86 ++++++++------ native/gajae-core/src/jobs.rs | 82 +++++++------ native/gajae-core/src/lib.rs | 2 +- native/gajae-core/src/main.rs | 2 +- native/gajae-core/src/pty.rs | 4 +- native/gajae-core/src/watcher.rs | 24 ++-- server/gjc-sdk-contract.bun.test.ts | 13 ++ src-tauri/src/lifecycle.rs | 176 ++++++++++++++++++++-------- src-tauri/src/supervisor.rs | 100 +++++++++++----- 9 files changed, 324 insertions(+), 165 deletions(-) diff --git a/native/gajae-core/src/git.rs b/native/gajae-core/src/git.rs index 40df38fd..25f021f8 100644 --- a/native/gajae-core/src/git.rs +++ b/native/gajae-core/src/git.rs @@ -5,9 +5,9 @@ use std::process::{Command, Output, Stdio}; use std::sync::mpsc; use std::thread; -use base64::{engine::general_purpose::STANDARD, Engine as _}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; const MAX_FRAME_BYTES: usize = 64 * 1024; const MAX_DIFF_BYTES: usize = 16 * 1024 * 1024; @@ -949,40 +949,48 @@ mod tests { // Canonicalize so expected paths match git's canonical worktree // output (macOS resolves /var -> /private/var). let path = std::fs::canonicalize(&path).unwrap(); - assert!(Command::new("git") - .args(["init", "--quiet"]) - .current_dir(&path) - .status() - .unwrap() - .success()); - assert!(Command::new("git") - .args(["config", "core.autocrlf", "false"]) - .current_dir(&path) - .status() - .unwrap() - .success()); + assert!( + Command::new("git") + .args(["init", "--quiet"]) + .current_dir(&path) + .status() + .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") - .args(["add", "tracked.txt"]) - .current_dir(&path) - .status() - .unwrap() - .success()); - assert!(Command::new("git") - .args([ - "-c", - "user.name=Gajae Test", - "-c", - "user.email=gajae@example.test", - "commit", - "--quiet", - "-m", - "initial", - ]) - .current_dir(&path) - .status() - .unwrap() - .success()); + assert!( + Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(&path) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .args([ + "-c", + "user.name=Gajae Test", + "-c", + "user.email=gajae@example.test", + "commit", + "--quiet", + "-m", + "initial", + ]) + .current_dir(&path) + .status() + .unwrap() + .success() + ); Self { path } } } @@ -1155,9 +1163,11 @@ mod tests { .decode(stream[0]["data"].as_str().unwrap()) .unwrap(); assert!(patch.starts_with(b"diff --git ")); - assert!(patch - .windows(b"-before".len()) - .any(|part| part == b"-before")); + assert!( + patch + .windows(b"-before".len()) + .any(|part| part == b"-before") + ); assert!(patch.windows(b"+after".len()).any(|part| part == b"+after")); } diff --git a/native/gajae-core/src/jobs.rs b/native/gajae-core/src/jobs.rs index ca0698dc..0738e594 100644 --- a/native/gajae-core/src/jobs.rs +++ b/native/gajae-core/src/jobs.rs @@ -3,7 +3,7 @@ use std::io::{BufRead, Read, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; -use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -1965,14 +1965,16 @@ mod tests { a.append_event("j", &l, "e", json!(2)), Err(AuthorityError::EventConflict) ); - assert!(a - .connection - .execute("INSERT INTO job_events VALUES('j',1,'x','{}')", []) - .is_err()); - assert!(a - .connection - .execute("INSERT INTO job_events VALUES('j',2,'e','{}')", []) - .is_err()); + assert!( + a.connection + .execute("INSERT INTO job_events VALUES('j',1,'x','{}')", []) + .is_err() + ); + assert!( + a.connection + .execute("INSERT INTO job_events VALUES('j',2,'e','{}')", []) + .is_err() + ); drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -2068,11 +2070,12 @@ mod tests { Err(AuthorityError::Storage) ); assert_eq!(a.snapshot("rollback").unwrap().state, JobState::Running); - assert!(a - .replay("rollback", 0, 999, "test") - .unwrap() - .events - .is_empty()); + assert!( + a.replay("rollback", 0, 999, "test") + .unwrap() + .events + .is_empty() + ); drop(a); std::fs::remove_dir_all(d).unwrap(); } @@ -2408,15 +2411,19 @@ mod tests { .collect(); assert_eq!(responses.len(), 3); assert_eq!(responses[1]["result"]["prompt"], json!("draft")); - assert!(!responses[1]["result"]["createdAt"] - .as_str() - .unwrap() - .is_empty()); + assert!( + !responses[1]["result"]["createdAt"] + .as_str() + .unwrap() + .is_empty() + ); assert_eq!(responses[2]["result"]["items"][0]["prompt"], json!("draft")); - assert!(!responses[2]["result"]["items"][0]["createdAt"] - .as_str() - .unwrap() - .is_empty()); + assert!( + !responses[2]["result"]["items"][0]["createdAt"] + .as_str() + .unwrap() + .is_empty() + ); std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -2540,10 +2547,12 @@ mod tests { .unwrap(); let archived = a.archive("ready").unwrap(); assert_eq!(archived.state, JobState::Succeeded); - assert!(serde_json::to_value(&archived) - .unwrap() - .get("archivedAt") - .is_none()); + assert!( + serde_json::to_value(&archived) + .unwrap() + .get("archivedAt") + .is_none() + ); let archived_at: Option = a .connection .query_row("SELECT archived_at FROM jobs WHERE id='ready'", [], |row| { @@ -2687,9 +2696,10 @@ mod tests { .unwrap(), 6 ); - assert!(c - .query_row("SELECT archived_at FROM jobs LIMIT 1", [], |_| Ok(())) - .is_err()); + assert!( + c.query_row("SELECT archived_at FROM jobs LIMIT 1", [], |_| Ok(())) + .is_err() + ); drop(c); std::fs::remove_dir_all(d).unwrap(); } @@ -2772,10 +2782,11 @@ mod tests { .unwrap(), 1 ); - assert!(c - .query_row("SELECT state_json FROM job_authority WHERE id=1", [], |r| r + assert!( + c.query_row("SELECT state_json FROM job_authority WHERE id=1", [], |r| r .get::<_, String>(0)) - .is_ok()); + .is_ok() + ); drop(c); std::fs::remove_dir_all(d).unwrap(); } @@ -2860,9 +2871,10 @@ mod tests { .unwrap(), 2 ); - assert!(c - .query_row("SELECT base_commit FROM jobs LIMIT 1", [], |_| Ok(())) - .is_err()); + assert!( + c.query_row("SELECT base_commit FROM jobs LIMIT 1", [], |_| Ok(())) + .is_err() + ); drop(c); std::fs::remove_dir_all(d).unwrap(); } diff --git a/native/gajae-core/src/lib.rs b/native/gajae-core/src/lib.rs index 987c0297..5461d518 100644 --- a/native/gajae-core/src/lib.rs +++ b/native/gajae-core/src/lib.rs @@ -172,7 +172,7 @@ pub fn map_exit_status(code: Option, signal: Option) -> u8 { #[cfg(test)] mod tests { - use super::{map_exit_status, parse_args, Command, ParseError}; + use super::{Command, ParseError, map_exit_status, parse_args}; use std::ffi::OsString; fn os(value: &str) -> OsString { diff --git a/native/gajae-core/src/main.rs b/native/gajae-core/src/main.rs index 356d91bc..4625b7ff 100644 --- a/native/gajae-core/src/main.rs +++ b/native/gajae-core/src/main.rs @@ -4,7 +4,7 @@ use std::io::{self, Read, Write}; use std::process::{Child, Command, ExitCode, ExitStatus, Stdio}; use std::thread; -use gajae_core::{git, jobs, map_exit_status, parse_args, pty, watcher, Command as CliCommand}; +use gajae_core::{Command as CliCommand, git, jobs, map_exit_status, parse_args, pty, watcher}; const USAGE_ERROR: &[u8] = b"gajae-core: usage error\n"; const SPAWN_ERROR: &[u8] = b"gajae-core: spawn failed\n"; diff --git a/native/gajae-core/src/pty.rs b/native/gajae-core/src/pty.rs index a93b64ef..5d377e6b 100644 --- a/native/gajae-core/src/pty.rs +++ b/native/gajae-core/src/pty.rs @@ -5,9 +5,9 @@ use std::sync::{Arc, Mutex}; use std::thread; use base64::Engine; -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; const MAX_FRAME_BYTES: usize = 64 * 1024; const MAX_WRITE_BYTES: usize = 48 * 1024; diff --git a/native/gajae-core/src/watcher.rs b/native/gajae-core/src/watcher.rs index a973c2fe..af77ee08 100644 --- a/native/gajae-core/src/watcher.rs +++ b/native/gajae-core/src/watcher.rs @@ -2,9 +2,9 @@ use std::collections::VecDeque; use std::ffi::OsStr; use std::io::{self, Read, Write}; use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, TrySendError}; -use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; @@ -294,10 +294,10 @@ fn fail() -> bool { #[cfg(test)] mod tests { use super::{ - backfill_frames, frame_for_path, frame_for_resolved_path, write_due_backfill_frames, - write_event_frames, OutputEvent, MAX_BACKFILL_ENTRIES, MAX_PENDING_BACKFILLS, + MAX_BACKFILL_ENTRIES, MAX_PENDING_BACKFILLS, OutputEvent, backfill_frames, frame_for_path, + frame_for_resolved_path, write_due_backfill_frames, write_event_frames, }; - use notify::{event::Flag, Event, EventKind}; + use notify::{Event, EventKind, event::Flag}; use std::collections::VecDeque; use std::fs; use std::path::PathBuf; @@ -345,9 +345,11 @@ mod tests { .collect::>(); assert_eq!(reported.len(), 2, "{reported:?}"); - assert!(reported - .iter() - .all(|frame| frame.contains("\"event\":\"add\""))); + assert!( + reported + .iter() + .all(|frame| frame.contains("\"event\":\"add\"")) + ); assert!(reported.iter().any(|frame| frame.contains("session.jsonl"))); assert!(reported.iter().any(|frame| frame.contains("nested.jsonl"))); assert!(!reported.iter().any(|frame| frame.contains("ignored.txt"))); @@ -369,9 +371,11 @@ mod tests { #[cfg(unix)] std::os::unix::fs::symlink(&outside, created.join("linked.jsonl")).unwrap(); - assert!(backfill_frames(&created, std::slice::from_ref(&root)) - .unwrap() - .is_empty()); + assert!( + backfill_frames(&created, std::slice::from_ref(&root)) + .unwrap() + .is_empty() + ); fs::remove_dir_all(container).unwrap(); } diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 52f5fdaf..c2c3578a 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -346,6 +346,17 @@ 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)!; } + +function collectWindowsSqliteHandles(): void { + if (process.platform !== 'win32') return; + // Bun 1.4.0 defers cached SQLite statement finalization beyond the + // public Database.close() call. Force finalization only after every fixture + // owner has closed its session, registry, cache, auth, and settings handles; + // this is required for Windows to release the files before rm(). + const bun = (globalThis as typeof globalThis & { Bun?: { gc(force?: boolean): void } }).Bun; + if (!bun) throw new Error('Windows SDK fixture requires Bun.gc(true).'); + bun.gc(true); +} async function firstSession(sessions: FakeAgentSession[]): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { if (sessions[0]) return sessions[0]; @@ -1386,6 +1397,7 @@ async function identityFixture() { closeModelCache(modelCachePath); authStorage.close(); await settings.close(); + collectWindowsSqliteHandles(); await rm(root, { recursive: true, force: true }); }, }; @@ -1617,6 +1629,7 @@ async function rawSdkDelegationFixture() { closeModelCache(modelCachePath); authStorage.close(); await settings.close(); + collectWindowsSqliteHandles(); await rm(root, { recursive: true, force: true }); } }; } diff --git a/src-tauri/src/lifecycle.rs b/src-tauri/src/lifecycle.rs index 89c7c89d..ac701436 100644 --- a/src-tauri/src/lifecycle.rs +++ b/src-tauri/src/lifecycle.rs @@ -10,27 +10,36 @@ 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(any(unix, test))] - fn unmanaged(pid: u32) -> Self { + #[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(pid: u32) -> Self { - Self::unmanaged(pid) + pub fn unix_owned(child: tauri_plugin_shell::process::CommandChild) -> Self { + let pid = child.pid(); + Self { + pid, + child: Mutex::new(Some(child)), + } } #[cfg(windows)] @@ -43,7 +52,22 @@ impl Sidecar { fn stop(&self, force: bool) -> Result<(), String> { #[cfg(unix)] - return signal_sidecar(self.pid, if force { 9 } else { 15 }); + { + 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 @@ -135,10 +159,6 @@ impl SidecarLifecycle { self.current_pid().is_some() } - pub fn may_exit(&self) -> bool { - self.shutdown_complete() - } - pub fn shutdown_complete(&self) -> bool { self.is_shutting_down() && !self.has_sidecar() } @@ -173,11 +193,6 @@ impl SidecarLifecycle { } } - /// Signal only the currently tracked child, while Retry cannot replace it. - pub(crate) fn terminate(&self, expected_pid: u32) -> Result<(), String> { - self.stop(expected_pid, false) - } - pub fn reap_if_stopped(&self, pid: u32) -> bool { let mut sidecar = self .sidecar @@ -195,8 +210,8 @@ impl SidecarLifecycle { } } - async fn wait_for_exit(&self) -> Result<(), String> { - tokio::time::timeout(SHUTDOWN_TIMEOUT, async { + async fn wait_for_exit(&self, timeout: Duration) -> Result<(), String> { + tokio::time::timeout(timeout, async { loop { // Register before checking durable state: exit can happen // before this wait begins or between the check and await. @@ -222,21 +237,32 @@ impl SidecarLifecycle { self.reap_if_stopped(pid) } - pub async fn stop_and_wait(&self, pid: u32) -> Result<(), String> { - // The supervisor drains output concurrently. A closed stdin or failed - // signal skips directly to the bounded force-stop fallback. - if self.stop(pid, false).is_ok() && self.wait_for_exit().await.is_ok() { - return Ok(()); + 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; } - self.stop(pid, true)?; - let deadline = Instant::now() + FORCE_STOP_TIMEOUT; - while Instant::now() < deadline { - if self.reap_if_stopped(pid) { + #[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(()); } - tokio::time::sleep(Duration::from_millis(50)).await; + 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()) } - Err("desktop server tree did not exit after forced shutdown".to_owned()) } } @@ -255,16 +281,6 @@ fn signal_sidecar(pid: u32, signal: i32) -> Result<(), String> { } } -#[cfg(unix)] -pub fn terminate_sidecar(pid: u32) -> Result<(), String> { - signal_sidecar(pid, 15) -} - -#[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" { @@ -281,11 +297,6 @@ pub(crate) fn process_alive(pid: u32) -> bool { } } -#[cfg(not(unix))] -pub(crate) fn process_alive(_pid: u32) -> bool { - false -} - /// macOS Apple-event Quit can bypass ExitRequested in this Tauri version. pub fn blocking_shutdown(app: &AppHandle) { let lifecycle = app.state::(); @@ -293,10 +304,13 @@ pub fn blocking_shutdown(app: &AppHandle) { 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); } } @@ -340,7 +354,7 @@ pub fn graceful_quit(app: AppHandle) { tauri::async_runtime::spawn(async move { let lifecycle = app.state::(); let result = match pid { - Some(pid) => lifecycle.stop_and_wait(pid).await, + Some(pid) => lifecycle.stop_and_wait(pid, SHUTDOWN_TIMEOUT).await, None => Ok(()), }; // Keep the spawn fence, but let another Close/Quit retry a failed @@ -432,10 +446,13 @@ mod tests { 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(); }); } @@ -512,7 +529,7 @@ mod tests { .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 @@ -533,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/supervisor.rs b/src-tauri/src/supervisor.rs index d2d753ac..f6b44acf 100644 --- a/src-tauri/src/supervisor.rs +++ b/src-tauri/src/supervisor.rs @@ -312,7 +312,7 @@ async fn stop_failed_sidecar( lifecycle: &crate::lifecycle::SidecarLifecycle, pid: u32, events: &mut tauri::async_runtime::Receiver, - force_stop: bool, + force_stop: Option Result<(), String>>, grace: Duration, kill_timeout: Duration, ) -> Result<(), String> { @@ -320,12 +320,12 @@ async fn stop_failed_sidecar( if wait_for_sidecar_exit(lifecycle, pid, events, grace).await { return Ok(()); } - if !force_stop { + let Some(force_stop) = force_stop else { return Err(format!( "Desktop server {pid} did not complete graceful shutdown. Retry remains disabled until it exits." )); - } - let kill_error = lifecycle.stop(pid, true).err(); + }; + let kill_error = force_stop().err(); if wait_for_sidecar_exit(lifecycle, pid, events, kill_timeout).await { return Ok(()); } @@ -356,7 +356,7 @@ async fn handle_sidecar_failure( &lifecycle, pid, events, - !was_ready, + (!was_ready).then_some(|| lifecycle.stop(pid, true)), if was_ready { SESSION_STOP_GRACE } else { @@ -374,6 +374,39 @@ async fn handle_sidecar_failure( 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, @@ -530,7 +563,7 @@ pub fn start(app: AppHandle) { #[cfg(unix)] let sidecar_pid = child.pid(); #[cfg(unix)] - let tracked = crate::lifecycle::Sidecar::unix(sidecar_pid); + 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())?; @@ -699,15 +732,19 @@ pub fn start(app: AppHandle) { reset_desktop_readiness(&app); lifecycle.exited(sidecar_pid); if lifecycle.has_sidecar() { - let _ = stop_failed_sidecar( - &lifecycle, + handle_sidecar_failure( + &app, + &window, sidecar_pid, &mut events, - true, - FAILED_STOP_GRACE, - FAILED_KILL_TIMEOUT, + format!( + "Desktop server exited unexpectedly ({status:?}).\n\n{}", + output.text() + ), + false, ) .await; + return; } if !lifecycle.is_shutting_down() { show_error( @@ -921,19 +958,19 @@ 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((crate::lifecycle::Sidecar::unix(child.pid), ()))) + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) .unwrap(); let mut stopping = Box::pin(stop_failed_sidecar( &lifecycle, child.pid, &mut events, - true, + Some(|| child.kill()), Duration::from_millis(150), Duration::from_secs(2), )); @@ -951,10 +988,10 @@ mod tests { .unwrap(); assert_eq!(child.status().signal(), Some(9)); assert!(!crate::lifecycle::process_alive(child.pid)); - let (retry, mut retry_events) = TestChild::spawn(false, true); + let (mut retry, mut retry_events) = TestChild::spawn(false, true); assert_eq!( lifecycle - .start(|| Ok((crate::lifecycle::Sidecar::unix(retry.pid), ()))) + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(retry.pid), ()))) .unwrap(), Some(()) ); @@ -967,7 +1004,7 @@ mod tests { &lifecycle, retry.pid, &mut retry_events, - false, + Some(|| retry.kill()), Duration::from_secs(2), Duration::from_secs(1), ) @@ -984,7 +1021,7 @@ mod tests { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, true); lifecycle - .start(|| Ok((crate::lifecycle::Sidecar::unix(child.pid), ()))) + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) .unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(child.pid)); assert_eq!(lifecycle.begin_shutdown(), None); @@ -993,7 +1030,7 @@ mod tests { &lifecycle, child.pid, &mut events, - true, + Some(|| child.kill()), Duration::from_millis(50), Duration::from_secs(2), ) @@ -1014,7 +1051,7 @@ mod tests { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, false); lifecycle - .start(|| Ok((crate::lifecycle::Sidecar::unix(child.pid), ()))) + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) .unwrap(); let error = time::timeout( Duration::from_secs(2), @@ -1022,7 +1059,7 @@ mod tests { &lifecycle, child.pid, &mut events, - false, + Some(|| Err("injected kill failure".to_owned())), Duration::from_millis(30), Duration::from_millis(30), ), @@ -1030,7 +1067,7 @@ mod tests { .await .unwrap() .unwrap_err(); - assert!(error.contains("did not complete graceful shutdown")); + assert!(error.contains("injected kill failure")); assert!(lifecycle.has_sidecar()); assert!(crate::lifecycle::process_alive(child.pid)); assert_eq!( @@ -1059,13 +1096,13 @@ mod tests { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, true); lifecycle - .start(|| Ok((crate::lifecycle::Sidecar::unix(child.pid), ()))) + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(child.pid), ()))) .unwrap(); let result = stop_failed_sidecar( &lifecycle, child.pid, &mut events, - false, + None:: Result<(), String>>, Duration::from_millis(30), Duration::from_millis(30), ) @@ -1133,25 +1170,28 @@ mod tests { let pid = child.id(); let lifecycle = crate::lifecycle::SidecarLifecycle::default(); lifecycle - .start(|| Ok((crate::lifecycle::Sidecar::unix(pid), ()))) + .start(|| Ok((crate::lifecycle::Sidecar::unmanaged(pid), ()))) .unwrap(); - let reaper = std::thread::spawn(move || child.wait().unwrap()); let (sender, mut events) = tauri::async_runtime::channel(1); drop(sender); - time::timeout( + let cleanup = time::timeout( Duration::from_secs(3), stop_failed_sidecar( &lifecycle, pid, &mut events, - true, + None:: Result<(), String>>, Duration::from_millis(100), Duration::from_secs(2), ), ) .await - .expect("closed output must not make cleanup loop forever"); - assert!(!reaper.join().unwrap().success()); + .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()); }); } From 472d2ba708473532193f6efab27bfab7f2a655c9 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:34:51 +0900 Subject: [PATCH 15/22] fix(desktop): scope native environment ownership to Windows --- src-tauri/src/supervisor.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/supervisor.rs b/src-tauri/src/supervisor.rs index f6b44acf..6e1275b5 100644 --- a/src-tauri/src/supervisor.rs +++ b/src-tauri/src/supervisor.rs @@ -1,8 +1,9 @@ +#[cfg(windows)] +use std::ffi::OsString; use std::fmt::Write as _; use std::{ collections::VecDeque, env, - ffi::OsString, io::{Read, Write}, net::TcpStream, path::PathBuf, @@ -483,6 +484,7 @@ pub fn start(app: AppHandle) { }; 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()), @@ -502,7 +504,8 @@ pub fn start(app: AppHandle) { .map(|path| path.into_os_string()) .or_else(|| env::var_os("HOME")) .unwrap_or_default(); - environment.push(("HOME".into(), home.clone())); + #[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 = @@ -514,6 +517,7 @@ pub fn start(app: AppHandle) { return; } }; + #[cfg(windows)] environment.push(("PATH".into(), path)); let command = lifecycle.start(|| { reset_desktop_readiness(&app); From 68865fa093490b398d7f278ec3436ffeec5e3a32 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:49:50 +0900 Subject: [PATCH 16/22] test(windows): use native paths and bounded SDK finalization --- native/gajae-core/src/jobs.rs | 15 +++++++++++---- server/gjc-sdk-contract.bun.test.ts | 27 ++++++++++++++++++++------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/native/gajae-core/src/jobs.rs b/native/gajae-core/src/jobs.rs index 0738e594..213e39c4 100644 --- a/native/gajae-core/src/jobs.rs +++ b/native/gajae-core/src/jobs.rs @@ -3253,14 +3253,21 @@ mod tests { 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 @@ -3273,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(); @@ -3332,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/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index c2c3578a..b43004c9 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -347,15 +347,30 @@ 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)!; } -function collectWindowsSqliteHandles(): void { - if (process.platform !== 'win32') return; +async function removeRealSdkFixture(root: string): Promise { + if (process.platform !== 'win32') { + await rm(root, { recursive: true, force: true }); + return; + } // Bun 1.4.0 defers cached SQLite statement finalization beyond the // public Database.close() call. Force finalization only after every fixture // owner has closed its session, registry, cache, auth, and settings handles; // this is required for Windows to release the files before rm(). const bun = (globalThis as typeof globalThis & { Bun?: { gc(force?: boolean): void } }).Bun; if (!bun) throw new Error('Windows SDK fixture requires Bun.gc(true).'); - bun.gc(true); + for (let attempt = 0; ; attempt += 1) { + bun.gc(true); + // Let deferred native finalizers run before removal. Bun 1.4.0 ignores + // fs.rm's retry options, so bound transient Windows cleanup explicitly. + await new Promise((resolve) => setTimeout(resolve, attempt * 50)); + try { + await rm(root, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (attempt === 4 || !['EBUSY', 'EPERM', 'ENOTEMPTY'].includes(code ?? '')) throw error; + } + } } async function firstSession(sessions: FakeAgentSession[]): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { @@ -1397,8 +1412,7 @@ async function identityFixture() { closeModelCache(modelCachePath); authStorage.close(); await settings.close(); - collectWindowsSqliteHandles(); - await rm(root, { recursive: true, force: true }); + await removeRealSdkFixture(root); }, }; } @@ -1629,8 +1643,7 @@ async function rawSdkDelegationFixture() { closeModelCache(modelCachePath); authStorage.close(); await settings.close(); - collectWindowsSqliteHandles(); - await rm(root, { recursive: true, force: true }); + await removeRealSdkFixture(root); } }; } From 9f8622c1c75de6abce87b0e41f6b8801b8fdaa7f Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:19:09 +0900 Subject: [PATCH 17/22] test(windows): canonicalize SDK roots and expose retained owners --- server/gjc-sdk-contract.bun.test.ts | 46 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index b43004c9..6d631295 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, relative } from 'node:path'; import { test } from 'node:test'; @@ -347,29 +347,25 @@ 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 removeRealSdkFixture(root: string): Promise { - if (process.platform !== 'win32') { - await rm(root, { recursive: true, force: true }); - return; - } +async function removeRealSdkFixture(root: string, modelCacheClosed: boolean): Promise { // Bun 1.4.0 defers cached SQLite statement finalization beyond the // public Database.close() call. Force finalization only after every fixture // owner has closed its session, registry, cache, auth, and settings handles; // this is required for Windows to release the files before rm(). - const bun = (globalThis as typeof globalThis & { Bun?: { gc(force?: boolean): void } }).Bun; - if (!bun) throw new Error('Windows SDK fixture requires Bun.gc(true).'); - for (let attempt = 0; ; attempt += 1) { + if (process.platform === 'win32') { + const bun = (globalThis as typeof globalThis & { Bun?: { gc(force?: boolean): void } }).Bun; + if (!bun) throw new Error('Windows SDK fixture requires Bun.gc(true).'); bun.gc(true); - // Let deferred native finalizers run before removal. Bun 1.4.0 ignores - // fs.rm's retry options, so bound transient Windows cleanup explicitly. - await new Promise((resolve) => setTimeout(resolve, attempt * 50)); - try { - await rm(root, { recursive: true, force: true }); - return; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (attempt === 4 || !['EBUSY', 'EPERM', 'ENOTEMPTY'].includes(code ?? '')) throw error; - } + await new Promise((resolve) => setTimeout(resolve, 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 }); } } async function firstSession(sessions: FakeAgentSession[]): Promise { @@ -1326,7 +1322,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'); const modelCachePath = join(agentDir, 'models.db'); @@ -1409,10 +1407,10 @@ async function identityFixture() { // ModelRegistry.dispose() cancels discovery but the SDK's shared SQLite // model cache is an independent @gajae-code/ai resource. Close the exact // cache owned by this fixture before removing its temporary agent root. - closeModelCache(modelCachePath); + const modelCacheClosed = closeModelCache(modelCachePath); authStorage.close(); await settings.close(); - await removeRealSdkFixture(root); + await removeRealSdkFixture(root, modelCacheClosed); }, }; } @@ -1640,10 +1638,10 @@ async function rawSdkDelegationFixture() { await registry.dispose(); // ModelRegistry.dispose() does not own the shared @gajae-code/ai cache // handle; release this fixture's exact database before deleting its root. - closeModelCache(modelCachePath); + const modelCacheClosed = closeModelCache(modelCachePath); authStorage.close(); await settings.close(); - await removeRealSdkFixture(root); + await removeRealSdkFixture(root, modelCacheClosed); } }; } From b2e134c7adf1f44a32e0a14355b3e52d3aa7a3a8 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:11:04 +0900 Subject: [PATCH 18/22] fix(gjc): own embedded SDK settings and control endpoints --- scripts/run-windows-tests.mjs | 15 +- server/GJC-LIVE-SPEC.md | 12 ++ server/gjc-bun-sdk-adapter.ts | 52 ++++++- server/gjc-delegation-executor.bun.test.ts | 148 ++++++++++++++++++-- server/gjc-delegation-executor.ts | 21 ++- server/gjc-sdk-contract.bun.test.ts | 152 ++++++++++++++++----- server/gjc-sdk-fixture-cleanup.ts | 33 +++++ 7 files changed, 378 insertions(+), 55 deletions(-) create mode 100644 server/gjc-sdk-fixture-cleanup.ts diff --git a/scripts/run-windows-tests.mjs b/scripts/run-windows-tests.mjs index 524a178f..845423e4 100644 --- a/scripts/run-windows-tests.mjs +++ b/scripts/run-windows-tests.mjs @@ -66,9 +66,20 @@ try { if (await versionOf(bun) !== BUN_VERSION) { throw new Error(`Bun ${BUN_VERSION} is required; run node scripts/fetch-bun.mjs.`); } - const result = spawnSync(bun, ['test', 'server/gjc-sdk-contract.bun.test.ts'], { + // 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); + const result = spawnSync(bun, [ + 'test', 'server/gjc-sdk-contract.bun.test.ts', 'server/gjc-delegation-executor.bun.test.ts', + ], { cwd: root, - env, + env: bunEnv, stdio: ['ignore', 'inherit', 'inherit'], }); if (result.error) throw result.error; diff --git a/server/GJC-LIVE-SPEC.md b/server/GJC-LIVE-SPEC.md index 785757ed..7dc6482f 100644 --- a/server/GJC-LIVE-SPEC.md +++ b/server/GJC-LIVE-SPEC.md @@ -134,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: 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-delegation-executor.bun.test.ts b/server/gjc-delegation-executor.bun.test.ts index 8770c931..2aaa1c56 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,22 @@ 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); + } + for (const cleanup of [ + ...[...new Set([...allRoots, ...children])].map((session) => () => session.dispose()), + () => 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 +229,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 +245,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 +253,115 @@ 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('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 +1093,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 +1186,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 +1280,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 +1417,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-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 6d631295..e98248ea 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, relative } from 'node:path'; import { test } from 'node:test'; @@ -12,11 +12,10 @@ import { Settings } from '@gajae-code/coding-agent/config/settings'; import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; import { AsyncJobManager } from '@gajae-code/coding-agent/async/job-manager'; import { registerCustomApi, unregisterCustomApis } from '@gajae-code/ai/api-registry'; -import { closeModelCache } from '@gajae-code/ai/model-cache'; 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, @@ -133,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(); @@ -204,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 { @@ -299,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; @@ -312,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, @@ -347,27 +351,6 @@ 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 removeRealSdkFixture(root: string, modelCacheClosed: boolean): Promise { - // Bun 1.4.0 defers cached SQLite statement finalization beyond the - // public Database.close() call. Force finalization only after every fixture - // owner has closed its session, registry, cache, auth, and settings handles; - // this is required for Windows to release the files before rm(). - if (process.platform === 'win32') { - const bun = (globalThis as typeof globalThis & { Bun?: { gc(force?: boolean): void } }).Bun; - if (!bun) throw new Error('Windows SDK fixture requires Bun.gc(true).'); - bun.gc(true); - await new Promise((resolve) => setTimeout(resolve, 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 }); - } -} async function firstSession(sessions: FakeAgentSession[]): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { if (sessions[0]) return sessions[0]; @@ -1327,7 +1310,6 @@ async function identityFixture() { const root = await realpath(await mkdtemp(join(tmpdir(), 'gjc-sdk-identity-'))); const cwd = join(root, 'project'); const agentDir = join(root, 'agent'); - const modelCachePath = join(agentDir, 'models.db'); await mkdir(cwd); const authStorage = await discoverAuthStorage(agentDir); const settings = await Settings.loadForScope({ cwd, agentDir }); @@ -1404,13 +1386,9 @@ async function identityFixture() { for (const session of sessions) await session.dispose(); await host.close(); await registry.dispose(); - // ModelRegistry.dispose() cancels discovery but the SDK's shared SQLite - // model cache is an independent @gajae-code/ai resource. Close the exact - // cache owned by this fixture before removing its temporary agent root. - const modelCacheClosed = closeModelCache(modelCachePath); authStorage.close(); await settings.close(); - await removeRealSdkFixture(root, modelCacheClosed); + await removeSdkFixture(root); }, }; } @@ -1597,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(); } }); @@ -1607,7 +1587,6 @@ async function rawSdkDelegationFixture() { const root = await mkdtemp(join(scratch, 'raw-sdk-delegation-')); const cwd = join(root, 'project'); const agentDir = join(root, 'agent'); - const modelCachePath = join(agentDir, 'models.db'); await mkdir(cwd); const authStorage = await discoverAuthStorage(agentDir); const settings = await Settings.loadForScope({ cwd, agentDir }); @@ -1630,18 +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(); - // ModelRegistry.dispose() does not own the shared @gajae-code/ai cache - // handle; release this fixture's exact database before deleting its root. - const modelCacheClosed = closeModelCache(modelCachePath); authStorage.close(); await settings.close(); - await removeRealSdkFixture(root, modelCacheClosed); + await removeSdkFixture(root); } }; } @@ -1815,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( @@ -2275,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 { 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 }); + } +} From 6edef4a4cde816fc82095230b18eea0444f0a55b Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:20:00 +0900 Subject: [PATCH 19/22] test(gjc): classify SDK fixture support in engine manifest --- scripts/run-windows-tests.mjs | 1 + server/gjc-engine-manifest.json | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/run-windows-tests.mjs b/scripts/run-windows-tests.mjs index 845423e4..ba889407 100644 --- a/scripts/run-windows-tests.mjs +++ b/scripts/run-windows-tests.mjs @@ -17,6 +17,7 @@ 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', 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", From 11bee00d193ad39aa73d5e863ba286323a90e377 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:06:15 +0900 Subject: [PATCH 20/22] test(windows): isolate pinned SDK file lock failures --- scripts/probe-windows-sdk-locks.mjs | 85 +++++++++++++++++++++++++++++ scripts/run-windows-tests.mjs | 23 +++++--- 2 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 scripts/probe-windows-sdk-locks.mjs 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/run-windows-tests.mjs b/scripts/run-windows-tests.mjs index ba889407..08decc67 100644 --- a/scripts/run-windows-tests.mjs +++ b/scripts/run-windows-tests.mjs @@ -76,15 +76,20 @@ try { if (key.toLowerCase() === 'path') delete bunEnv[key]; } bunEnv.PATH = [path.dirname(bun), previousPath].filter(Boolean).join(path.delimiter); - const result = spawnSync(bun, [ - 'test', 'server/gjc-sdk-contract.bun.test.ts', 'server/gjc-delegation-executor.bun.test.ts', - ], { - cwd: root, - env: bunEnv, - stdio: ['ignore', 'inherit', 'inherit'], - }); - if (result.error) throw result.error; - exitCode = result.status ?? 1; + 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 }); From 3a9506f6cf02215b747c80afaee92b45a46978c3 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:22:20 +0900 Subject: [PATCH 21/22] test(gjc): retain fixtures after unconfirmed SDK disposal --- docs/WINDOWS-DESKTOP.md | 41 ++++++++++++++++++++-- server/gjc-delegation-executor.bun.test.ts | 37 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/docs/WINDOWS-DESKTOP.md b/docs/WINDOWS-DESKTOP.md index dc44562e..9193e12f 100644 --- a/docs/WINDOWS-DESKTOP.md +++ b/docs/WINDOWS-DESKTOP.md @@ -10,8 +10,9 @@ 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`. -No Windows build, CI result, or interactive acceptance result for this merged -source is claimed below; the verification record is explicitly historical. +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 @@ -77,6 +78,11 @@ The `Windows desktop` workflow in `.github/workflows/windows.yml` runs on 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. @@ -118,6 +124,37 @@ validated public release: 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 diff --git a/server/gjc-delegation-executor.bun.test.ts b/server/gjc-delegation-executor.bun.test.ts index 2aaa1c56..245f43d5 100644 --- a/server/gjc-delegation-executor.bun.test.ts +++ b/server/gjc-delegation-executor.bun.test.ts @@ -183,8 +183,17 @@ async function fixture( 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 [ - ...[...new Set([...allRoots, ...children])].map((session) => () => session.dispose()), () => registry.dispose(), () => authStorage.close(), () => settings.close(), @@ -316,6 +325,32 @@ test('delegated settings flush completes before the child becomes reusable', { t } }); +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) => { From 044fe8dda8c5b384dc48a788a572bdc24884a74c Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:31:26 +0900 Subject: [PATCH 22/22] docs(windows): record paused integration handoff --- docs/V2-SESSION-HANDOFF.md | 113 +++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) 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;