diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 2dd97162..b14af9da 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -317,15 +317,98 @@ jobs: - name: Verify bundled Node resource (Electron Linux) run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@linux-x64 + - name: Verify Electron portable archive + run: | + set -euo pipefail + shopt -s nullglob + archives=(packages/electron-app/release/*.tar.gz) + if [ "${#archives[@]}" -ne 1 ]; then + echo "Expected one Electron Linux tar.gz, found ${#archives[@]}" >&2 + exit 1 + fi + + extract_dir=$(mktemp -d) + trap 'rm -rf "$extract_dir"' EXIT + tar -xzf "${archives[0]}" -C "$extract_dir" + + binary=$(find "$extract_dir" -type f -name CodeNomad -print -quit) + if [ -z "$binary" ] || [ ! -x "$binary" ]; then + echo "Electron portable archive does not contain an executable CodeNomad binary" >&2 + exit 1 + fi + + resources_dir="$(dirname "$binary")/resources" + node scripts/smoke-packaged-resources.cjs \ + --resources "$resources_dir" \ + --target linux-x64 + + app_asar="$resources_dir/app.asar" + if [ ! -f "$app_asar" ]; then + echo "Electron portable archive does not contain resources/app.asar" >&2 + exit 1 + fi + node - "$app_asar" <<'NODE' + const asar = require("@electron/asar") + const path = require("node:path") + const archive = process.argv[2] + const files = asar.listPackage(archive).map((file) => + file.replace(/^[/\\]/, "").replaceAll("\\", "/"), + ) + const fileSet = new Set(files) + const loadingPath = "dist/renderer/loading.html" + if (!fileSet.has(loadingPath)) { + throw new Error("Electron app.asar is missing dist/renderer/loading.html") + } + if (!files.some((file) => file.startsWith("dist/renderer/assets/") && !file.endsWith("/"))) { + throw new Error("Electron app.asar does not contain renderer assets") + } + const html = asar.extractFile(archive, loadingPath).toString("utf8") + for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) { + const reference = match[1] + if (/^(?:[a-z]+:|#)/i.test(reference)) continue + const cleanReference = reference.split(/[?#]/, 1)[0] + const target = cleanReference.startsWith("/") + ? cleanReference.slice(1) + : path.posix.normalize(path.posix.join(path.posix.dirname(loadingPath), cleanReference)) + if (!fileSet.has(target)) { + throw new Error(`Electron app.asar is missing renderer reference ${target}`) + } + } + NODE + + ldd "$binary" | tee /tmp/codenomad-electron-ldd.txt + if grep -q "not found" /tmp/codenomad-electron-ldd.txt; then + echo "Electron portable binary has unresolved shared libraries" >&2 + exit 1 + fi + - name: Upload release assets if: ${{ inputs.upload && inputs.tag != '' }} run: | set -euo pipefail shopt -s nullglob - for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do - [ -f "$file" ] || continue - echo "Uploading $file" - gh release upload "$TAG" "$file" --clobber + files=(packages/electron-app/release/*.tar.gz) + if [ "${#files[@]}" -ne 1 ]; then + echo "Expected one Electron Linux release asset, found ${#files[@]}" >&2 + exit 1 + fi + + file="${files[0]}" + asset_name=$(basename "$file") + echo "Uploading $file" + gh release upload "$TAG" "$file" --clobber + if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then + echo "Uploaded Electron Linux asset is missing from release: $asset_name" >&2 + exit 1 + fi + + gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do + case "$asset" in + CodeNomad-Electron-linux-*.zip|CodeNomad-Electron-linux-*.AppImage|CodeNomad-Electron-linux-*.flatpak) + echo "Deleting obsolete Linux asset $asset" + gh release delete-asset "$TAG" "$asset" --yes + ;; + esac done - name: Upload Actions artifacts (Electron Linux) @@ -333,9 +416,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux - path: | - packages/electron-app/release/*.zip - packages/electron-app/release/*.AppImage + path: packages/electron-app/release/*.tar.gz retention-days: ${{ inputs.actions_artifacts_retention_days }} if-no-files-found: error @@ -364,6 +445,9 @@ jobs: if: ${{ inputs.set_versions && inputs.version != '' }} run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -451,6 +535,9 @@ jobs: if: ${{ inputs.set_versions && inputs.version != '' }} run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -539,6 +626,9 @@ jobs: run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version shell: bash + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -643,6 +733,9 @@ jobs: if: ${{ inputs.set_versions && inputs.version != '' }} run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -669,53 +762,135 @@ jobs: echo "Tauri CLI failed to load after retries" >&2 exit 1 - - name: Build Linux bundle (Tauri) + - name: Build Debian package (Tauri) working-directory: packages/tauri-app - run: npm exec -- tauri build + run: npm exec -- tauri build --bundles deb - - name: Package Tauri artifacts (Linux) + - name: Package and verify Tauri deb if: ${{ inputs.upload || inputs.upload_actions_artifacts }} run: | set -euo pipefail - SEARCH_ROOT="packages/tauri-app/target" + BUNDLE_ROOT="packages/tauri-app/target/release/bundle/deb" ARTIFACT_DIR="packages/tauri-app/release-tauri" + VERSION_TO_USE="${VERSION:-}" + if [ -z "$VERSION_TO_USE" ]; then + VERSION_TO_USE=$(node -p "require('./packages/tauri-app/package.json').version") + fi rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" - shopt -s nullglob globstar + shopt -s nullglob - find_one() { - find "$SEARCH_ROOT" -type f -iname "$1" | head -n1 - } - - appimage=$(find_one "*.AppImage") - fallback_bin="$SEARCH_ROOT/release/codenomad-tauri" - - if [ -z "$appimage" ] || [ ! -f "$fallback_bin" ]; then - echo "Missing bundle(s): appimage=${appimage:-none} binary=$fallback_bin" >&2 + debs=("$BUNDLE_ROOT"/*.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "Expected one Tauri deb under $BUNDLE_ROOT, found ${#debs[@]}" >&2 exit 1 fi - cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.AppImage" - zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.zip" "$fallback_bin" + artifact="$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION_TO_USE}.deb" + cp "${debs[0]}" "$artifact" + dpkg-deb --info "$artifact" + dpkg-deb --contents "$artifact" + package_version=$(dpkg-deb --field "$artifact" Version) + if [ "$package_version" != "$VERSION_TO_USE" ]; then + echo "Tauri deb version mismatch: expected $VERSION_TO_USE, found $package_version" >&2 + exit 1 + fi + + extract_dir=$(mktemp -d) + trap 'rm -rf "$extract_dir"' EXIT + dpkg-deb --extract "$artifact" "$extract_dir" + + binary="$extract_dir/usr/bin/codenomad-tauri" + resources_dir="$extract_dir/usr/lib/CodeNomad/resources" + desktop_file="$extract_dir/usr/share/applications/CodeNomad.desktop" + for required in "$binary" "$resources_dir" "$desktop_file"; do + if [ ! -e "$required" ]; then + echo "Missing path in Tauri deb: $required" >&2 + exit 1 + fi + done + + grep -Fx "Exec=codenomad-tauri" "$desktop_file" + if grep -Fxq "NoDisplay=true" "$desktop_file"; then + echo "Generated Tauri desktop entry must be visible" >&2 + exit 1 + fi + node scripts/smoke-packaged-resources.cjs \ + --resources "$resources_dir" \ + --loading "$resources_dir/ui-loading" \ + --target linux-x64 + + ldd "$binary" | tee /tmp/codenomad-tauri-ldd.txt + if grep -q "not found" /tmp/codenomad-tauri-ldd.txt; then + echo "Tauri deb binary has unresolved shared libraries" >&2 + exit 1 + fi + + - name: Verify Tauri deb installation on Ubuntu 24.04 + if: ${{ inputs.upload || inputs.upload_actions_artifacts }} + run: | + set -euo pipefail + shopt -s nullglob + debs=(packages/tauri-app/release-tauri/*.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "Expected one packaged Tauri deb, found ${#debs[@]}" >&2 + exit 1 + fi + + artifact=$(realpath "${debs[0]}") + docker run --rm \ + --volume "$artifact:/tmp/codenomad.deb:ro" \ + ubuntu:24.04 \ + bash -euc ' + export DEBIAN_FRONTEND=noninteractive + apt-get update >/dev/null + apt-get install -y /tmp/codenomad.deb >/dev/null + test -x /usr/bin/codenomad-tauri + test -x /usr/lib/CodeNomad/resources/node/linux-x64/bin/node + test -f /usr/lib/CodeNomad/resources/server/dist/bin.js + test -f /usr/lib/CodeNomad/resources/ui-loading/loading.html + /usr/lib/CodeNomad/resources/node/linux-x64/bin/node \ + /usr/lib/CodeNomad/resources/server/dist/bin.js --version + ldd /usr/bin/codenomad-tauri | tee /tmp/codenomad-tauri-ldd.txt + ! grep -q "not found" /tmp/codenomad-tauri-ldd.txt + ' - name: Upload Actions artifacts (Tauri Linux) if: ${{ inputs.upload_actions_artifacts }} uses: actions/upload-artifact@v4 with: name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux - path: packages/tauri-app/release-tauri/* + path: packages/tauri-app/release-tauri/*.deb retention-days: ${{ inputs.actions_artifacts_retention_days }} - if-no-files-found: warn + if-no-files-found: error - name: Upload Tauri release assets (Linux) if: ${{ inputs.upload && inputs.tag != '' }} run: | set -euo pipefail shopt -s nullglob - for file in packages/tauri-app/release-tauri/*; do - [ -f "$file" ] || continue - echo "Uploading $file" - gh release upload "$TAG" "$file" --clobber + files=(packages/tauri-app/release-tauri/*.deb) + if [ "${#files[@]}" -ne 1 ]; then + echo "Expected one Tauri Linux release asset, found ${#files[@]}" >&2 + exit 1 + fi + + file="${files[0]}" + asset_name=$(basename "$file") + echo "Uploading $file" + gh release upload "$TAG" "$file" --clobber + if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then + echo "Uploaded Tauri Linux asset is missing from release: $asset_name" >&2 + exit 1 + fi + + gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do + case "$asset" in + CodeNomad-Tauri-linux-*.zip|CodeNomad-Tauri-linux-*.AppImage|CodeNomad-Tauri-linux-*.rpm|CodeNomad-Tauri-linux-*.flatpak) + echo "Deleting obsolete Linux asset $asset" + gh release delete-asset "$TAG" "$asset" --yes + ;; + esac done build-tauri-linux-arm64: @@ -772,6 +947,9 @@ jobs: - name: Set workspace versions run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional diff --git a/.github/workflows/comment-pr-artifacts.yml b/.github/workflows/comment-pr-artifacts.yml index 1b308d62..e6383bcf 100644 --- a/.github/workflows/comment-pr-artifacts.yml +++ b/.github/workflows/comment-pr-artifacts.yml @@ -93,6 +93,11 @@ jobs: return; } + if (matchedRun.conclusion !== 'success') { + core.setFailed(`PR Build Validation run ${matchedRun.id} concluded ${matchedRun.conclusion}.`); + return; + } + const artifacts = await github.paginate( github.rest.actions.listWorkflowRunArtifacts, { owner, repo, run_id: matchedRun.id, per_page: 100 } diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index cf8a11a2..9ab747b1 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -46,7 +46,10 @@ jobs: fi build: - needs: authorize + needs: + - authorize + - tests + - tests-tauri-windows if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} uses: ./.github/workflows/build-and-upload.yml with: @@ -56,3 +59,119 @@ jobs: actions_artifacts_retention_days: 7 actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}- set_versions: false + + tests: + needs: authorize + if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux test dependencies (Tauri) + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libgtk-3-dev \ + libglib2.0-dev \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + + - name: Install dependencies + run: npm ci + + - name: Typecheck desktop clients + run: npm run typecheck + + - name: Test Electron client state + run: npm run test:native --workspace @neuralnomads/codenomad-electron-app + + - name: Test changed runnable UI behavior + run: >- + node --import tsx --test + packages/ui/src/components/session-list-visibility.test.ts + packages/ui/src/lib/hooks/use-app-session-capture.test.ts + packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/trailing-resync.test.ts + packages/ui/src/stores/abort-created-workspace-cleanup.test.ts + packages/ui/src/stores/app-session-reconciliation.test.ts + packages/ui/src/stores/app-session-restore-gate.test.ts + packages/ui/src/stores/app-session-restore-queue.test.ts + packages/ui/src/stores/app-session-restore-timeout.test.ts + packages/ui/src/stores/app-session-snapshot-merge.test.ts + packages/ui/src/stores/restore-workspace-commit-gates.test.ts + packages/ui/src/stores/client-state-codec.test.ts + packages/ui/src/stores/client-state.test.ts + packages/ui/src/stores/instances-restore-cancellation.test.ts + packages/ui/src/stores/message-v2/message-hydration-authority.test.ts + packages/ui/src/stores/session-generation-recovery.test.ts + packages/ui/src/stores/session-metadata.test.ts + packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts + + - name: Test restore ownership integration + run: >- + node --conditions=browser --import tsx --test --test-force-exit + packages/ui/src/stores/instances-restore-ownership.test.ts + packages/ui/src/stores/permission-lifecycle.test.ts + + - name: Test server + run: node --import tsx --test "packages/server/src/**/*.test.ts" + + - name: Prepare Tauri test resources + run: >- + npm run dev:prep --workspace @codenomad/tauri-app && + node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})" + + - name: Test Tauri crate + working-directory: packages/tauri-app/src-tauri + run: cargo test --locked + + tests-tauri-windows: + needs: authorize + if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Test Windows server spawn behavior + run: node --import tsx --test packages/server/src/workspaces/__tests__/spawn.test.ts + + - name: Prepare Tauri test resources + run: >- + npm run dev:prep --workspace @codenomad/tauri-app && + node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})" + + - name: Test Tauri crate on Windows + working-directory: packages/tauri-app/src-tauri + run: cargo test --locked diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 286d32d8..2aa5a42d 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references. 1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated` - Events are pushed through the instance event stream -2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler - - **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true - - **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - **Action:** Automatically calls `Permission.reply()`, skips modal display - - **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()` +2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled) + - **File:** `packages/server/src/permissions/auto-accept-manager.ts` + - **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup + - **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes +3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` + - **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately - **Branch:** ELSE (normal flow) - **Mechanism:** Permission queued in `permissionQueues` signal - **Action:** Display approval modal diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index eda86515..3f5d4321 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync: 1. **Server emits** `permission.asked` or `permission.updated` SSE event - Pushed through instance event stream -2. **UI Store receives** via `serverEvents` +2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled) + - File: `packages/server/src/permissions/auto-accept-manager.ts` + - Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change + - Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI +3. **UI Store receives** via `serverEvents` - File: `packages/ui/src/stores/instances.ts` - - **Branch:** IF `isPermissionAutoAcceptEnabled()` - - Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - Action: Calls reply immediately, skips modal - - **Branch:** ELSE - - Mechanism: Queued in `permissionQueues` - - Action: Display modal -3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` -4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays -5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` -6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` -7. **Optimistic Update:** `removePermissionV2()` in bridge -8. **SSE Confirmation:** `permission.replied` event + - **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue + - **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal +4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` +5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays +6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` +7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` +8. **Optimistic Update:** `removePermissionV2()` in bridge +9. **SSE Confirmation:** `permission.replied` event - **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect ## Session Event Handling diff --git a/BUILD.md b/BUILD.md index 0ae1909f..60c2415a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -56,14 +56,14 @@ bun run build:win-arm64 ### Linux ```bash -# x64 (64-bit) -bun run build:linux +# Portable Electron archive (x64) +npm run build:linux --workspace @neuralnomads/codenomad-electron-app -# ARM64 -bun run build:linux-arm64 +# Tauri Debian package (x64) +npm exec --workspace @codenomad/tauri-app -- tauri build --bundles deb ``` -**Output formats:** `.AppImage`, `.deb`, `.tar.gz` +**Release formats:** Electron `.tar.gz` portable archive and Tauri `.deb` installer. ### Build All Platforms @@ -83,27 +83,29 @@ The build script performs these steps: ## Output -Binaries are generated in the `release/` directory: +Build artifacts are generated in package-specific output directories: ``` -release/ -├── CodeNomad-0.1.0-mac-universal.dmg -├── CodeNomad-0.1.0-mac-universal.zip -├── CodeNomad-0.1.0-win-x64.exe -├── CodeNomad-0.1.0-linux-x64.AppImage -└── ... +packages/electron-app/release/ +└── CodeNomad-Electron-linux-x64-0.18.0.tar.gz + +packages/tauri-app/target/release/bundle/deb/ +└── CodeNomad_0.18.0_amd64.deb ``` ## File Naming Convention ``` -CodeNomad-{version}-{os}-{arch}.{ext} +CodeNomad-Electron-{os}-{arch}-{version}.{ext} +CodeNomad-Tauri-{os}-{arch}-{version}.{ext} ``` -- **version**: From package.json (e.g., `0.1.0`) -- **os**: `mac`, `win`, `linux` +- **version**: From package.json (e.g., `0.18.0`) +- **os**: `macos`, `windows`, `linux` - **arch**: `x64`, `arm64`, `universal` -- **ext**: `dmg`, `zip`, `exe`, `AppImage`, `deb`, `tar.gz` +- **ext**: `dmg`, `zip`, `exe`, `deb`, `tar.gz` + +The Tauri build directory uses Tauri's native Debian filename. CI renames the package to the convention above when preparing release assets. ## Platform Requirements @@ -121,9 +123,9 @@ CodeNomad-{version}-{os}-{arch}.{ext} ### Linux -- **Build on:** Any platform -- **Run on:** Ubuntu 18.04+, Debian 10+, Fedora 32+, Arch -- **Dependencies:** Varies by distro +- **Build on:** Linux x64 +- **Electron portable:** extract the tar.gz and run the `CodeNomad` executable +- **Tauri deb:** built and installation-tested on Ubuntu 24.04; older distributions are not yet guaranteed ## Troubleshooting @@ -136,13 +138,7 @@ xcode-select --install ### Build fails on Linux -```bash -# Install dependencies (Debian/Ubuntu) -sudo apt-get install -y rpm - -# Install dependencies (Fedora) -sudo dnf install -y rpm-build -``` +Install the Electron and Tauri build dependencies documented by their upstream projects. Release builds currently target Linux x64 and produce an Electron portable archive plus a Tauri Debian package. ### "electron-builder not found" diff --git a/README.md b/README.md index 8da35441..946ce8cc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ Download the latest installer for your platform from [Releases](https://github.c |----------|---------| | macOS | DMG, ZIP (Universal: Intel + Apple Silicon) | | Windows | NSIS Installer, ZIP (x64, ARM64) | -| Linux | AppImage, deb, tar.gz (x64, ARM64) | +| Linux | Tauri deb, Electron portable tar.gz (x64) | + +The Tauri deb is currently built and installation-tested on Ubuntu 24.04. Compatibility with older Debian-based distributions is not yet guaranteed. ### 💻 CodeNomad Server @@ -169,7 +171,7 @@ On Intel Macs, also check **System Settings → Privacy & Security** on first la WebKitGTK DMA-BUF/GBM issue. Run with: ```bash -WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad +WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad-tauri ``` See full workaround in the original README. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..9043845f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,28 @@ +# Third-Party Notices + +## OpenChamber + +The provider usage adapters are derived from OpenChamber: +https://github.com/openchamber/openchamber + +MIT License + +Copyright (c) 2025 Bohdan Triapitsyn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 5d7e6d68..bbfc6443 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -584,7 +584,7 @@ npm run package # Create distributable - macOS: DMG + auto-update - Windows: NSIS installer + auto-update -- Linux: AppImage + deb/rpm +- Linux: Electron portable tar.gz + Tauri deb ## Configuration Files diff --git a/docs/superpowers/specs/2026-07-14-bracket-math-delimiters-design.md b/docs/superpowers/specs/2026-07-14-bracket-math-delimiters-design.md new file mode 100644 index 00000000..7a92b5bc --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-bracket-math-delimiters-design.md @@ -0,0 +1,38 @@ +# Bracket Math Delimiters Design + +## Goal + +Support inline `\(...\)` and display `\[...\]` math in CodeNomad Markdown while preserving existing `$...$` and `$$...$$` behavior. No unrelated Markdown changes. + +## Approach + +Keep `marked-katex-extension` as owner of dollar-delimited math. Add two small parser-native Marked extensions in `packages/ui/src/lib/markdown.ts`: + +- Inline rule: recognize `\(...\)` and render with KaTeX `displayMode: false`. +- Block rule: recognize `\[...\]` and render with KaTeX `displayMode: true`. + +Both rules will use `katex.renderToString()` with the same error policy as the existing integration: `throwOnError: false` and `strict: "ignore"`. Parser-native rules avoid source rewriting, ignore fenced/code content through Marked's normal lexer flow, and preserve unmatched delimiters as plain Markdown text. + +## Integration + +Register the two rules beside the existing `markedKatex(...)` registration in `setupRenderer()`. Existing renderer setup, syntax highlighting, HTML handling, styles, and component APIs remain unchanged. No new runtime dependency is needed because KaTeX is already a direct UI dependency. + +## Edge Cases + +- `\\(` and `\\[` remain escaped literal text rather than opening math. +- Inline math must close with `\)` before a line break. +- Display math may span lines and must close with `\]`. +- Empty or unmatched delimiter pairs remain text. +- Existing dollar delimiter boundary behavior remains controlled by `marked-katex-extension` with `nonStandard: true`. + +## Tests + +Add focused Markdown rendering regression tests covering: + +- `\(x^2\)` renders inline KaTeX. +- `\[x^2\]` renders display KaTeX, including multiline content. +- `$x^2$` and `$$x^2$$` still render. +- Delimiters inside inline and fenced code remain literal. +- Escaped, empty, and unmatched bracket delimiters remain literal. + +Run the repository's confirmed UI test command, UI typecheck, and inspect the final diff. diff --git a/package-lock.json b/package-lock.json index 5d08c142..473c9074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "version": "7.28.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -4295,6 +4296,7 @@ "version": "7.20.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -4396,6 +4398,7 @@ "version": "22.19.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4470,6 +4473,7 @@ "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cac": "^6.7.14", "colorette": "^2.0.20", @@ -4552,6 +4556,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4754,7 +4759,6 @@ "version": "5.3.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -4772,7 +4776,6 @@ "version": "2.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -4793,7 +4796,6 @@ "version": "2.3.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4807,14 +4809,12 @@ "node_modules/archiver-utils/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -5128,7 +5128,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5192,6 +5191,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5682,7 +5682,6 @@ "version": "4.1.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -5812,7 +5811,6 @@ "version": "1.2.2", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -5824,7 +5822,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -6190,6 +6187,7 @@ "version": "24.13.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -6356,7 +6354,6 @@ "version": "24.13.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -6368,7 +6365,6 @@ "version": "10.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -6382,7 +6378,6 @@ "version": "6.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -6394,7 +6389,6 @@ "version": "2.0.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -7129,8 +7123,7 @@ "node_modules/fs-constants": { "version": "1.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "8.1.0", @@ -8347,8 +8340,7 @@ "node_modules/isarray": { "version": "1.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.6", @@ -8398,6 +8390,7 @@ "version": "1.21.7", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8554,7 +8547,6 @@ "version": "1.0.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -8566,7 +8558,6 @@ "version": "2.3.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8580,14 +8571,12 @@ "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8652,26 +8641,22 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.sortby": { "version": "4.7.0", @@ -8683,8 +8668,7 @@ "node_modules/lodash.union": { "version": "4.6.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -8738,6 +8722,7 @@ "node_modules/marked": { "version": "12.0.2", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -9498,6 +9483,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9645,8 +9631,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/process-warning": { "version": "3.0.0", @@ -9895,7 +9880,6 @@ "version": "3.6.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -9909,7 +9893,6 @@ "version": "1.1.3", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -10212,6 +10195,7 @@ "version": "4.52.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10435,6 +10419,7 @@ "node_modules/seroval": { "version": "1.3.2", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -10758,6 +10743,7 @@ "node_modules/solid-js": { "version": "1.9.10", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -10898,7 +10884,6 @@ "version": "1.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -11232,7 +11217,6 @@ "version": "2.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -11425,6 +11409,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11674,6 +11659,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12021,6 +12007,7 @@ "version": "5.4.21", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12879,6 +12866,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13073,6 +13061,7 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -13361,7 +13350,6 @@ "version": "4.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -13375,7 +13363,6 @@ "version": "3.0.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -13395,6 +13382,7 @@ "node_modules/zod": { "version": "3.25.76", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13454,6 +13442,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index bb49f85b..50efd5fa 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.17.0", + "minServerVersion": "0.18.0", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/electron/main/client-state-cross-host-child.ts b/packages/electron-app/electron/main/client-state-cross-host-child.ts new file mode 100644 index 00000000..079032bb --- /dev/null +++ b/packages/electron-app/electron/main/client-state-cross-host-child.ts @@ -0,0 +1,47 @@ +import { existsSync, writeFileSync } from "node:fs" +import { CrossHostRegistration, createCrossHostOwner } from "./client-state-cross-host" +import { getProcessStartIdentity } from "./client-state-process-identity" +import { isPidAlive } from "./client-state-process" +import { ClientStateManager } from "./client-state" + +const [directory, startPath, readyPath, mode, userDataPath, participantReadyPath, participantContinuePath, legacyTauriDataPath, operation, payload] = process.argv.slice(2) +if (!directory || !startPath) throw new Error("Expected election directory and start path") +if (readyPath) writeFileSync(readyPath, "") +while (!existsSync(startPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + +const manager = mode === "full" && userDataPath + ? new ClientStateManager(userDataPath, undefined, { + crossHostElectionDirectory: directory, + legacyTauriDataPath: legacyTauriDataPath || null, + crossHostDependencies: { + pidAlive: isPidAlive, + processStartIdentity: getProcessStartIdentity, + onParticipantPublished: participantReadyPath && participantContinuePath + ? () => { + writeFileSync(participantReadyPath, "") + while (!existsSync(participantContinuePath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + } + : undefined, + }, + }) + : undefined +const owner = manager ? undefined : createCrossHostOwner() +const registration = owner && CrossHostRegistration.register(directory, owner, true, { + pidAlive: mode === "retire-crash" ? () => false : isPidAlive, + processStartIdentity: getProcessStartIdentity, + onOwnerPrepared: mode === "owner-crash" ? () => process.exit(91) : undefined, + onOwnerRetired: mode === "retire-crash" ? () => process.exit(91) : undefined, +}) +if (manager?.isPrimary && operation === "save") { + await manager.setRestoreEnabled(true) + await manager.saveClientState(JSON.parse(payload)) +} +process.stdout.write(`${JSON.stringify({ + acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary), + state: operation === "load" ? manager?.loadClientState() : undefined, +})}\n`) +process.stdin.resume() +process.stdin.once("end", () => { + if (manager) void manager.drainAndReleasePrimary().finally(() => process.exit()) + else registration?.release() +}) diff --git a/packages/electron-app/electron/main/client-state-cross-host.test.ts b/packages/electron-app/electron/main/client-state-cross-host.test.ts new file mode 100644 index 00000000..1d7e0b38 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-cross-host.test.ts @@ -0,0 +1,226 @@ +import assert from "node:assert/strict" +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" +import { once } from "node:events" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, posix, win32 } from "node:path" +import { fileURLToPath } from "node:url" +import test from "node:test" +import { + CrossHostRegistration, + CROSS_HOST_OWNER_DIRECTORY, + resolveCrossHostElectionDirectory, + resolveCrossHostStatePath, + type CrossHostLeaseDependencies, +} from "./client-state-cross-host" +import type { ProcessOwner } from "./client-state-process" + +function temp(t: test.TestContext): string { + const path = mkdtempSync(join(tmpdir(), "codenomad-cross-host-")) + t.after(() => rmSync(path, { recursive: true, force: true })) + return path +} + +function owner(pid: number, token: string, identity = `${token}-start`): ProcessOwner { + return { pid, runToken: token, processStartIdentity: identity } +} + +function dependencies(alive: boolean, identity?: string): CrossHostLeaseDependencies { + return { pidAlive: () => alive, processStartIdentity: () => identity } +} + +function ownerFile(directory: string): string { + return join(directory, CROSS_HOST_OWNER_DIRECTORY, "owner.json") +} + +interface Child { + process: ChildProcessWithoutNullStreams + result: Promise +} + +function child(directory: string, start: string, mode = ""): Child { + const process = spawn(globalThis.process.execPath, [ + "--import", "tsx", fileURLToPath(new URL("./client-state-cross-host-child.ts", import.meta.url)), directory, start, "", mode, + ]) as ChildProcessWithoutNullStreams + process.stdout.setEncoding("utf8"); process.stderr.setEncoding("utf8") + const result = new Promise((resolve, reject) => { + let output = "", errors = "" + process.stdout.on("data", (chunk: string) => { + output += chunk + if (output.includes("\n")) resolve(JSON.parse(output).acquired) + }) + process.stderr.on("data", (chunk: string) => { errors += chunk }) + process.once("error", reject) + process.once("exit", (code) => { if (!output && code !== 91) reject(new Error(`child ${code}: ${errors}`)) }) + }) + return { process, result } +} + +async function stop(...children: Child[]): Promise { + const running = children.filter(({ process }) => process.exitCode === null && process.signalCode === null) + const exits = running.map(({ process }) => once(process, "exit")) + running.forEach(({ process }) => process.stdin.end()) + await Promise.all(exits) +} + +async function waitForExit(child: Child): Promise { + if (child.process.exitCode === null && child.process.signalCode === null) await once(child.process, "exit") +} + +test("simultaneous acquisition across processes yields one owner", async (t) => { + const directory = temp(t), start = join(directory, "start") + const children = Array.from({ length: 4 }, () => child(directory, start)) + try { + writeFileSync(start, "") + assert.equal((await Promise.all(children.map(({ result }) => result))).filter(Boolean).length, 1) + } finally { await stop(...children) } +}) + +test("owner publication crash leaves no visible owner", async (t) => { + const directory = temp(t), start = join(directory, "start") + const crashed = child(directory, start, "owner-crash") + writeFileSync(start, "") + await waitForExit(crashed) + assert.equal(existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY)), false) + const winner = CrossHostRegistration.register(directory, owner(101, "winner"), true, dependencies(true, "winner-start"))! + assert.equal(winner.isPrimary, true) +}) + +test("stale retirement crash cannot retire a successor", async (t) => { + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + mkdirSync(staleDirectory) + writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(4_000_000_000, "stale"))) + const start = join(directory, "start"), crashed = child(directory, start, "retire-crash") + writeFileSync(start, "") + await waitForExit(crashed) + const winner = CrossHostRegistration.register(directory, owner(102, "successor"), true, dependencies(true, "successor-start"))! + assert.equal(winner.isPrimary, true) + assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "successor") +}) + +test("stale recovery is identity guarded and blocked by non-claiming live participants", (t) => { + for (const value of [ + { alive: false, identity: undefined, recover: true }, + { alive: true, identity: "reused", recover: true }, + { alive: true, identity: "old-start", recover: false }, + { alive: true, identity: undefined, recover: false }, + ]) { + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(201, "old", "old-start"))) + const registration = CrossHostRegistration.register(directory, owner(202, "new"), true, dependencies(value.alive, value.identity))! + assert.equal(registration.isPrimary, value.recover) + } + + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(301, "dead"))) + writeFileSync(join(directory, "participant.302.secondary.json"), JSON.stringify(owner(302, "secondary"))) + const identities = new Map([[302, "secondary-start"]]) + const blocked = CrossHostRegistration.register(directory, owner(303, "next"), true, { + pidAlive: (pid) => pid === 302, + processStartIdentity: (pid) => identities.get(pid), + })! + assert.equal(blocked.isPrimary, false) +}) + +test("simultaneous claimants deterministically recover a stale owner", (t) => { + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + const stale = owner(601, "stale"), first = owner(602, "a"), second = owner(603, "b") + mkdirSync(staleDirectory) + const observed = JSON.stringify(stale) + writeFileSync(join(staleDirectory, "owner.json"), observed) + writeFileSync(join(directory, "participant.603.b.json"), JSON.stringify(second)) + writeFileSync(join(directory, "recovery.603.b.claim"), observed) + const identities = new Map([[602, first.processStartIdentity], [603, second.processStartIdentity]]) + const deps = { + pidAlive: (pid: number) => pid !== stale.pid, + processStartIdentity: (pid: number) => identities.get(pid), + } + const winner = CrossHostRegistration.register(directory, first, true, deps)! + const loser = CrossHostRegistration.register(directory, second, true, deps)! + assert.equal(winner.isPrimary, true) + assert.equal(loser.isPrimary, false) +}) + +test("graceful primary release allows a successor while a secondary remains", (t) => { + const directory = temp(t) + const primary = CrossHostRegistration.register(directory, owner(401, "primary"), true, dependencies(true, "primary-start"))! + const secondary = CrossHostRegistration.register(directory, owner(402, "secondary"), true, dependencies(true, "primary-start"))! + assert.equal(secondary.isPrimary, false) + + assert.equal(primary.release(), true) + const successor = CrossHostRegistration.register(directory, owner(403, "successor"), true, dependencies(true, "successor-start"))! + assert.equal(successor.isPrimary, true) +}) + +test("graceful handoff retires the old cohort so a crashed successor can recover", (t) => { + const directory = temp(t), secondaryOwner = owner(422, "secondary"), successorOwner = owner(423, "successor"), lateOwner = owner(425, "late") + const malformed = join(directory, "participant.malformed.json") + const primary = CrossHostRegistration.register(directory, owner(421, "primary"), true, { + pidAlive: () => true, + processStartIdentity: () => "primary-start", + onGracefulOwnerChecked: () => { + writeFileSync(join(directory, "participant.423.successor.json"), JSON.stringify(successorOwner)) + writeFileSync(join(directory, "participant.425.late.json"), JSON.stringify(lateOwner)) + writeFileSync(malformed, "malformed") + }, + onOwnerRetired: () => { + mkdirSync(join(directory, CROSS_HOST_OWNER_DIRECTORY)) + writeFileSync(ownerFile(directory), JSON.stringify(successorOwner)) + }, + })! + CrossHostRegistration.register(directory, secondaryOwner, true, dependencies(true, "primary-start"))! + + primary.release() + assert.equal(readdirSync(directory).some((name) => name.startsWith("retired.")), false) + assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "successor") + assert.equal(existsSync(join(directory, "participant.423.successor.json")), false) + assert.equal(existsSync(join(directory, "participant.425.late.json")), false) + assert.equal(existsSync(malformed), false) + + const claimantOwner = owner(424, "claimant"), identities = new Map([ + [secondaryOwner.pid, secondaryOwner.processStartIdentity], + [lateOwner.pid, lateOwner.processStartIdentity], + [claimantOwner.pid, claimantOwner.processStartIdentity], + ]) + const claimant = CrossHostRegistration.register(directory, claimantOwner, true, { + pidAlive: (pid) => identities.has(pid), + processStartIdentity: (pid) => identities.get(pid), + })! + assert.equal(claimant.isPrimary, true) +}) + +test("non-owner release does not remove a live owner's record", (t) => { + const directory = temp(t) + const primary = CrossHostRegistration.register(directory, owner(411, "primary"), true, dependencies(true, "primary-start"))! + const secondary = CrossHostRegistration.register(directory, owner(412, "secondary"), true, dependencies(true, "primary-start"))! + + assert.equal(secondary.release(), true) + assert.equal(primary.isPrimary, true) + assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "primary") +}) + +test("primary crash remains fenced by its non-claiming secondary cohort", async (t) => { + const directory = temp(t), firstStart = join(directory, "first-start") + const primary = child(directory, firstStart), secondary = child(directory, firstStart) + writeFileSync(firstStart, "") + const roles = await Promise.all([primary.result, secondary.result]) + const winner = roles[0] ? primary : secondary, survivor = roles[0] ? secondary : primary + winner.process.kill() + await waitForExit(winner) + const blocked = CrossHostRegistration.register(directory, owner(501, "blocked"), true)! + assert.equal(blocked.isPrimary, false) + blocked.release() + survivor.process.kill() + await waitForExit(survivor) + const successor = CrossHostRegistration.register(directory, owner(502, "successor"), true)! + assert.equal(successor.isPrimary, true) +}) + +test("platform paths match the Rust contract", () => { + assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "election")) + assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "election")) + assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "election")) + assert.equal(resolveCrossHostStatePath({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "client-state.json")) + assert.equal(resolveCrossHostStatePath({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "client-state.json")) + assert.equal(resolveCrossHostStatePath({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "client-state.json")) +}) diff --git a/packages/electron-app/electron/main/client-state-cross-host.ts b/packages/electron-app/electron/main/client-state-cross-host.ts new file mode 100644 index 00000000..33cbbd82 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-cross-host.ts @@ -0,0 +1,344 @@ +import { randomUUID } from "node:crypto" +import { closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { basename, dirname, join, posix, win32 } from "node:path" +import { getProcessStartIdentity, type ProcessStartIdentityLookup } from "./client-state-process-identity" +import { hasErrorCode, isPidAlive, type ProcessOwner } from "./client-state-process" + +export const CROSS_HOST_OWNER_DIRECTORY = "primary.owner.json" +const OWNER_FILENAME = "owner.json" +const PARTICIPANT_PREFIX = "participant." +const PARTICIPANT_SUFFIX = ".json" +const RECOVERY_PREFIX = "recovery." +const RECOVERY_SUFFIX = ".claim" +const RETIRED_PREFIX = "retired." +const ACQUIRE_ATTEMPTS = 10 + +export interface CrossHostLeaseDependencies { + pidAlive(pid: number): boolean + processStartIdentity: ProcessStartIdentityLookup + onParticipantPublished?(): void + onOwnerPrepared?(): void + onOwnerRetired?(): void + onGracefulOwnerChecked?(): void +} + +const defaultDependencies: CrossHostLeaseDependencies = { + pidAlive: isPidAlive, + processStartIdentity: getProcessStartIdentity, +} + +function validHome(value: string | undefined, platform: NodeJS.Platform): string | undefined { + if (!value) return undefined + if (platform !== "win32") return posix.isAbsolute(value) ? value : undefined + return /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) ? value : undefined +} + +export function resolveCrossHostElectionDirectory( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fallbackHome = homedir(), +): string { + const pathApi = platform === "win32" ? win32 : posix + const configured = platform === "win32" + ? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform) + : validHome(environment.HOME, platform) + return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "election") +} + +export function resolveCrossHostStatePath( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fallbackHome = homedir(), +): string { + const pathApi = platform === "win32" ? win32 : posix + const configured = platform === "win32" + ? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform) + : validHome(environment.HOME, platform) + return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "client-state.json") +} + +export function resolveLegacyTauriDataDirectory( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fallbackHome = homedir(), +): string { + const pathApi = platform === "win32" ? win32 : posix + const home = platform === "win32" + ? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform) ?? fallbackHome + : validHome(environment.HOME, platform) ?? fallbackHome + const root = platform === "win32" + ? validHome(environment.APPDATA, platform) ?? pathApi.join(home, "AppData", "Roaming") + : platform === "darwin" + ? pathApi.join(home, "Library", "Application Support") + : validHome(environment.XDG_DATA_HOME, platform) ?? pathApi.join(home, ".local", "share") + return pathApi.join(root, "ai.neuralnomads.codenomad.client") +} + +export function createCrossHostOwner(): ProcessOwner | undefined { + const processStartIdentity = getProcessStartIdentity(process.pid) + return processStartIdentity ? { pid: process.pid, runToken: randomUUID(), processStartIdentity } : undefined +} + +function serializeOwner(owner: ProcessOwner): string { + return JSON.stringify({ pid: owner.pid, runToken: owner.runToken, processStartIdentity: owner.processStartIdentity }) +} + +function parseOwner(value: string): ProcessOwner | undefined { + try { + const owner = JSON.parse(value) as Partial + if (Number.isInteger(owner.pid) && Number(owner.pid) > 0 && Number(owner.pid) <= 0xffff_ffff && + typeof owner.runToken === "string" && /^[A-Za-z0-9_-]+$/.test(owner.runToken) && + typeof owner.processStartIdentity === "string" && owner.processStartIdentity) { + return { pid: Number(owner.pid), runToken: owner.runToken, processStartIdentity: owner.processStartIdentity } + } + } catch {} + return undefined +} + +function sameOwner(left: ProcessOwner, right: ProcessOwner): boolean { + return left.pid === right.pid && left.runToken === right.runToken && left.processStartIdentity === right.processStartIdentity +} + +function readIfExists(path: string): string | undefined { + try { return readFileSync(path, "utf8") } catch (error) { + if (hasErrorCode(error, "ENOENT")) return undefined + throw error + } +} + +function sync(descriptor: number): void { + try { fsyncSync(descriptor) } catch (error) { + if (!["EINVAL", "ENOTSUP", "ENOSYS"].some((code) => hasErrorCode(error, code))) throw error + } +} + +function publishFile(path: string, value: string): void { + const temporary = join(dirname(path), `.publish.${randomUUID()}.tmp`) + let descriptor: number | undefined + try { + descriptor = openSync(temporary, "wx", 0o600) + writeFileSync(descriptor, value, "utf8") + sync(descriptor) + closeSync(descriptor) + descriptor = undefined + linkSync(temporary, path) + } finally { + if (descriptor !== undefined) try { closeSync(descriptor) } catch {} + try { unlinkSync(temporary) } catch {} + } +} + +function participantPath(directory: string, owner: ProcessOwner): string { + return join(directory, `${PARTICIPANT_PREFIX}${owner.pid}.${owner.runToken}${PARTICIPANT_SUFFIX}`) +} + +function recoveryPath(directory: string, owner: ProcessOwner): string { + return join(directory, `${RECOVERY_PREFIX}${owner.pid}.${owner.runToken}${RECOVERY_SUFFIX}`) +} + +function publishParticipant(path: string, owner: ProcessOwner): void { + const value = serializeOwner(owner) + try { publishFile(path, value) } catch (error) { + if (!hasErrorCode(error, "EEXIST") || readIfExists(path) !== value) throw error + } +} + +function ownerPath(directory: string): string { + return join(directory, CROSS_HOST_OWNER_DIRECTORY, OWNER_FILENAME) +} + +function ownerIsStale(owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean | undefined { + if (!dependencies.pidAlive(owner.pid)) return true + const identity = dependencies.processStartIdentity(owner.pid) + return identity ? identity !== owner.processStartIdentity : undefined +} + +function removeParticipantIfOwned(path: string, owner: ProcessOwner): void { + const observed = readIfExists(path) + const current = observed === undefined ? undefined : parseOwner(observed) + if (!current || !sameOwner(current, owner)) return + try { unlinkSync(path) } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error + } +} + +function retireOwnerIfOwned(directory: string, owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): void { + const observed = readIfExists(ownerPath(directory)) + const current = parseOwner(observed ?? "") + if (!current || !sameOwner(current, owner)) return + dependencies.onGracefulOwnerChecked?.() + if (readIfExists(ownerPath(directory)) !== observed) return + const retired = join(directory, `${RETIRED_PREFIX}${owner.pid}.${owner.runToken}`) + try { renameSync(join(directory, CROSS_HOST_OWNER_DIRECTORY), retired) } catch (error) { + if (["ENOENT", "EEXIST", "ENOTEMPTY"].some((code) => hasErrorCode(error, code)) || existsSync(retired)) return + throw error + } + try { + dependencies.onOwnerRetired?.() + for (const name of readdirSync(directory)) { + if (!name.startsWith(PARTICIPANT_PREFIX) || !name.endsWith(PARTICIPANT_SUFFIX)) continue + const path = join(directory, name), observedParticipant = readIfExists(path) + if (observedParticipant === undefined) continue + const participant = parseOwner(observedParticipant) + if (participant) { + removeParticipantIfOwned(path, participant) + try { unlinkSync(recoveryPath(directory, participant)) } catch {} + } else if (readIfExists(path) === observedParticipant) { + try { unlinkSync(path) } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error + } + } + } + } finally { + try { rmSync(retired, { recursive: true, force: true }) } catch {} + } +} + +function recoveryClaimants( + directory: string, + current: ProcessOwner, + observedOwner: string, + dependencies: CrossHostLeaseDependencies, +): ProcessOwner[] | undefined { + const claimants = [current] + for (const name of readdirSync(directory)) { + if (!name.startsWith(PARTICIPANT_PREFIX) || !name.endsWith(PARTICIPANT_SUFFIX)) continue + const path = join(directory, name) + const participant = parseOwner(readIfExists(path) ?? "") + if (!participant) return undefined + if (sameOwner(participant, current)) continue + const stale = ownerIsStale(participant, dependencies) + if (stale === true) { + removeParticipantIfOwned(path, participant) + try { unlinkSync(recoveryPath(directory, participant)) } catch {} + continue + } + const claimPath = recoveryPath(directory, participant) + let claim = readIfExists(claimPath) + for (let attempt = 0; claim !== observedOwner && attempt < 20; attempt += 1) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + claim = readIfExists(claimPath) + } + if (claim !== observedOwner) return undefined + claimants.push(participant) + } + return claimants +} + +function retireOwner(directory: string, observed: string, owner: ProcessOwner, claimant: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean { + if (ownerIsStale(owner, dependencies) !== true) return false + const claimants = recoveryClaimants(directory, claimant, observed, dependencies) + if (!claimants) return false + claimants.sort((left, right) => serializeOwner(left) < serializeOwner(right) ? -1 : 1) + if (!sameOwner(claimants[0]!, claimant)) return false + if (readIfExists(ownerPath(directory)) !== observed) return false + const retired = join(directory, `${RETIRED_PREFIX}${owner.pid}.${owner.runToken}`) + try { + renameSync(join(directory, CROSS_HOST_OWNER_DIRECTORY), retired) + dependencies.onOwnerRetired?.() + return true + } catch (error) { + if (["ENOENT", "EEXIST", "ENOTEMPTY"].some((code) => hasErrorCode(error, code)) || existsSync(retired)) return false + throw error + } +} + +function publishOwner(directory: string, owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean { + const temporary = join(directory, `.owner.${randomUUID()}.tmp`) + try { + mkdirSync(temporary, { mode: 0o700 }) + const descriptor = openSync(join(temporary, OWNER_FILENAME), "wx", 0o600) + try { writeFileSync(descriptor, serializeOwner(owner), "utf8"); sync(descriptor) } finally { closeSync(descriptor) } + dependencies.onOwnerPrepared?.() + renameSync(temporary, join(directory, CROSS_HOST_OWNER_DIRECTORY)) + return true + } catch (error) { + if (hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOTEMPTY") || existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY))) return false + throw error + } finally { + try { rmSync(temporary, { recursive: true, force: true }) } catch {} + } +} + +export function crossHostParticipants(directory: string): ProcessOwner[] { + try { + return readdirSync(directory) + .filter((name) => name.startsWith(PARTICIPANT_PREFIX) && name.endsWith(PARTICIPANT_SUFFIX)) + .map((name) => parseOwner(readIfExists(join(directory, name)) ?? "")) + .filter((owner): owner is ProcessOwner => Boolean(owner)) + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return [] + throw error + } +} + +export class CrossHostRegistration { + private released = false + + private constructor( + private readonly directory: string, + readonly owner: ProcessOwner, + private readonly participant: string, + private readonly recoveryClaim: string | undefined, + private primary: boolean, + private readonly dependencies: CrossHostLeaseDependencies, + ) {} + + get path(): string { return this.directory } + + static register( + directory: string, + owner: ProcessOwner, + primaryCandidate: boolean | (() => boolean), + dependencies: CrossHostLeaseDependencies = defaultDependencies, + ): CrossHostRegistration | undefined { + if (!owner.processStartIdentity || !/^[A-Za-z0-9_-]+$/.test(owner.runToken)) return undefined + mkdirSync(directory, { recursive: true, mode: 0o700 }) + const participant = participantPath(directory, owner) + publishParticipant(participant, owner) + dependencies.onParticipantPublished?.() + let primary = false + let recoveryClaim: string | undefined + try { + if (typeof primaryCandidate === "function" ? primaryCandidate() : primaryCandidate) { + for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt += 1) { + if (publishOwner(directory, owner, dependencies)) { primary = true; break } + const observed = readIfExists(ownerPath(directory)) + if (observed === undefined) continue + const existing = parseOwner(observed) + if (!existing) break + if (sameOwner(existing, owner)) { primary = true; break } + if (ownerIsStale(existing, dependencies) === true) { + recoveryClaim ??= recoveryPath(directory, owner) + try { publishFile(recoveryClaim, observed) } catch (error) { + if (!hasErrorCode(error, "EEXIST") || readIfExists(recoveryClaim) !== observed) throw error + } + } + if (!retireOwner(directory, observed, existing, owner, dependencies)) break + } + } + return new CrossHostRegistration(directory, owner, participant, recoveryClaim, primary, dependencies) + } catch (error) { + removeParticipantIfOwned(participant, owner) + if (recoveryClaim) try { unlinkSync(recoveryClaim) } catch {} + throw error + } + } + + get isPrimary(): boolean { + if (this.released || !this.primary) return false + const current = parseOwner(readIfExists(ownerPath(this.directory)) ?? "") + return Boolean(current && sameOwner(current, this.owner)) + } + + release(): boolean { + if (this.released) return false + retireOwnerIfOwned(this.directory, this.owner, this.dependencies) + removeParticipantIfOwned(this.participant, this.owner) + if (this.recoveryClaim) try { unlinkSync(this.recoveryClaim) } catch {} + this.primary = false + this.released = true + return true + } +} diff --git a/packages/electron-app/electron/main/client-state-election-child.ts b/packages/electron-app/electron/main/client-state-election-child.ts new file mode 100644 index 00000000..4b4db1bf --- /dev/null +++ b/packages/electron-app/electron/main/client-state-election-child.ts @@ -0,0 +1,62 @@ +import { existsSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { + electClientStateProcess, + isPidAlive, + REGISTRATION_LOCK_WAIT_MS, + removeProcessOwnerLockIfOwned, + removeRunningMarkerIfOwned, + type ProcessOwner, +} from "./client-state-process" +import { getProcessStartIdentity } from "./client-state-process-identity" + +const [directory, runToken, startPath, registrationWaitArgument, primaryPausedPath, primaryReleasePath] = + process.argv.slice(2) +if (!directory || !runToken || !startPath) { + throw new Error("Expected election directory, run token, and start path") +} + +while (!existsSync(startPath)) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) +} + +const owner: ProcessOwner = { + pid: process.pid, + runToken, + processStartIdentity: getProcessStartIdentity(process.pid), +} +const primaryLockPath = join(directory, "client-state.primary.lock") +const registrationLockPath = join(directory, "client-state.registration.lock") +const registrationLockWaitMs = registrationWaitArgument + ? Number(registrationWaitArgument) + : REGISTRATION_LOCK_WAIT_MS +const warnings: string[] = [] +const election = electClientStateProcess( + directory, + owner, + { primaryLockPath, registrationLockPath }, + (message, error) => warnings.push(`${message}: ${String(error)}`), + isPidAlive, + registrationLockWaitMs, + () => { + if (!primaryPausedPath || !primaryReleasePath) { + return + } + try { + writeFileSync(primaryPausedPath, JSON.stringify(owner), { encoding: "utf8", flag: "wx" }) + } catch { + // Only the contender that published the synchronization point owns the pause gate. + return + } + while (!existsSync(primaryReleasePath)) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + } + }, +) + +process.stdout.write(`${JSON.stringify({ isPrimary: election, owner, warnings })}\n`) +process.stdin.resume() +process.stdin.once("end", () => { + removeRunningMarkerIfOwned(join(directory, `client-state.running.${owner.pid}.${owner.runToken}.json`), owner) + removeProcessOwnerLockIfOwned(primaryLockPath, owner) +}) diff --git a/packages/electron-app/electron/main/client-state-ipc.test.ts b/packages/electron-app/electron/main/client-state-ipc.test.ts new file mode 100644 index 00000000..15f1698e --- /dev/null +++ b/packages/electron-app/electron/main/client-state-ipc.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { IpcMainInvokeEvent } from "electron" +import { setupClientStateIPC } from "./client-state-ipc" + +function harness() { + const handlers = new Map unknown>() + const listeners = new Map void>() + const frame = { url: "http://127.0.0.1:3000/app" } + const webContents = { + mainFrame: frame, + getURL: () => "http://127.0.0.1:3000/app", + on: (event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener), + } + const window = { isDestroyed: () => false, webContents } + let current: typeof window | null = window + const calls: string[] = [] + const state = { + claimClientStateAccess: (token: unknown) => { calls.push(`claim:${token}`); return true }, + assertRendererAccessToken: (token: unknown) => calls.push(`assert:${token}`), + loadClientState: () => ({ isPrimary: true }), + saveClientState: () => true, + setRestoreEnabled: () => true, + clearClientState: () => true, + resetRendererAccessToken: () => calls.push("reset"), + } + const bind = setupClientStateIPC( + { handle: (channel, listener) => handlers.set(channel, listener) }, + state as never, + () => current as never, + () => ["http://127.0.0.1:3000"], + ) + bind(window as never) + return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window } +} + +test("IPC channels enforce the current main sender, frame, origin, and token", async () => { + const h = harness() + assert.deepEqual([...h.handlers.keys()], [ + "client-state:claimAccess", "client-state:load", "client-state:save", + "client-state:setRestoreEnabled", "client-state:clear", + ]) + const event = { sender: h.webContents, senderFrame: h.frame } + await h.handlers.get("client-state:claimAccess")!(event as never, "token") + await h.handlers.get("client-state:load")!(event as never, "token") + assert.deepEqual(h.calls, ["claim:token", "assert:token"]) + + for (const invalid of [ + { sender: {}, senderFrame: h.frame }, + { sender: h.webContents, senderFrame: { url: h.frame.url } }, + { sender: h.webContents, senderFrame: { ...h.frame, url: "https://example.com" } }, + ]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise) +}) + +test("only the registered current window can reset renderer authority", () => { + const h = harness() + h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/next") + h.listeners.get("render-process-gone")!() + assert.deepEqual(h.calls, ["reset", "reset"]) + h.setCurrent(null) + h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/late") + h.listeners.get("destroyed")!() + assert.deepEqual(h.calls, ["reset", "reset"]) +}) diff --git a/packages/electron-app/electron/main/client-state-ipc.ts b/packages/electron-app/electron/main/client-state-ipc.ts new file mode 100644 index 00000000..51b0b0c5 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-ipc.ts @@ -0,0 +1,78 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from "electron" +import type { ClientStateManager } from "./client-state" +import { shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation" +import { isAllowedRendererOrigin } from "./renderer-origin" + +interface IPCRegistrar { + handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void +} + +function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | null, allowedOrigins: string[]) { + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender !== mainWindow.webContents || + event.senderFrame !== mainWindow.webContents.mainFrame + ) { + throw new Error("Client state IPC is only available to the local main window") + } + + const currentUrl = mainWindow.webContents.getURL() + if ( + !isAllowedRendererOrigin(currentUrl, allowedOrigins) || + !isAllowedRendererOrigin(event.senderFrame.url, allowedOrigins) || + new URL(currentUrl).origin !== new URL(event.senderFrame.url).origin + ) { + throw new Error("Client state IPC is not available to the current renderer origin") + } +} + +export function setupClientStateIPC( + ipcMain: IPCRegistrar, + clientState: ClientStateManager, + getMainWindow: () => BrowserWindow | null, + getAllowedOrigins: (window: BrowserWindow | null) => string[], +) { + const validate = (event: IpcMainInvokeEvent) => { + const window = getMainWindow() + validateSender(event, window, getAllowedOrigins(window)) + } + const handle = ( + channel: string, + operation: (argument: unknown, token: unknown) => unknown, + ) => ipcMain.handle(channel, async (event, token: unknown, argument: unknown) => { + validate(event) + clientState.assertRendererAccessToken(token) + return operation(argument, token) + }) + + ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => { + validate(event) + return clientState.claimClientStateAccess(token) + }) + handle("client-state:load", () => clientState.loadClientState()) + handle("client-state:save", (snapshot, token) => clientState.saveClientState(snapshot, token)) + handle("client-state:setRestoreEnabled", (enabled, token) => { + if (typeof enabled !== "boolean") throw new Error("Restore enabled must be a boolean") + return clientState.setRestoreEnabled(enabled, token) + }) + handle("client-state:clear", (_argument, token) => clientState.clearClientState(token)) + + return (window: BrowserWindow): void => { + window.webContents.on("did-navigate", (_event, url) => { + if (getMainWindow() === window && shouldResetRendererAccessTokenForNavigation( + url, + false, + true, + (target) => isAllowedRendererOrigin(target, getAllowedOrigins(window)), + )) { + clientState.resetRendererAccessToken() + } + }) + const resetDestroyedRenderer = () => { + if (getMainWindow() === window) clientState.resetRendererAccessToken() + } + window.webContents.on("render-process-gone", resetDestroyedRenderer) + window.webContents.on("destroyed", resetDestroyedRenderer) + } +} diff --git a/packages/electron-app/electron/main/client-state-lifecycle.test.ts b/packages/electron-app/electron/main/client-state-lifecycle.test.ts new file mode 100644 index 00000000..9f65b211 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-lifecycle.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict" +import { setTimeout as delay } from "node:timers/promises" +import test from "node:test" +import type { App, BrowserWindow } from "electron" +import { ClientStateLifecycle } from "./client-state-lifecycle" +import type { ClientStateManager } from "./client-state" +import type { CliProcessManager } from "./process-manager" +import type { WindowStateTracker } from "./window-state" + +const tick = () => new Promise((resolve) => setImmediate(resolve)) +function harness(options: { + flush?: () => Promise + stop?: () => Promise + nativeFlush?: () => Promise + otherWindow?: boolean + sessionEndCleanupTimeoutMs?: number + sessionEndReleaseTimeoutMs?: number + release?: () => Promise +} = {}) { + const windows = new Map void>() + const appEvents = new Map void>() + const calls: string[] = [] + let exits = 0 + const window = { + on: (name: string, handler: (event?: { preventDefault(): void }) => void) => windows.set(name, handler), + isDestroyed: () => false, + close: () => { calls.push("close"); windows.get("close")?.({ preventDefault: () => assert.fail("approved close prevented") }) }, + hide: () => { calls.push("hide") }, + show: () => { calls.push("show") }, + webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:43123/workspace", executeJavaScript: () => { calls.push("renderer"); return options.flush?.() ?? Promise.resolve() } }, + } as unknown as BrowserWindow + const other = { isDestroyed: () => false, hide: () => { calls.push("hide-other") } } as unknown as BrowserWindow + const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App + const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release"); await options.release?.() } } as ClientStateManager + const cli = { shutdown: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager + const lifecycle = new ClientStateLifecycle({ app, clientStateManager: manager, cliManager: cli, getMainWindow: () => window, getAllWindows: () => options.otherWindow ? [window, other] : [window], getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], isTrustedRendererOrigin: () => true, isWindows: true, sessionEndCleanupTimeoutMs: options.sessionEndCleanupTimeoutMs, sessionEndReleaseTimeoutMs: options.sessionEndReleaseTimeoutMs }) + lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker) + lifecycle.registerAppEvents() + const close = () => { let prevented = false; windows.get("close")?.({ preventDefault: () => { prevented = true } }); return prevented } + return { appEvents, calls, close, exits: () => exits, lifecycle, window, windows } +} + +test("close flushes renderer/native once before approval, even when repeated or renderer fails", async (t) => { + await t.test("ordinary", async () => { + const h = harness({ otherWindow: true }) + assert.equal(h.close(), true) + await tick() + assert.deepEqual(h.calls, ["renderer", "native", "close"]) + }) + await t.test("coalesced", async () => { + let release!: () => void + const h = harness({ otherWindow: true, flush: () => new Promise((resolve) => { release = resolve }) }) + assert.equal(h.close(), true); assert.equal(h.close(), true) + assert.deepEqual(h.calls, ["renderer"]) + release(); await tick() + assert.deepEqual(h.calls, ["renderer", "native", "close"]) + }) + await t.test("renderer failure", async () => { + const h = harness({ otherWindow: true, flush: async () => { throw new Error("failed") } }) + assert.equal(h.close(), true); await tick() + assert.deepEqual(h.calls, ["renderer", "native", "close"]) + }) +}) + +test("late old-window detach preserves replacement tracker during shutdown", async () => { + const h = harness() + const replacement = { on: () => {} } as unknown as BrowserWindow + h.lifecycle.attachMainWindow(replacement, { flush: async () => { h.calls.push("replacement-native") } } as unknown as WindowStateTracker) + h.lifecycle.detachMainWindow(h.window) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await (h.lifecycle as any).shutdown + assert.deepEqual(h.calls, ["hide", "renderer", "replacement-native", "stop", "release"]) +}) + +test("Windows session end vetoes termination until cleanup exits explicitly", async () => { + const h = harness() + let prevented = false + h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) + h.windows.get("session-end")?.() + await (h.lifecycle as any).sessionEnd; await tick() + assert.equal(prevented, true) + assert.deepEqual(h.calls, ["renderer", "native", "stop", "release"]) + assert.equal(h.exits(), 1) +}) + +test("session end force-exits after the bounded window when an ordinary shutdown is hung", async () => { + const h = harness({ flush: () => new Promise(() => {}), sessionEndCleanupTimeoutMs: 10 }) + let prevented = false + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) + await delay(25) + assert.equal(prevented, true) + assert.deepEqual(h.calls, ["hide", "renderer", "release"]) + assert.equal(h.exits(), 1) +}) + +test("ordinary quit hides promptly and waits for CLI stop confirmation", async () => { + let confirmStop!: () => void + const h = harness({ stop: () => new Promise((resolve) => { confirmStop = resolve }) }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await tick() + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop"]) + assert.equal(h.exits(), 0) + confirmStop() + await (h.lifecycle as any).shutdown; await tick() + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) + assert.equal(h.exits(), 1) +}) + +test("ordinary quit does not exit when CLI cleanup is unconfirmed", async () => { + const h = harness({ stop: async () => { throw new Error("unconfirmed") } }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await assert.rejects((h.lifecycle as any).shutdown, /unconfirmed/) + await tick() + assert.equal(h.exits(), 0) + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "show"]) +}) + +test("Windows session-end rejection fails open at the bounded deadline", async () => { + const h = harness({ stop: async () => { throw new Error("unconfirmed") }, sessionEndCleanupTimeoutMs: 10 }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + h.windows.get("query-session-end")?.({ preventDefault: () => {} }) + await delay(25) + assert.equal(h.exits(), 1) + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) +}) + +test("Windows fail-open bounds a hanging primary release before app.exit", async () => { + const h = harness({ + flush: () => new Promise(() => {}), + release: () => new Promise(() => {}), + sessionEndCleanupTimeoutMs: 30, + sessionEndReleaseTimeoutMs: 10, + }) + h.windows.get("query-session-end")?.({ preventDefault: () => {} }) + + await delay(25) + assert.deepEqual(h.calls, ["renderer", "release"]) + assert.equal(h.exits(), 0) + await delay(15) + assert.equal(h.exits(), 1) +}) + +test("CLI termination waits for the native snapshot flush", async () => { + let release!: () => void + const h = harness({ nativeFlush: () => new Promise((resolve) => { release = resolve }) }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await tick() + assert.deepEqual(h.calls, ["hide", "renderer", "native"]) + assert.equal(h.exits(), 0) + release(); await (h.lifecycle as any).shutdown + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) +}) + +test("closing the final window hides it before requesting quit", () => { + const h = harness() + assert.equal(h.close(), true) + assert.deepEqual(h.calls, ["hide", "quit"]) +}) diff --git a/packages/electron-app/electron/main/client-state-lifecycle.ts b/packages/electron-app/electron/main/client-state-lifecycle.ts new file mode 100644 index 00000000..c0e7405c --- /dev/null +++ b/packages/electron-app/electron/main/client-state-lifecycle.ts @@ -0,0 +1,192 @@ +import type { App, BrowserWindow } from "electron" +import type { ClientStateManager } from "./client-state" +import type { CliProcessManager } from "./process-manager" +import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" +import type { WindowStateTracker } from "./window-state" + +interface ClientStateLifecycleDependencies { + app: App + clientStateManager: ClientStateManager + cliManager: CliProcessManager + getMainWindow(): BrowserWindow | null + getAllWindows(): BrowserWindow[] + getAllowedRendererOrigins(window?: BrowserWindow | null): string[] + isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean + rendererFlushTimeoutMs?: number + sessionEndCleanupTimeoutMs?: number + sessionEndReleaseTimeoutMs?: number + isWindows?: boolean +} + +export class ClientStateLifecycle { + private shutdown: Promise | null = null + private sessionEnd: Promise | null = null + private exitAllowed = false + private trackedMainWindow: BrowserWindow | null = null + private windowStateTracker: WindowStateTracker | null = null + private windowsHiddenForShutdown = false + private primaryRelease: Promise | null = null + + constructor(private readonly dependencies: ClientStateLifecycleDependencies) {} + + attachMainWindow(window: BrowserWindow, tracker: WindowStateTracker | null): void { + this.trackedMainWindow = window + this.windowStateTracker = tracker + let closeApproved = false + let closeInProgress = false + + window.on("close", (event) => { + if (this.exitAllowed || closeApproved) return + event.preventDefault() + if (this.shutdown) return + + const hasOtherWindow = this.dependencies + .getAllWindows() + .some((candidate) => candidate !== window && !candidate.isDestroyed()) + if (!hasOtherWindow) { + window.hide() + this.dependencies.app.quit() + } else if (!closeInProgress) { + closeInProgress = true + void this.flushForClose(window).finally(() => { + closeApproved = true + try { + window.close() + } catch (error) { + closeApproved = false + closeInProgress = false + console.warn("[client-state] main-window close failed", error) + } + }) + } + }) + + if (this.dependencies.isWindows ?? process.platform === "win32") { + window.on("query-session-end", (event) => { + if (this.exitAllowed) return + event.preventDefault() + this.promoteToSessionEnd(window) + }) + window.on("session-end", () => this.promoteToSessionEnd(window)) + } + } + + detachMainWindow(window: BrowserWindow): void { + if (this.trackedMainWindow !== window) return + this.trackedMainWindow = null + this.windowStateTracker = null + } + + registerAppEvents(): void { + const { app } = this.dependencies + app.on("before-quit", (event) => { + if (this.exitAllowed) return + event.preventDefault() + this.hideWindows() + void this.startShutdown(this.dependencies.getMainWindow()).then(() => this.exit(), (error) => { + if (!this.sessionEnd) this.restoreWindowAfterRejectedShutdown(this.dependencies.getMainWindow()) + console.warn("[client-state] desktop shutdown remains pending because cleanup was not contained", error) + }) + }) + app.on("window-all-closed", () => app.quit()) + } + + private async flushForClose(window: BrowserWindow): Promise { + await this.runStage("renderer main-window close flush", () => this.flushRenderer(window)) + await this.runStage("native main-window close flush", () => this.flushNative()) + } + + private startShutdown(window: BrowserWindow | null): Promise { + if (this.shutdown) return this.shutdown + const stages = (async () => { + await this.runStage("renderer shutdown flush", () => this.flushRenderer(window)) + await this.runStage("native shutdown flush", () => this.flushNative()) + await this.dependencies.cliManager.shutdown() + await this.releasePrimary() + })() + this.shutdown = stages.catch((error) => { + this.shutdown = null + throw error + }) + return this.shutdown + } + + private hideWindows(): void { + for (const window of this.dependencies.getAllWindows()) { + if (!window.isDestroyed()) { + window.hide() + this.windowsHiddenForShutdown = true + } + } + } + + private restoreWindowAfterRejectedShutdown(preferred: BrowserWindow | null): void { + if (!this.windowsHiddenForShutdown) return + this.windowsHiddenForShutdown = false + const window = preferred && !preferred.isDestroyed() + ? preferred + : this.dependencies.getAllWindows().find((candidate) => !candidate.isDestroyed()) + if (window) window.show() + } + + private promoteToSessionEnd(window: BrowserWindow): void { + if (this.exitAllowed || this.sessionEnd) return + const cleanup = this.startShutdown(window) + this.sessionEnd = new Promise((resolve) => { + const timeoutMs = this.dependencies.sessionEndCleanupTimeoutMs ?? 5_000 + const releaseTimeoutMs = Math.min(timeoutMs, this.dependencies.sessionEndReleaseTimeoutMs ?? 250) + const releaseTimer = setTimeout(() => { + void this.releasePrimary() + }, Math.max(0, timeoutMs - releaseTimeoutMs)) + const exitTimer = setTimeout(() => { + console.warn(`[client-state] OS session-end cleanup exceeded ${timeoutMs}ms; exiting without containment`) + resolve() + }, timeoutMs) + void cleanup.then(() => { + clearTimeout(releaseTimer) + clearTimeout(exitTimer) + resolve() + }, (error) => { + console.warn("[client-state] OS session-end cleanup was not contained; waiting for forced exit", error) + }) + }).then(() => this.exit()) + } + + private releasePrimary(): Promise { + if (!this.primaryRelease) { + this.primaryRelease = this.runStage("primary release", () => this.dependencies.clientStateManager.drainAndReleasePrimary()) + } + return this.primaryRelease + } + + private async flushRenderer(window: BrowserWindow | null): Promise { + const result = await flushRendererClientStateBeforeShutdown( + window, + this.dependencies.clientStateManager.isPrimary, + (url) => this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(window)), + this.dependencies.rendererFlushTimeoutMs, + ) + if (result === "untrusted-origin") { + console.warn("[client-state] skipped renderer flush for an untrusted origin") + } + } + + private async flushNative(): Promise { + if (this.windowStateTracker) await this.windowStateTracker.flush() + else await this.dependencies.clientStateManager.flush() + } + + private async runStage(name: string, operation: () => Promise): Promise { + try { + await operation() + } catch (error) { + console.warn(`[client-state] ${name} failed; continuing`, error) + } + } + + private exit(): void { + if (this.exitAllowed) return + this.exitAllowed = true + this.dependencies.app.exit(0) + } +} diff --git a/packages/electron-app/electron/main/client-state-navigation.test.ts b/packages/electron-app/electron/main/client-state-navigation.test.ts new file mode 100644 index 00000000..8c40b0a9 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-navigation.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { ClientStateManager } from "./client-state" +import { ClientStateNavigationController, shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation" + +const tick = () => new Promise((resolve) => setImmediate(resolve)) +function window(executeJavaScript: () => Promise = async () => {}) { + return { isDestroyed: () => false, webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript } } +} +function controller(win: ReturnType, manager: { isPrimary: boolean }, report: (error: unknown) => void = (error) => assert.fail(String(error))) { + return new ClientStateNavigationController(win as never, { clientStateManager: manager, isTrustedOrigin: () => true, reportFlushError: report }) +} +function managerHarness(t: test.TestContext) { + const directory = mkdtempSync(join(tmpdir(), "codenomad-navigation-")) + const manager = new ClientStateManager(directory, undefined, { crossHostElectionDirectory: join(directory, "election") }) + t.after(async () => { await manager.drainAndReleasePrimary().catch(() => {}); rmSync(directory, { recursive: true, force: true }) }) + return manager +} + +test("renderer access resets only for trusted full main-frame navigation", () => { + const trusted = (url: string) => new URL(url).origin === "http://127.0.0.1:3000" + for (const [url, inPlace, mainFrame, expected] of [ + ["http://127.0.0.1:3000/reload", false, true, true], + ["http://127.0.0.1:3000/frame", false, false, false], + ["http://127.0.0.1:3000/#route", true, true, false], + ["https://untrusted.example/reload", false, true, false], + ] as const) assert.equal(shouldResetRendererAccessTokenForNavigation(url, inPlace, mainFrame, trusted), expected) +}) + +test("immediate reload flushes latest state before rotating document access", async (t) => { + const manager = managerHarness(t) + await manager.setRestoreEnabled(true) + manager.claimClientStateAccess("outgoing") + const load = (token: string) => { manager.assertRendererAccessToken(token); return manager.loadClientState() } + const save = (token: string, state: unknown) => { manager.assertRendererAccessToken(token); return manager.saveClientState(state) } + for (const denied of [() => load("other"), () => save("other", {})]) assert.throws(denied, /has not been claimed/) + let navigated = false + await controller(window(() => save("outgoing", { revision: 7, editor: "latest" })), manager).navigate(() => { + navigated = true + assert.deepEqual(load("outgoing").snapshot, { revision: 7, editor: "latest" }) + manager.resetRendererAccessToken() + }) + assert.equal(navigated, true) + assert.throws(() => load("outgoing"), /has not been claimed/) + manager.claimClientStateAccess("incoming") + assert.deepEqual(load("incoming").snapshot, { revision: 7, editor: "latest" }) +}) + +test("failed navigation retains current document access", async (t) => { + for (const [name, operation] of [ + ["loadURL", () => Promise.reject(new Error("loadURL failed"))], + ["reload", () => { throw new Error("reload failed") }], + ] as const) await t.test(name, async (st) => { + const manager = managerHarness(st) + manager.claimClientStateAccess("current") + await assert.rejects(controller(window(), manager).navigate(operation), new RegExp(`${name} failed`)) + manager.assertRendererAccessToken("current") + assert.equal(await manager.saveClientState({ retained: name }), true) + }) +}) + +test("hung renderer flush is bounded without rotating access or blocking navigation", async () => { + const manager = { isPrimary: true, resets: 0, resetRendererAccessToken() { this.resets++ } } + let navigated = false, reported = false + const started = Date.now() + await controller(window(() => new Promise(() => {})), manager, () => { reported = true }).navigate(() => { navigated = true }) + const elapsed = Date.now() - started + assert.equal(reported, true) + assert.equal(manager.resets, 0) + assert.equal(navigated, true) + assert.ok(elapsed >= 900 && elapsed < 2_000, `unexpected timeout: ${elapsed}ms`) +}) + +test("queued navigation preserves order and distinct generations", async () => { + const calls: string[] = [] + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const navigation = controller(window(), { isPrimary: true }) + const first = navigation.navigate(async (_window, generation) => { calls.push(`start-${generation}`); await gate; calls.push(`end-${generation}`) }) + const second = navigation.navigate((_window, generation) => { calls.push(`run-${generation}`) }) + await tick() + assert.deepEqual(calls, ["start-1"]) + release() + await Promise.all([first, second]) + assert.deepEqual(calls, ["start-1", "end-1", "run-2"]) +}) diff --git a/packages/electron-app/electron/main/client-state-navigation.ts b/packages/electron-app/electron/main/client-state-navigation.ts new file mode 100644 index 00000000..feed061d --- /dev/null +++ b/packages/electron-app/electron/main/client-state-navigation.ts @@ -0,0 +1,56 @@ +import type { BrowserWindow } from "electron" +import type { ClientStateManager } from "./client-state" +import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" + +interface ClientStateNavigationDependencies { + clientStateManager: Pick + isTrustedOrigin(url: string): boolean + reportFlushError(error: unknown): void +} + +export class ClientStateNavigationController { + private queue: Promise = Promise.resolve() + private generation = 0 + + constructor( + private readonly window: BrowserWindow, + private readonly dependencies: ClientStateNavigationDependencies, + ) {} + + navigate(operation: (window: BrowserWindow, generation: number) => void | Promise): Promise { + const generation = ++this.generation + const request = this.queue.catch(() => {}).then(() => this.performNavigation(operation, generation)) + this.queue = request + return request + } + + private async performNavigation( + operation: (window: BrowserWindow, generation: number) => void | Promise, + generation: number, + ): Promise { + const { window } = this + if (window.isDestroyed() || window.webContents.isDestroyed()) return + + try { + await flushRendererClientStateBeforeShutdown( + window, + this.dependencies.clientStateManager.isPrimary, + this.dependencies.isTrustedOrigin, + ) + } catch (error) { + this.dependencies.reportFlushError(error) + } + + if (window.isDestroyed() || window.webContents.isDestroyed()) return + await operation(window, generation) + } +} + +export function shouldResetRendererAccessTokenForNavigation( + url: string, + isInPlace: boolean, + isMainFrame: boolean, + isTrustedOrigin: (url: string) => boolean, +): boolean { + return isMainFrame && !isInPlace && isTrustedOrigin(url) +} diff --git a/packages/electron-app/electron/main/client-state-process-identity.ts b/packages/electron-app/electron/main/client-state-process-identity.ts new file mode 100644 index 00000000..749bfc57 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-process-identity.ts @@ -0,0 +1,129 @@ +import { execFile, spawnSync } from "node:child_process" +import { readFile as readFileAsync } from "node:fs/promises" +import { readFileSync, readlinkSync } from "node:fs" +import { basename, resolve } from "node:path" + +export type ProcessStartIdentityLookup = (pid: number) => string | undefined +export type AsyncProcessStartIdentityLookup = (pid: number, timeoutMs: number) => Promise | string | undefined +export type ExpectedProcessLookup = (pid: number) => boolean | undefined + +function readLinuxProcessStartIdentity(pid: number): string | undefined { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8") + const commandEnd = stat.lastIndexOf(")") + if (commandEnd < 0) return undefined + + // Fields after the command begin with field 3; process start time is field 22. + const fields = stat.slice(commandEnd + 1).trim().split(/\s+/) + const startTicks = fields[19] + if (!startTicks) return undefined + + const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() + return bootId ? `linux:${bootId}:${startTicks}` : undefined +} + +function readCommandIdentity(command: string, args: string[], prefix: string): string | undefined { + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = spawnSync(command, args, { + encoding: "utf8", + windowsHide: true, + timeout: 5_000, + }) + if (result.status === 0 && !result.error) { + const value = result.stdout.trim() + if (value) return `${prefix}:${value}` + } + } + return undefined +} + +function readCommandIdentityAsync(command: string, args: string[], prefix: string, timeoutMs: number): Promise { + if (timeoutMs <= 0) return Promise.resolve(undefined) + return new Promise((resolve) => { + execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => { + const value = error ? "" : stdout.trim() + resolve(value ? `${prefix}:${value}` : undefined) + }) + }) +} + +export function getProcessStartIdentity(pid: number): string | undefined { + if (!Number.isInteger(pid) || pid <= 0) return undefined + + try { + if (process.platform === "linux") { + return readLinuxProcessStartIdentity(pid) + } + if (process.platform === "darwin") { + return readCommandIdentity("ps", ["-p", String(pid), "-o", "lstart="], "darwin") + } + if (process.platform === "win32") { + return readCommandIdentity( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + ], + "win32", + ) + } + } catch { + // Identity lookup is best-effort; callers preserve election safety when it is unavailable. + } + + return undefined +} + +export async function getProcessStartIdentityAsync( + pid: number, + timeoutMs: number, + platform: NodeJS.Platform = process.platform, +): Promise { + if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return undefined + try { + if (platform === "linux") { + const signal = AbortSignal.timeout(timeoutMs) + const stat = await readFileAsync(`/proc/${pid}/stat`, { encoding: "utf8", signal }) + const commandEnd = stat.lastIndexOf(")") + const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19] + if (!startTicks) return undefined + const bootId = (await readFileAsync("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim() + return bootId ? `linux:${bootId}:${startTicks}` : undefined + } + if (platform === "darwin") { + return readCommandIdentityAsync("ps", ["-p", String(pid), "-o", "lstart="], "darwin", timeoutMs) + } + if (platform === "win32") { + return readCommandIdentityAsync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + ], "win32", timeoutMs) + } + } catch { + // Identity lookup is best-effort; callers refuse destructive actions when it is unavailable. + } + return undefined +} + +export function isExpectedTauriProcess(pid: number): boolean | undefined { + try { + const executable = process.platform === "linux" + ? readlinkSync(`/proc/${pid}/exe`) + : readCommandIdentity( + process.platform === "win32" ? "powershell.exe" : "ps", + process.platform === "win32" + ? ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid} -ErrorAction Stop).Path`] + : ["-p", String(pid), "-o", "comm="], + "path", + )?.slice(5) + if (!executable) return undefined + if (resolve(executable).toLowerCase() === resolve(process.execPath).toLowerCase()) return false + return ["codenomad", "codenomad.exe", "codenomad-tauri", "codenomad-tauri.exe"] + .includes(basename(executable).toLowerCase()) + } catch { + return undefined + } +} diff --git a/packages/electron-app/electron/main/client-state-process.test.ts b/packages/electron-app/electron/main/client-state-process.test.ts new file mode 100644 index 00000000..fe9e0bde --- /dev/null +++ b/packages/electron-app/electron/main/client-state-process.test.ts @@ -0,0 +1,190 @@ +import assert from "node:assert/strict" +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" +import { randomUUID } from "node:crypto" +import { once } from "node:events" +import fs, { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { syncBuiltinESMExports } from "node:module" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import test from "node:test" +import { cleanStaleRunningMarkers, createRunningMarker, electClientStateProcess, getRunningMarkerPath, hasLiveTauriClient, REGISTRATION_LOCK_WAIT_MS, removeProcessOwnerLockIfOwned, removeRunningMarkerIfOwned, type ProcessOwner } from "./client-state-process" +import { getProcessStartIdentity, getProcessStartIdentityAsync } from "./client-state-process-identity" + +function temp(t: test.TestContext) { const directory = mkdtempSync(join(tmpdir(), "codenomad-election-")); t.after(() => rmSync(directory, { recursive: true, force: true })); return directory } + +test("legacy Tauri markers block only while their PID may be live", (t) => { + const directory = temp(t) + writeFileSync(join(directory, "client-state.running.123.1.lock"), "") + writeFileSync(join(directory, "client-state.running.456.2.lock"), "") + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456), true) + assert.equal(hasLiveTauriClient(directory, () => false), false) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "reused", () => false), false) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => undefined, () => undefined), true) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "tauri-start", () => true), true) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "tauri-start", () => true, [ + { pid: 456, runToken: "upgraded", processStartIdentity: "tauri-start" }, + ]), false) +}) + +interface Child { process: ChildProcessWithoutNullStreams; result: Promise<{ isPrimary: boolean }> } +function child(directory: string, start: string, wait = "", paused = "", release = ""): Child { + const process = spawn(globalThis.process.execPath, ["--import", "tsx", fileURLToPath(new URL("./client-state-election-child.ts", import.meta.url)), directory, randomUUID(), start, wait, paused, release]) + process.stdout.setEncoding("utf8"); process.stderr.setEncoding("utf8") + const result = new Promise<{ isPrimary: boolean }>((resolve, reject) => { + let output = "", errors = "", settled = false + process.stdout.on("data", (chunk: string) => { + output += chunk + const end = output.indexOf("\n") + if (end >= 0 && !settled) { settled = true; resolve(JSON.parse(output.slice(0, end))) } + }) + process.stderr.on("data", (chunk: string) => { errors += chunk }); process.once("error", reject) + process.once("exit", (code) => { if (!settled) reject(new Error(`child ${code}: ${errors}`)) }) + }) + return { process, result } +} +async function stop(children: Child[]) { const exits = children.map(({ process }) => once(process, "exit")); children.forEach(({ process }) => process.stdin.end()); await Promise.all(exits) } +async function contenders(directory: string, configure?: () => void, count = 2) { + const start = join(directory, "start") + configure?.() + const children = Array.from({ length: count }, () => child(directory, start, "40")) + try { + writeFileSync(start, "") + const roles = await Promise.all(children.map(({ result }) => result)) + assert.equal(roles.filter(({ isPrimary }) => isPrimary).length, 1, JSON.stringify(roles)) + } finally { await stop(children) } +} + +test("current process start identity is stable", () => { + const identity = getProcessStartIdentity(process.pid) + assert.ok(identity, `identity unavailable on ${process.platform}`) + assert.equal(getProcessStartIdentity(process.pid), identity) +}) + +test("async process identity matches the spawn-time identity", async () => { + const identity = getProcessStartIdentity(process.pid) + assert.ok(identity) + assert.equal(await getProcessStartIdentityAsync(process.pid, 1_500), identity) +}) + +test("marker cleanup preserves only election-relevant live cohorts", async (t) => { + const cases: Array<{ name: string; marker: ProcessOwner; current: ProcessOwner; alive: boolean; identity?: string; primary?: ProcessOwner; blocking: boolean; remains: boolean }> = [ + { name: "live secondary", marker: { pid: 2, runToken: "live" }, current: { pid: 3, runToken: "new" }, alive: true, blocking: true, remains: true }, + { name: "same PID old run", marker: { pid: 4, runToken: "old" }, current: { pid: 4, runToken: "new" }, alive: true, blocking: false, remains: false }, + { name: "reused PID", marker: { pid: 5, runToken: "old", processStartIdentity: "old" }, current: { pid: 6, runToken: "new" }, alive: true, identity: "reused", blocking: false, remains: false }, + { name: "unknown identity", marker: { pid: 7, runToken: "unknown", processStartIdentity: "old" }, current: { pid: 8, runToken: "new" }, alive: true, blocking: true, remains: true }, + { name: "acknowledged primary", marker: { pid: 9, runToken: "secondary" }, current: { pid: 10, runToken: "primary" }, alive: true, primary: { pid: 10, runToken: "primary" }, blocking: false, remains: true }, + ] + for (const value of cases) await t.test(value.name, (st) => { + const directory = temp(st) + const path = createRunningMarker(directory, value.marker, value.primary) + const identity = () => value.identity + assert.equal(cleanStaleRunningMarkers(directory, value.current, () => value.alive, identity), value.blocking) + assert.equal(existsSync(path), value.remains) + }) +}) + +test("marker removal and mismatched contents never discard a possible live owner", (t) => { + const directory = temp(t) + const filenameOwner = { pid: 11, runToken: "filename" } + const storedOwner = { pid: 12, runToken: "stored" } + const current = { pid: 13, runToken: "current" } + const path = createRunningMarker(directory, filenameOwner) + writeFileSync(path, JSON.stringify(storedOwner)) + assert.equal(removeRunningMarkerIfOwned(path, filenameOwner), false) + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), storedOwner) + assert.equal(cleanStaleRunningMarkers(directory, current, () => false), false) + assert.equal(existsSync(path), false) + createRunningMarker(directory, filenameOwner) + writeFileSync(path, JSON.stringify(storedOwner)) + assert.equal(cleanStaleRunningMarkers(directory, current, (pid) => pid === filenameOwner.pid), true) + assert.equal(existsSync(path), true) +}) + +test("process files tolerate only unsupported fsync and never clobber an owner", (t) => { + const directory = temp(t) + const first = { pid: 14, runToken: "first" } + const path = createRunningMarker(directory, first) + assert.throws(() => createRunningMarker(directory, { pid: 14, runToken: "first" }), { code: "EEXIST" }) + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), first) + const original = fs.fsyncSync + t.after(() => { fs.fsyncSync = original; syncBuiltinESMExports() }) + for (const [code, retained] of [["EINVAL", true], ["ENOTSUP", true], ["ENOSYS", true], ["EIO", false]] as const) { + fs.fsyncSync = () => { throw Object.assign(new Error(code), { code }) }; syncBuiltinESMExports() + const owner = { pid: 15, runToken: code }, next = getRunningMarkerPath(directory, owner) + if (retained) createRunningMarker(directory, owner) + else assert.throws(() => createRunningMarker(directory, owner), { code }) + assert.equal(existsSync(next), retained) + } +}) + +test("simultaneous registration and stale lock recovery elect exactly one primary", async (t) => { + await t.test("clean", (st) => contenders(temp(st))) + await t.test("unrelated live registration PID", (st) => { + const directory = temp(st) + return contenders(directory, () => writeFileSync(join(directory, "client-state.registration.lock"), JSON.stringify({ pid: process.pid, runToken: "old" }))) + }) + for (let round = 0; round < 10; round++) await t.test(`reused primary PID ${round}`, (st) => { + const directory = temp(st) + return contenders(directory, () => { + const owner = { pid: process.pid, runToken: `old-${round}`, processStartIdentity: `old-start-${round}` } + writeFileSync(join(directory, "client-state.primary.lock"), JSON.stringify(owner)) + createRunningMarker(directory, owner) + }) + }) +}) + +test("overlapping stale-registration recovery cannot leave all contenders secondary", async (t) => { + const directory = temp(t), start = join(directory, "start"), paused = join(directory, "paused"), release = join(directory, "release") + writeFileSync(join(directory, "client-state.registration.lock"), JSON.stringify({ pid: process.pid, runToken: "old" })) + const children = [child(directory, start, "40", paused, release), child(directory, start, "40", paused, release)] + try { + writeFileSync(start, "") + const deadline = Date.now() + 2_000 + while (!existsSync(paused) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5)) + assert.equal(existsSync(paused), true) + await Promise.race(children.map(({ result }) => result)) + writeFileSync(release, "") + const roles = await Promise.all(children.map(({ result }) => result)) + assert.equal(roles.filter(({ isPrimary }) => isPrimary).length, 1, JSON.stringify(roles)) + } finally { if (!existsSync(release)) writeFileSync(release, ""); await stop(children) } +}) + +test("a surviving older secondary keeps later processes secondary", async (t) => { + const directory = temp(t) + const launch = async (name: string) => { + const start = join(directory, name), next = child(directory, start) + writeFileSync(start, "") + return { next, role: await next.result } + } + const first = await launch("first"); assert.equal(first.role.isPrimary, true) + const second = await launch("second"); assert.equal(second.role.isPrimary, false) + await stop([first.next]) + const third = await launch("third") + await stop([second.next, third.next]) + assert.equal(third.role.isPrimary, false) +}) + +test("lock recovery handles PID reuse, malformed files, and verified live owners", async (t) => { + const cases = [ + { name: "same PID old token", owner: { pid: process.pid, runToken: "new" }, file: { pid: process.pid, runToken: "old" }, lock: "primary", alive: () => true, expected: true }, + { name: "malformed registration", owner: { pid: 21, runToken: "new" }, file: "malformed", lock: "registration", alive: () => true, expected: false }, + { name: "live registration marker", owner: { pid: 22, runToken: "new" }, file: { pid: 23, runToken: "live" }, lock: "registration", alive: (pid: number) => pid === 23, marker: true, expected: false }, + { name: "identity-verified registration", owner: { pid: 24, runToken: "new" }, file: { pid: 25, runToken: "live", processStartIdentity: "start" }, lock: "registration", alive: (pid: number) => pid === 25, identity: () => "start", expected: false }, + { name: "live primary marker", owner: { pid: 26, runToken: "new" }, file: { pid: 27, runToken: "live" }, lock: "primary", alive: (pid: number) => pid === 27, marker: true, expected: false }, + ] as const + for (const value of cases) await t.test(value.name, (st) => { + const directory = temp(st), primary = join(directory, "client-state.primary.lock"), registration = join(directory, "client-state.registration.lock") + const path = value.lock === "primary" ? primary : registration + writeFileSync(path, typeof value.file === "string" ? value.file : JSON.stringify(value.file)) + if ("marker" in value && value.marker && typeof value.file !== "string") createRunningMarker(directory, value.file) + const started = Date.now() + const elected = electClientStateProcess(directory, value.owner, { primaryLockPath: primary, registrationLockPath: registration }, () => {}, value.alive, 30, () => {}, "identity" in value ? value.identity : undefined) + assert.equal(elected, value.expected) + assert.ok(Date.now() - started < 500) + if (!value.expected) assert.deepEqual(readFileSync(path, "utf8"), typeof value.file === "string" ? value.file : JSON.stringify(value.file)) + removeRunningMarkerIfOwned(getRunningMarkerPath(directory, value.owner), value.owner) + removeProcessOwnerLockIfOwned(primary, value.owner) + }) + assert.equal(REGISTRATION_LOCK_WAIT_MS, 1_000) +}) diff --git a/packages/electron-app/electron/main/client-state-process.ts b/packages/electron-app/electron/main/client-state-process.ts new file mode 100644 index 00000000..6874dde0 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-process.ts @@ -0,0 +1,511 @@ +import { closeSync, fsyncSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs" +import { basename, join } from "node:path" +import { + type ExpectedProcessLookup, + getProcessStartIdentity, + isExpectedTauriProcess, + type ProcessStartIdentityLookup, +} from "./client-state-process-identity" + +const RUNNING_MARKER_PREFIX = "client-state.running." +const RUNNING_MARKER_SUFFIX = ".json" +const PRIMARY_LOCK_ACQUIRE_ATTEMPTS = 5 +const LOCK_RETRY_DELAY_MS = 10 +export const REGISTRATION_LOCK_WAIT_MS = 1_000 + +export interface ProcessOwner { + pid: number + runToken: string + processStartIdentity?: string +} + +export type RunningMarkerStatus = "current" | "other-live" | "stale" + +export interface ClientStateElectionPaths { + primaryLockPath: string + registrationLockPath: string +} + +interface ProcessOwnerLockAcquisition { + acquired: boolean + liveOwner?: { + owner: ProcessOwner + observed: string + } +} + +export function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code +} + +function isTransientFileContentionError(error: unknown): boolean { + return hasErrorCode(error, "EPERM") || hasErrorCode(error, "EACCES") || hasErrorCode(error, "EBUSY") +} + +export function parseProcessOwner(value: string): ProcessOwner | undefined { + try { + return normalizeProcessOwner(JSON.parse(value)) + } catch { + // Incomplete process files are handled conservatively using their filename owner. + } + return undefined +} + +function normalizeProcessOwner(candidate: unknown): ProcessOwner | undefined { + if (!candidate || typeof candidate !== "object") { + return undefined + } + const owner = candidate as Partial + if (Number.isInteger(owner.pid) && Number(owner.pid) > 0 && typeof owner.runToken === "string" && owner.runToken) { + return { + pid: Number(owner.pid), + runToken: owner.runToken, + ...(typeof owner.processStartIdentity === "string" && owner.processStartIdentity + ? { processStartIdentity: owner.processStartIdentity } + : {}), + } + } + return undefined +} + +function parseAcknowledgedPrimary(value: string): ProcessOwner | undefined { + try { + const candidate = JSON.parse(value) as { primaryOwner?: unknown } + return normalizeProcessOwner(candidate.primaryOwner) + } catch { + return undefined + } +} + +export function isSameProcessOwner(left: ProcessOwner, right: ProcessOwner): boolean { + return left.pid === right.pid && left.runToken === right.runToken +} + +export function isPidAlive(pid: number): boolean { + if (pid === process.pid) { + return true + } + try { + process.kill(pid, 0) + return true + } catch (error) { + return !hasErrorCode(error, "ESRCH") + } +} + +export function hasLiveTauriClient( + tauriDataPath: string, + pidAlive: (pid: number) => boolean = isPidAlive, + processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, + expectedProcess: ExpectedProcessLookup = isExpectedTauriProcess, + upgradedParticipants: readonly ProcessOwner[] = [], +): boolean { + let entries: string[] + try { + entries = readdirSync(tauriDataPath) + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false + throw error + } + return entries.some((name) => { + const match = /^client-state\.running\.(\d+)\..+\.lock$/.exec(name) + if (!match) return false + const pid = Number(match[1]) + if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid)) return false + const liveIdentity = processStartIdentity(pid) + if (liveIdentity && upgradedParticipants.some((owner) => owner.pid === pid && owner.processStartIdentity === liveIdentity)) return false + return expectedProcess(pid) !== false + }) +} + +export function classifyRunningMarker( + markerOwner: ProcessOwner, + currentOwner: ProcessOwner, + pidAlive: (pid: number) => boolean = isPidAlive, + processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, +): RunningMarkerStatus { + if (isSameProcessOwner(markerOwner, currentOwner)) { + return "current" + } + // Two live processes cannot share a PID. A different token therefore belongs to an old run. + if (markerOwner.pid === currentOwner.pid) { + return "stale" + } + if (!pidAlive(markerOwner.pid)) return "stale" + if (markerOwner.processStartIdentity) { + const liveIdentity = processStartIdentity(markerOwner.pid) + if (liveIdentity && liveIdentity !== markerOwner.processStartIdentity) return "stale" + } + return "other-live" +} + +export function getRunningMarkerPath(userDataPath: string, owner: ProcessOwner): string { + return join(userDataPath, `${RUNNING_MARKER_PREFIX}${owner.pid}.${owner.runToken}${RUNNING_MARKER_SUFFIX}`) +} + +function parseRunningMarkerFilename(filename: string): ProcessOwner | undefined { + if (!filename.startsWith(RUNNING_MARKER_PREFIX) || !filename.endsWith(RUNNING_MARKER_SUFFIX)) { + return undefined + } + + const value = filename.slice(RUNNING_MARKER_PREFIX.length, -RUNNING_MARKER_SUFFIX.length) + const separator = value.indexOf(".") + if (separator < 1) { + return undefined + } + const pid = Number(value.slice(0, separator)) + const runToken = value.slice(separator + 1) + if (!Number.isInteger(pid) || pid <= 0 || !runToken) { + return undefined + } + return { pid, runToken } +} + +export function createRunningMarker( + userDataPath: string, + owner: ProcessOwner, + primaryOwner?: ProcessOwner, +): string { + const markerPath = getRunningMarkerPath(userDataPath, owner) + publishProcessFile(markerPath, JSON.stringify(primaryOwner ? { ...owner, primaryOwner } : owner)) + return markerPath +} + +function publishProcessFile(path: string, contents: string): void { + let descriptor: number | undefined + try { + descriptor = openSync(path, "wx", 0o600) + writeFileSync(descriptor, contents, "utf8") + try { + fsyncSync(descriptor) + } catch (error) { + if (!["EINVAL", "ENOTSUP", "ENOSYS"].some((code) => hasErrorCode(error, code))) throw error + } + closeSync(descriptor) + descriptor = undefined + } catch (error) { + if (descriptor !== undefined) { + try { closeSync(descriptor) } catch {} + try { unlinkSync(path) } catch {} + } + throw error + } +} + +function removeFileIfUnchanged(path: string, observed: string): boolean { + try { + if (readFileSync(path, "utf8") !== observed) { + return false + } + unlinkSync(path) + return true + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return false + } + throw error + } +} + +function readFileIfExists(path: string): string | undefined { + try { + return readFileSync(path, "utf8") + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return undefined + throw error + } +} + +function waitForLockRetry(delayMs = LOCK_RETRY_DELAY_MS) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, delayMs)) +} + +function removeContendedFile(path: string, observed: string): void { + try { + removeFileIfUnchanged(path, observed) + } catch (error) { + if (!isTransientFileContentionError(error)) throw error + waitForLockRetry() + } +} + +function acquireProcessOwnerLockWithStatus( + path: string, + owner: ProcessOwner, + waitForLiveOwner: boolean, + pidAlive: (pid: number) => boolean = isPidAlive, + liveOwnerWaitMs = REGISTRATION_LOCK_WAIT_MS, + processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, +): ProcessOwnerLockAcquisition { + const serializedOwner = JSON.stringify(owner) + const waitDeadline = Date.now() + Math.max(0, liveOwnerWaitMs) + let liveOwner: ProcessOwnerLockAcquisition["liveOwner"] + + for (let attempt = 0; ; attempt += 1) { + if ( + (!waitForLiveOwner && attempt >= PRIMARY_LOCK_ACQUIRE_ATTEMPTS) || + (waitForLiveOwner && attempt > 0 && Date.now() >= waitDeadline) + ) { + return { acquired: false, liveOwner } + } + + try { + publishProcessFile(path, serializedOwner) + return { acquired: true } + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error + } + } + + const observed = readFileIfExists(path) + if (observed === undefined) continue + + const existingOwner = parseProcessOwner(observed) + if (existingOwner) { + const status = classifyRunningMarker(existingOwner, owner, pidAlive, processStartIdentity) + if (status === "other-live") { + liveOwner = { owner: existingOwner, observed } + if (!waitForLiveOwner) { + return { acquired: false, liveOwner } + } + const remainingWaitMs = waitDeadline - Date.now() + if (remainingWaitMs <= 0) { + return { acquired: false, liveOwner } + } + waitForLockRetry(Math.min(LOCK_RETRY_DELAY_MS, remainingWaitMs)) + continue + } + } else if (waitForLiveOwner && attempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS - 1) { + // The owner may still be writing a newly-created lock file. + waitForLockRetry() + continue + } + + removeContendedFile(path, observed) + } + + return { acquired: false, liveOwner } +} + +export function removeProcessOwnerLockIfOwned(path: string, owner: ProcessOwner): boolean { + const observed = readFileIfExists(path) + const current = observed === undefined ? undefined : parseProcessOwner(observed) + return Boolean(current && isSameProcessOwner(current, owner) && removeFileIfUnchanged(path, observed!)) +} + +function releaseProcessOwnerLock( + path: string, + owner: ProcessOwner, + onWarning: (message: string, error: unknown) => void, + warning: string, +): void { + try { + removeProcessOwnerLockIfOwned(path, owner) + } catch (error) { + onWarning(warning, error) + } +} + +export function isProcessOwnerLockOwned(path: string, owner: ProcessOwner): boolean { + const value = readFileIfExists(path) + const current = value === undefined ? undefined : parseProcessOwner(value) + return Boolean(current && isSameProcessOwner(current, owner)) +} + +export function removeRunningMarkerIfOwned(markerPath: string, owner: ProcessOwner): boolean { + const filenameOwner = parseRunningMarkerFilename(basename(markerPath)) + if (!filenameOwner || !isSameProcessOwner(filenameOwner, owner)) { + return false + } + + const observed = readFileIfExists(markerPath) + const storedOwner = observed === undefined ? undefined : parseProcessOwner(observed) + return Boolean(storedOwner && isSameProcessOwner(storedOwner, owner) && removeFileIfUnchanged(markerPath, observed!)) +} + +export function cleanStaleRunningMarkers( + userDataPath: string, + currentOwner: ProcessOwner, + pidAlive: (pid: number) => boolean = isPidAlive, + processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, +): boolean { + let hasOtherLiveProcess = false + + for (const filename of readdirSync(userDataPath)) { + const filenameOwner = parseRunningMarkerFilename(filename) + if (!filenameOwner) { + continue + } + + const markerPath = join(userDataPath, filename) + const observed = readFileIfExists(markerPath) + if (observed === undefined) continue + + const storedOwner = parseProcessOwner(observed) + if (storedOwner && !isSameProcessOwner(storedOwner, filenameOwner)) { + const storedStatus = classifyRunningMarker(storedOwner, currentOwner, pidAlive, processStartIdentity) + const filenameStatus = classifyRunningMarker(filenameOwner, currentOwner, pidAlive, processStartIdentity) + if (storedStatus === "other-live" || filenameStatus === "other-live") { + hasOtherLiveProcess = true + } else { + removeFileIfUnchanged(markerPath, observed) + } + continue + } + + const markerOwner = storedOwner ?? filenameOwner + const status = classifyRunningMarker(markerOwner, currentOwner, pidAlive, processStartIdentity) + if (status === "other-live") { + const acknowledgedPrimary = parseAcknowledgedPrimary(observed) + if (!acknowledgedPrimary || !isSameProcessOwner(acknowledgedPrimary, currentOwner)) { + hasOtherLiveProcess = true + } + } else if (status === "stale") { + removeFileIfUnchanged(markerPath, observed) + } + } + + return hasOtherLiveProcess +} + +function hasMatchingLiveRunningMarker( + userDataPath: string, + owner: ProcessOwner, + pidAlive: (pid: number) => boolean, + processStartIdentity: ProcessStartIdentityLookup, +): boolean { + if (!pidAlive(owner.pid)) { + return false + } + + const value = readFileIfExists(getRunningMarkerPath(userDataPath, owner)) + const markerOwner = value === undefined ? undefined : parseProcessOwner(value) + if (!markerOwner || !isSameProcessOwner(markerOwner, owner)) return false + const liveIdentity = markerOwner.processStartIdentity && processStartIdentity(markerOwner.pid) + return !liveIdentity || liveIdentity === markerOwner.processStartIdentity +} + +function acquireMarkerBackedProcessOwnerLock( + userDataPath: string, + path: string, + owner: ProcessOwner, + pidAlive: (pid: number) => boolean, + liveOwnerWaitMs: number, + processStartIdentity: ProcessStartIdentityLookup, +): ProcessOwnerLockAcquisition { + let lastAcquisition: ProcessOwnerLockAcquisition = { acquired: false } + for (let recoveryAttempt = 0; recoveryAttempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS; recoveryAttempt += 1) { + const acquisition = acquireProcessOwnerLockWithStatus( + path, + owner, + true, + pidAlive, + liveOwnerWaitMs, + processStartIdentity, + ) + lastAcquisition = acquisition + if (acquisition.acquired || !acquisition.liveOwner) { + if (acquisition.acquired) return acquisition + continue + } + // An identity-backed owner only reaches this point after an exact identity match + // or an inconclusive lookup. Never steal its lock while its PID remains live. + if (acquisition.liveOwner.owner.processStartIdentity) { + return acquisition + } + if (hasMatchingLiveRunningMarker( + userDataPath, + acquisition.liveOwner.owner, + pidAlive, + processStartIdentity, + )) { + return acquisition + } + removeContendedFile(path, acquisition.liveOwner.observed) + } + return lastAcquisition +} + +export function electClientStateProcess( + userDataPath: string, + owner: ProcessOwner, + paths: ClientStateElectionPaths, + onWarning: (message: string, error: unknown) => void = () => {}, + pidAlive: (pid: number) => boolean = isPidAlive, + registrationLockWaitMs = REGISTRATION_LOCK_WAIT_MS, + onPrimaryLockAcquired: () => void = () => {}, + processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, +): boolean { + let registrationAcquired = false + let registeringOwner: ProcessOwner | undefined + + try { + const registration = acquireMarkerBackedProcessOwnerLock( + userDataPath, + paths.registrationLockPath, + owner, + pidAlive, + registrationLockWaitMs, + processStartIdentity, + ) + registrationAcquired = registration.acquired + registeringOwner = registration.liveOwner?.owner + } catch (error) { + onWarning("failed to acquire registration lock", error) + } + + if (!registrationAcquired) { + try { + createRunningMarker(userDataPath, owner, registeringOwner) + } catch (error) { + onWarning("failed to create running marker", error) + } + return false + } + + try { + let isPrimary = false + let acknowledgedPrimary: ProcessOwner | undefined + try { + const acquisition = acquireMarkerBackedProcessOwnerLock( + userDataPath, + paths.primaryLockPath, + owner, + pidAlive, + registrationLockWaitMs, + processStartIdentity, + ) + isPrimary = acquisition.acquired + acknowledgedPrimary = acquisition.liveOwner?.owner + } catch (error) { + onWarning("failed to acquire primary lock", error) + } + + if (isPrimary) { + try { + onPrimaryLockAcquired() + if (cleanStaleRunningMarkers(userDataPath, owner, pidAlive, processStartIdentity)) { + removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner) + isPrimary = false + } + } catch (error) { + onWarning("failed to inspect running markers", error) + releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock") + isPrimary = false + } + } + + try { + createRunningMarker(userDataPath, owner, acknowledgedPrimary) + } catch (error) { + onWarning("failed to create running marker", error) + if (isPrimary) releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock") + return false + } + + return isPrimary + } finally { + releaseProcessOwnerLock(paths.registrationLockPath, owner, onWarning, "failed to release registration lock") + } +} diff --git a/packages/electron-app/electron/main/client-state.test.ts b/packages/electron-app/electron/main/client-state.test.ts new file mode 100644 index 00000000..18ee8d3a --- /dev/null +++ b/packages/electron-app/electron/main/client-state.test.ts @@ -0,0 +1,282 @@ +import assert from "node:assert/strict" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { ClientStateManager, type ClientStateWriter } from "./client-state" + +function harness(t: test.TestContext, initial?: object) { + const directory = mkdtempSync(join(tmpdir(), "codenomad-state-")) + const statePath = join(directory, "client-state.json") + if (initial) writeFileSync(statePath, JSON.stringify(initial)) + let failing = false + let writes = 0 + const managers: ClientStateManager[] = [] + const create = (writer: ClientStateWriter = async (path, value) => { + writes++ + if (failing) throw new Error("injected write failure") + await writeFile(path, value, "utf8") + }, processOwner?: { pid: number; runToken: string; processStartIdentity: string }) => { + const manager = new ClientStateManager(directory, writer, { crossHostElectionDirectory: join(directory, "election"), processOwner }) + managers.push(manager) + return manager + } + t.after(async () => { + await Promise.all(managers.map((manager) => manager.drainAndReleasePrimary().catch(() => {}))) + rmSync(directory, { recursive: true, force: true }) + }) + return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes } +} + +test("renderer access is exclusive per document and resettable", async (t) => { + const manager = harness(t, { version: 1, restoreEnabled: true }).create() + assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/) + assert.throws(() => manager.assertRendererAccessToken("unclaimed"), /has not been claimed/) + assert.equal(manager.claimClientStateAccess("document-1"), true) + assert.equal(manager.claimClientStateAccess("document-1"), true) + assert.throws(() => manager.claimClientStateAccess("document-2"), /does not match/) + manager.assertRendererAccessToken("document-1") + assert.equal(await manager.saveClientState({ saved: true }), true) + manager.resetRendererAccessToken() + assert.throws(() => manager.assertRendererAccessToken("document-1"), /has not been claimed/) + assert.equal(manager.claimClientStateAccess("document-2"), true) +}) + +test("restore defaults on unless explicitly disabled", (t) => { + assert.equal(harness(t).create().loadClientState().restoreEnabled, true) + assert.equal(harness(t, { version: 1 }).create().loadClientState().restoreEnabled, true) + assert.equal(harness(t, { version: 1, restoreEnabled: false }).create().loadClientState().restoreEnabled, false) +}) + +test("cross-host ownership is required in addition to each host-local election", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-cross-host-state-")) + const electronDirectory = join(root, "electron"), tauriDirectory = join(root, "tauri"), election = join(root, "election") + t.after(() => rmSync(root, { recursive: true, force: true })) + const identities = new Map([[8101, "tauri-start"], [8102, "electron-start"], [8103, "successor-start"]]) + const crossHostDependencies = { pidAlive: (pid: number) => identities.has(pid), processStartIdentity: (pid: number) => identities.get(pid) } + const primary = new ClientStateManager(tauriDirectory, undefined, { + crossHostElectionDirectory: election, + crossHostDependencies, + processOwner: { pid: 8101, runToken: "tauri", processStartIdentity: "tauri-start" }, + }) + mkdirSync(electronDirectory) + writeFileSync(join(electronDirectory, "client-state.json"), JSON.stringify({ + version: 1, + restoreEnabled: true, + snapshot: { tabs: ["must-not-restore"] }, + })) + const secondary = new ClientStateManager(electronDirectory, undefined, { + crossHostElectionDirectory: election, + crossHostDependencies, + processOwner: { pid: 8102, runToken: "electron", processStartIdentity: "electron-start" }, + }) + assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + await secondary.drainAndReleasePrimary() + + await primary.drainAndReleasePrimary() + identities.delete(8101); identities.delete(8102) + const successor = new ClientStateManager(electronDirectory, undefined, { + crossHostElectionDirectory: election, + crossHostDependencies, + processOwner: { pid: 8103, runToken: "successor", processStartIdentity: "successor-start" }, + }) + assert.equal(successor.isPrimary, true) + await successor.drainAndReleasePrimary() +}) + +test("first shared primary deterministically migrates legacy host envelopes", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election") + mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { revision: 999, savedAt: 10, host: "electron" } })) + writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ + version: 1, + restoreEnabled: true, + snapshot: { savedAt: 20, host: "tauri" }, + window: { bounds: { x: 10, y: 10, width: 1000, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }, + })) + const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) + assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 20, host: "tauri" }) + assert.equal(manager.getWindowState(), undefined) + assert.equal(existsSync(join(electron, "client-state.json")), false) + assert.equal(existsSync(join(tauri, "client-state.json")), false) + await manager.drainAndReleasePrimary() +}) + +test("legacy migration prefers disabled and ignores malformed candidates", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election") + mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), "malformed") + writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: false, snapshot: { savedAt: 1 } })) + const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.equal(JSON.parse(readFileSync(join(root, "shared", "client-state.json"), "utf8")).restoreEnabled, false) + await manager.drainAndReleasePrimary() +}) + +test("legacy migration does not resurrect a snapshot after clear", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election") + mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true })) + writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 20 } })) + const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) + assert.equal(manager.loadClientState().snapshot, null) + await manager.drainAndReleasePrimary() +}) + +test("legacy cleanup failure cannot abort startup after shared state replacement", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), shared = join(root, "shared"), election = join(shared, "election") + mkdirSync(electron, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 10 } })) + + const manager = new ClientStateManager(electron, undefined, { + crossHostElectionDirectory: election, + removeLegacyState: () => { throw new Error("injected cleanup failure") }, + }) + assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 10 }) + assert.equal(existsSync(join(shared, "client-state.json")), true) + await manager.drainAndReleasePrimary() +}) + +test("ownership loss immediately disables restore reads and mutations", async (t) => { + const h = harness(t, { + version: 1, + restoreEnabled: true, + snapshot: { tabs: ["must-stop"] }, + window: { width: 900, height: 700 }, + }) + const manager = h.create() + writeFileSync(join(h.directory, "election", "primary.owner.json", "owner.json"), "malformed") + assert.equal(manager.isPrimary, false) + assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + assert.equal(manager.getWindowState(), undefined) + assert.equal(await manager.saveClientState({ ignored: true }), false) +}) + +test("failed preference and clear writes roll memory and suppression back", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + await manager.saveClientState({ kept: true }) + h.fail(true) + await assert.rejects(manager.setRestoreEnabled(false), /injected write failure/) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: { kept: true } }) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).restoreEnabled, true) + await assert.rejects(manager.clearClientState(), /injected write failure/) + h.fail(false) + await manager.setRestoreEnabled(true) + const before = h.writes() + await manager.saveClientState({ replacement: true }) + assert.equal(h.writes(), before + 1) + assert.deepEqual(manager.loadClientState().snapshot, { replacement: true }) +}) + +test("successful clear suppresses saves, including after failed re-enable", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + await manager.saveClientState({ kept: true }) + await manager.clearClientState() + h.fail(true) + await assert.rejects(manager.setRestoreEnabled(true), /injected write failure/) + const before = h.writes() + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(h.writes(), before) + assert.equal(manager.loadClientState().snapshot, null) +}) + +test("disabling restore atomically removes snapshot/window and survives restart", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create(undefined, { pid: process.pid, runToken: "before-restart", processStartIdentity: "old-start" }) + await manager.saveClientState({ kept: true }) + await manager.saveWindowState({ bounds: { x: 10, y: 20, width: 1200, height: 800 }, maximized: true, fullscreen: false, zoomFactor: 1.25 }) + const before = h.writes() + assert.equal(await manager.setRestoreEnabled(false), true) + assert.equal(h.writes(), before + 1) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.equal(manager.getWindowState(), undefined) + const disabled = JSON.stringify({ version: 1, restoreEnabled: false }) + assert.equal(readFileSync(h.statePath, "utf8"), disabled) + await manager.drainAndReleasePrimary() + const restarted = h.create() + assert.equal(await restarted.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }), true) + assert.equal(readFileSync(h.statePath, "utf8"), disabled) +}) + +test("drain freezes mutations and waits for admitted writes", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const manager = h.create(async (path, value) => { await writeFile(path, value); started(); await gate }) + const admitted = manager.saveClientState({ admitted: true }) + await began + let settled = false + const drain = manager.drainAndReleasePrimary().finally(() => { settled = true }) + await assert.rejects(manager.saveClientState({ late: true }), /frozen for shutdown/) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(settled, false) + assert.equal(manager.isPrimary, true) + release() + await Promise.all([admitted, drain]) + assert.equal(manager.isPrimary, false) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { admitted: true }) +}) + +test("an old writer cannot replace a successor after PID reuse", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const old = h.create( + async (path, value) => { await writeFile(path, value); started(); await gate }, + { pid: process.pid, runToken: "old-run", processStartIdentity: "old-start" }, + ) + const staleWrite = old.saveClientState({ stale: true }) + await began + const oldDrain = old.drainAndReleasePrimary() + const successor = h.create() + assert.equal(successor.isPrimary, true) + await successor.saveClientState({ successor: true }) + release() + await assert.rejects(staleWrite, /ownership changed before atomic replacement/) + await assert.rejects(oldDrain, /ownership changed before atomic replacement/) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { successor: true }) +}) + +test("future envelopes are preserved until a successful explicit clear", async (t) => { + const future = { version: 7, restoreEnabled: false, snapshot: { future: true }, futurePreference: "keep" } + const h = harness(t, future) + const manager = h.create(undefined, { pid: process.pid, runToken: "future-before-restart", processStartIdentity: "old-start" }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(await manager.setRestoreEnabled(false), false) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) + await manager.drainAndReleasePrimary() + const restarted = h.create() + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) + assert.equal(await restarted.clearClientState(), true) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), { version: 1, restoreEnabled: false }) + assert.equal(await restarted.saveClientState({ supported: true }), true) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { supported: true }) +}) + +test("failed future-envelope clear leaves persistence blocked", async (t) => { + const future = { version: 7, future: true } + const h = harness(t, future) + let writes = 0 + const manager = h.create(async () => { writes++; throw new Error("clear failed") }) + await assert.rejects(manager.clearClientState(), /clear failed/) + assert.equal(await manager.setRestoreEnabled(false), false) + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(writes, 1) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) +}) diff --git a/packages/electron-app/electron/main/client-state.ts b/packages/electron-app/electron/main/client-state.ts new file mode 100644 index 00000000..98b09320 --- /dev/null +++ b/packages/electron-app/electron/main/client-state.ts @@ -0,0 +1,511 @@ +import { randomUUID } from "node:crypto" +import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs" +import { open, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import { + electClientStateProcess, + getRunningMarkerPath, + hasLiveTauriClient, + hasErrorCode, + isProcessOwnerLockOwned, + removeProcessOwnerLockIfOwned, + type ProcessOwner, + removeRunningMarkerIfOwned, +} from "./client-state-process" +import { getProcessStartIdentity } from "./client-state-process-identity" +import { + CrossHostRegistration, + crossHostParticipants, + resolveCrossHostElectionDirectory, + resolveCrossHostStatePath, + resolveLegacyTauriDataDirectory, + type CrossHostLeaseDependencies, +} from "./client-state-cross-host" +import { normalizeNativeWindowState } from "./window-state" + +const CLIENT_STATE_VERSION = 1 +const CLIENT_STATE_FILENAME = "client-state.json" +const PRIMARY_LOCK_FILENAME = "client-state.primary.lock" +const REGISTRATION_LOCK_FILENAME = "client-state.registration.lock" + +export const MAX_CLIENT_SNAPSHOT_BYTES = 1024 * 1024 + +export interface WindowBounds { + x: number + y: number + width: number + height: number +} + +export interface NativeWindowState { + bounds: WindowBounds + maximized: boolean + fullscreen: boolean + zoomFactor: number +} + +export interface ClientStateLoadResult { + isPrimary: boolean + restoreEnabled: boolean + snapshot: unknown | null +} + +interface PersistedClientState { + version: typeof CLIENT_STATE_VERSION + restoreEnabled: boolean + snapshot?: unknown + window?: NativeWindowState +} + +export type ClientStateWriter = ( + temporaryPath: string, + serializedState: string, +) => Promise + +interface ClientStateManagerOptions { + crossHostElectionDirectory?: string + crossHostDependencies?: CrossHostLeaseDependencies + legacyTauriDataPath?: string | null + processOwner?: ProcessOwner + removeLegacyState?(path: string): void +} + +async function writeClientStateTemporary(temporaryPath: string, serializedState: string): Promise { + const file = await open(temporaryPath, "w", 0o600) + try { + await file.writeFile(serializedState, "utf8") + await file.sync() + } finally { + await file.close() + } +} + +interface ParsedClientState { + state: PersistedClientState + unsupportedFutureEnvelope: boolean +} + +function parseClientState(value: string): ParsedClientState { + const defaults: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } + try { + const candidate = JSON.parse(value) as Record + if (candidate && typeof candidate.version === "number" && candidate.version > CLIENT_STATE_VERSION) { + return { state: { ...defaults, restoreEnabled: false }, unsupportedFutureEnvelope: true } + } + if (!candidate || candidate.version !== CLIENT_STATE_VERSION) { + return { state: defaults, unsupportedFutureEnvelope: false } + } + + const state: PersistedClientState = { + version: CLIENT_STATE_VERSION, + restoreEnabled: typeof candidate.restoreEnabled === "boolean" ? candidate.restoreEnabled : true, + } + if (Object.prototype.hasOwnProperty.call(candidate, "snapshot")) { + state.snapshot = candidate.snapshot + } + const windowState = normalizeNativeWindowState(candidate.window) + if (windowState) { + state.window = windowState + } + return { state, unsupportedFutureEnvelope: false } + } catch (error) { + console.warn("[client-state] ignored invalid state file", error) + return { state: defaults, unsupportedFutureEnvelope: false } + } +} + +function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: PersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined { + try { + const candidate = JSON.parse(readFileSync(path, "utf8")) as Record + if (!candidate || candidate.version !== CLIENT_STATE_VERSION) return undefined + const parsed = parseClientState(JSON.stringify(candidate)).state + delete parsed.window + const snapshot = candidate.snapshot as Record | undefined + const savedAt = typeof snapshot?.savedAt === "number" && Number.isFinite(snapshot.savedAt) ? snapshot.savedAt : -1 + return { host, state: parsed, savedAt, hasSnapshot: snapshot !== undefined } + } catch { + return undefined + } +} + +function isFutureLegacyCandidate(path: string): boolean { + try { + const candidate = JSON.parse(readFileSync(path, "utf8")) as Record + return typeof candidate?.version === "number" && candidate.version > CLIENT_STATE_VERSION + } catch { + return false + } +} + +export class ClientStateManager { + private readonly userDataPath: string + private readonly statePath: string + private readonly lockPath: string + private readonly legacyTauriDataPath: string | null + private readonly owner: ProcessOwner + private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } + private writeQueue: Promise = Promise.resolve() + private drainAndReleasePromise: Promise | undefined + private crossHostRegistration: CrossHostRegistration | undefined + private primary = false + private persistenceSuppressed = false + private unsupportedFutureEnvelope = false + private frozen = false + private rendererAccessToken: string | undefined + + constructor( + userDataPath: string, + private readonly writeState: ClientStateWriter = writeClientStateTemporary, + options?: ClientStateManagerOptions, + ) { + this.owner = options?.processOwner ?? { + pid: process.pid, + runToken: randomUUID(), + processStartIdentity: getProcessStartIdentity(process.pid), + } + mkdirSync(userDataPath, { recursive: true }) + this.userDataPath = userDataPath + const crossHostElectionDirectory = options?.crossHostElectionDirectory ?? resolveCrossHostElectionDirectory() + this.statePath = options?.crossHostElectionDirectory + ? join(dirname(crossHostElectionDirectory), CLIENT_STATE_FILENAME) + : resolveCrossHostStatePath() + mkdirSync(dirname(this.statePath), { recursive: true }) + this.lockPath = join(userDataPath, PRIMARY_LOCK_FILENAME) + const registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME) + + const election = electClientStateProcess( + userDataPath, + this.owner, + { primaryLockPath: this.lockPath, registrationLockPath }, + (message, error) => console.warn(`[client-state] ${message}`, error), + ) + const legacyTauriDataPath = options?.legacyTauriDataPath === undefined + ? (options?.crossHostElectionDirectory ? null : resolveLegacyTauriDataDirectory()) + : options.legacyTauriDataPath + this.legacyTauriDataPath = legacyTauriDataPath + this.primary = election + try { + this.crossHostRegistration = CrossHostRegistration.register( + crossHostElectionDirectory, + this.owner, + () => { + if (!this.primary || !legacyTauriDataPath) return this.primary + try { + return !hasLiveTauriClient( + legacyTauriDataPath, + options?.crossHostDependencies?.pidAlive, + options?.crossHostDependencies?.processStartIdentity, + undefined, + crossHostParticipants(crossHostElectionDirectory), + ) + } catch (error) { + console.warn("[client-state] failed to inspect legacy Tauri process markers; continuing as secondary", error) + return false + } + }, + options?.crossHostDependencies, + ) + } catch (error) { + console.warn("[client-state] failed to register cross-host ownership", error) + } + if (!this.crossHostRegistration?.isPrimary) { + if (election) removeProcessOwnerLockIfOwned(this.lockPath, this.owner) + this.primary = false + } + if (this.isPrimary) { + const legacyPaths = [ + ["electron", join(userDataPath, CLIENT_STATE_FILENAME)], + ...(legacyTauriDataPath ? [["tauri", join(legacyTauriDataPath, CLIENT_STATE_FILENAME)] as const] : []), + ] as ReadonlyArray + this.migrateLegacyStateIfNeeded(legacyPaths, options?.removeLegacyState) + const futureLegacyBlocked = this.unsupportedFutureEnvelope + const persisted = this.readState() + this.state = futureLegacyBlocked + ? { version: CLIENT_STATE_VERSION, restoreEnabled: false } + : persisted.state + this.persistenceSuppressed = !this.state.restoreEnabled + this.unsupportedFutureEnvelope = futureLegacyBlocked || persisted.unsupportedFutureEnvelope + } + } + + get isPrimary(): boolean { + if (!this.primary || !this.crossHostRegistration?.isPrimary) return false + if (!this.legacyTauriDataPath) return true + try { + return !hasLiveTauriClient(this.legacyTauriDataPath, undefined, undefined, undefined, crossHostParticipants(this.crossHostRegistration.path)) + } catch (error) { + console.warn("[client-state] failed to recheck legacy Tauri process markers; ownership disabled", error) + return false + } + } + + loadClientState(): ClientStateLoadResult { + if (!this.isPrimary) { + return { isPrimary: false, restoreEnabled: false, snapshot: null } + } + return { + isPrimary: true, + restoreEnabled: this.state.restoreEnabled, + snapshot: this.state.restoreEnabled ? (this.state.snapshot ?? null) : null, + } + } + + getWindowState(): NativeWindowState | undefined { + return this.isPrimary && !this.unsupportedFutureEnvelope && this.state.restoreEnabled ? this.state.window : undefined + } + + claimClientStateAccess(token: unknown): true { + this.validateRendererAccessTokenValue(token) + if (this.rendererAccessToken === undefined) { + this.rendererAccessToken = token + return true + } + if (this.rendererAccessToken !== token) { + throw new Error("Client state access token does not match the claimed renderer") + } + return true + } + + assertRendererAccessToken(token: unknown): void { + this.validateRendererAccessTokenValue(token) + if (this.rendererAccessToken === undefined || this.rendererAccessToken !== token) { + throw new Error("Client state access has not been claimed by this renderer") + } + } + + resetRendererAccessToken(): void { + this.rendererAccessToken = undefined + } + + saveClientState(snapshot: unknown, rendererToken?: unknown): Promise { + const disposition = this.getMutationDisposition() + if (disposition) return disposition + + const serialized = JSON.stringify(snapshot) + if (serialized === undefined) { + throw new TypeError("Client snapshot must be JSON-serializable") + } + if (Buffer.byteLength(serialized, "utf8") > MAX_CLIENT_SNAPSHOT_BYTES) { + throw new RangeError("Client snapshot exceeds the 1 MiB limit") + } + + const normalizedSnapshot = JSON.parse(serialized) as unknown + return this.mutateAndPersist((state) => { + state.snapshot = normalizedSnapshot + }, true, rendererToken) + } + + setRestoreEnabled(enabled: boolean, rendererToken?: unknown): Promise { + const disposition = this.getMutationDisposition(false) + if (disposition) return disposition + + if (typeof enabled !== "boolean") { + throw new TypeError("Restore enabled must be a boolean") + } + + return this.mutateAndPersist((state) => { + state.restoreEnabled = enabled + if (enabled) { + this.persistenceSuppressed = false + } else { + delete state.snapshot + delete state.window + this.persistenceSuppressed = true + } + }, false, rendererToken) + } + + clearClientState(rendererToken?: unknown): Promise { + if (!this.isPrimary) { + return Promise.resolve(false) + } + if (this.frozen) { + return Promise.reject(new Error("Client state persistence is frozen for shutdown")) + } + + const clearingFutureEnvelope = this.unsupportedFutureEnvelope + + return this.mutateAndPersist((state) => { + delete state.snapshot + delete state.window + this.unsupportedFutureEnvelope = false + this.persistenceSuppressed = !clearingFutureEnvelope + }, false, rendererToken) + } + + saveWindowState(windowState: NativeWindowState): Promise { + const disposition = this.getMutationDisposition() + if (disposition) return disposition + + const normalized = normalizeNativeWindowState(windowState) + if (!normalized) { + return Promise.resolve(false) + } + return this.mutateAndPersist((state) => { + state.window = normalized + }, true) + } + + async flush(): Promise { + await this.writeQueue + } + + drainAndReleasePrimary(): Promise { + if (this.drainAndReleasePromise) { + return this.drainAndReleasePromise + } + + this.frozen = true + this.drainAndReleasePromise = this.writeQueue.finally(() => { + this.primary = false + this.releaseOwnedProcessFiles() + }) + return this.drainAndReleasePromise + } + + private readState(): ParsedClientState { + try { + return parseClientState(readFileSync(this.statePath, "utf8")) + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + console.warn("[client-state] failed to read state", error) + } + return { + state: { version: CLIENT_STATE_VERSION, restoreEnabled: true }, + unsupportedFutureEnvelope: false, + } + } + } + + private migrateLegacyStateIfNeeded( + paths: ReadonlyArray, + removeLegacyState = (path: string) => rmSync(path, { force: true }), + ): void { + try { + readFileSync(this.statePath) + return + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) return + } + if (paths.some(([, path]) => isFutureLegacyCandidate(path))) { + this.unsupportedFutureEnvelope = true + return + } + const winner = paths + .map(([host, path]) => legacyCandidate(path, host)) + .filter((candidate): candidate is NonNullable => Boolean(candidate)) + .sort((left, right) => + Number(left.state.restoreEnabled) - Number(right.state.restoreEnabled) || + Number(left.hasSnapshot) - Number(right.hasSnapshot) || + right.savedAt - left.savedAt || + right.host.localeCompare(left.host), + )[0] + if (!winner) return + + const temporaryPath = join(dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.migration.tmp`) + let descriptor: number | undefined + try { + descriptor = openSync(temporaryPath, "wx", 0o600) + writeFileSync(descriptor, JSON.stringify(winner.state), "utf8") + fsyncSync(descriptor) + closeSync(descriptor) + descriptor = undefined + this.assertReplacementAllowed() + renameSync(temporaryPath, this.statePath) + for (const [, path] of paths) { + try { + removeLegacyState(path) + } catch (error) { + console.warn(`[client-state] failed to remove migrated legacy state at ${path}`, error) + } + } + } finally { + if (descriptor !== undefined) closeSync(descriptor) + rm(temporaryPath, { force: true }).catch(() => {}) + } + } + + private getMutationDisposition(futureEnvelopeResult = true): Promise | undefined { + if (!this.isPrimary) { + return Promise.resolve(false) + } + if (this.frozen) { + return Promise.reject(new Error("Client state persistence is frozen for shutdown")) + } + if (this.unsupportedFutureEnvelope) { + return Promise.resolve(futureEnvelopeResult) + } + return undefined + } + + private mutateAndPersist( + mutate: (state: PersistedClientState) => void, + skipWhenSuppressed = false, + rendererToken?: unknown, + ): Promise { + const operation = this.writeQueue.catch(() => {}).then(async () => { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) + if (skipWhenSuppressed && this.persistenceSuppressed) { + return + } + + const previousState = { ...this.state } + const previousPersistenceSuppressed = this.persistenceSuppressed + const previousUnsupportedFutureEnvelope = this.unsupportedFutureEnvelope + try { + mutate(this.state) + await this.writeAtomically(JSON.stringify(this.state), rendererToken) + } catch (error) { + this.state = previousState + this.persistenceSuppressed = previousPersistenceSuppressed + this.unsupportedFutureEnvelope = previousUnsupportedFutureEnvelope + throw error + } + }) + this.writeQueue = operation + return operation.then(() => true) + } + + private async writeAtomically(serializedState: string, rendererToken?: unknown): Promise { + const temporaryPath = join( + dirname(this.statePath), + `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.tmp`, + ) + try { + await this.writeState(temporaryPath, serializedState) + this.assertReplacementAllowed(rendererToken) + await rename(temporaryPath, this.statePath) + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => {}) + throw error + } + } + + private assertReplacementAllowed(rendererToken?: unknown): void { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) + if (!this.isPrimary || !isProcessOwnerLockOwned(this.lockPath, this.owner)) { + throw new Error("Client state ownership changed before atomic replacement") + } + } + + private releaseOwnedProcessFiles(): void { + const releases: Array<[string, () => void]> = [ + ["remove running marker", () => { removeRunningMarkerIfOwned(getRunningMarkerPath(this.userDataPath, this.owner), this.owner) }], + ["release primary lock", () => { removeProcessOwnerLockIfOwned(this.lockPath, this.owner) }], + ["release cross-host registration", () => { this.crossHostRegistration?.release(); this.crossHostRegistration = undefined }], + ] + for (const [action, release] of releases) { + try { + release() + } catch (error) { + console.warn(`[client-state] failed to ${action}`, error) + } + } + } + + private validateRendererAccessTokenValue(token: unknown): asserts token is string { + if (typeof token !== "string" || token.trim().length === 0) { + throw new TypeError("Client state access token must be a nonempty string") + } + } +} diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 076ec8ca..6e664d90 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -41,8 +41,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan ipcMain.handle("cli:restart", async () => { const devMode = process.env.NODE_ENV === "development" - await cliManager.stop() - return cliManager.start({ dev: devMode }) + return cliManager.restart({ dev: devMode }) }) ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index c4e01e29..f90fb361 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,13 +1,26 @@ -import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" +import { app, BrowserView, BrowserWindow, ipcMain, nativeImage, screen, session, shell } from "electron" import http from "node:http" import https from "node:https" import { existsSync, mkdirSync, rmSync } from "fs" import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" +import { ClientStateManager } from "./client-state" +import { setupClientStateIPC } from "./client-state-ipc" +import { ClientStateLifecycle } from "./client-state-lifecycle" +import { ClientStateNavigationController } from "./client-state-navigation" import { setupCliIPC } from "./ipc" -import { configureMediaPermissionHandlers } from "./permissions" +import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" +import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { CliProcessManager } from "./process-manager" +import { + clampWindowBounds, + DEFAULT_WINDOW_HEIGHT, + DEFAULT_WINDOW_WIDTH, + installWindowZoomInput, + restoreWindowState, + WindowStateTracker, +} from "./window-state" const mainFilename = fileURLToPath(import.meta.url) const mainDirname = dirname(mainFilename) @@ -82,6 +95,7 @@ function cleanupPackagedChromiumStorage() { cleanupPackagedChromiumStorage() +const clientStateManager = new ClientStateManager(app.getPath("userData")) const cliManager = new CliProcessManager() let mainWindow: BrowserWindow | null = null let currentCliUrl: string | null = null @@ -89,8 +103,24 @@ let pendingCliUrl: string | null = null let pendingBootstrapToken: string | null = null let showingLoadingScreen = false let preloadingView: BrowserView | null = null +let mainNavigationController: ClientStateNavigationController | null = null const remoteWindowOrigins = new Map>() const insecureWindowOrigins = new Map>() +const clientStateLifecycle = new ClientStateLifecycle({ + app, + clientStateManager, + cliManager, + getMainWindow: () => mainWindow, + getAllWindows: () => BrowserWindow.getAllWindows(), + getAllowedRendererOrigins, + isTrustedRendererOrigin: isAllowedRendererOrigin, +}) +const bindClientStateWindow = setupClientStateIPC( + ipcMain, + clientStateManager, + () => mainWindow, + getAllowedRendererOrigins, +) if (isMac) { app.commandLine.appendSwitch("disable-spell-checking") @@ -151,19 +181,18 @@ function resolveLoadingFilePath() { return join(app.getAppPath(), "dist/renderer/loading.html") } -function loadLoadingScreen(window: BrowserWindow) { +async function loadLoadingScreen(window: BrowserWindow): Promise { const target = resolveLoadingTarget() - const loader = - target.type === "url" - ? window.loadURL(target.source) - : window.loadFile(target.source) - - loader.catch((error) => { + try { + await (target.type === "url" ? window.loadURL(target.source) : window.loadFile(target.source)) + return true + } catch (error) { if (isIgnorableNavigationError(error)) { - return + return false } console.error("[cli] failed to load loading screen:", error) - }) + return false + } } function isIgnorableNavigationError(error: unknown): boolean { @@ -183,16 +212,11 @@ function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { origins.add(origin) } } - const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL] - for (const candidate of rendererCandidates) { - if (!candidate) { - continue - } - try { - origins.add(new URL(candidate).origin) - } catch (error) { - console.warn("[cli] failed to parse origin for", candidate, error) - } + for (const origin of resolveConfiguredRendererOrigins(currentCliUrl, app.isPackaged, [ + process.env.VITE_DEV_SERVER_URL, + process.env.ELECTRON_RENDERER_URL, + ])) { + origins.add(origin) } return Array.from(origins) } @@ -210,7 +234,7 @@ function shouldOpenExternally(url: string, window?: BrowserWindow | null): boole } } -function setupNavigationGuards(window: BrowserWindow) { +function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) { const handleExternal = (url: string) => { shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) } @@ -224,6 +248,20 @@ function setupNavigationGuards(window: BrowserWindow) { }) window.webContents.on("will-navigate", (event, url) => { + if (shouldOpenExternally(url, window)) { + event.preventDefault() + handleExternal(url) + } else if (navigationController) { + event.preventDefault() + void navigationController.navigate((target) => target.loadURL(url)).catch((error) => { + if (!isIgnorableNavigationError(error)) { + console.error("[client-state] trusted renderer navigation failed", error) + } + }) + } + }) + + window.webContents.on("will-redirect", (event, url) => { if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) @@ -240,6 +278,21 @@ function setWindowAllowedOrigin(window: BrowserWindow, url: string) { } } +function stageWindowAllowedOrigin(window: BrowserWindow, url: string): () => void { + const previous = remoteWindowOrigins.get(window.id) + try { + const origins = new Set(previous) + origins.add(new URL(url).origin) + remoteWindowOrigins.set(window.id, origins) + } catch (error) { + console.warn("[cli] failed to stage allowed origin", url, error) + } + return () => { + if (previous) remoteWindowOrigins.set(window.id, previous) + else remoteWindowOrigins.delete(window.id) + } +} + function clearWindowAllowedOrigin(window: BrowserWindow) { remoteWindowOrigins.delete(window.id) } @@ -320,10 +373,19 @@ function createWindow() { const prefersDark = true const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" const iconPath = getIconPath() + const savedWindowState = clientStateManager.getWindowState() + const restoredBounds = savedWindowState + ? clampWindowBounds( + savedWindowState.bounds, + screen.getAllDisplays().map((display) => display.workArea), + ) + : undefined mainWindow = new BrowserWindow({ - width: 1400, - height: 900, + width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH, + height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT, + useContentSize: true, + ...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}), minWidth: 800, minHeight: 600, backgroundColor, @@ -332,14 +394,36 @@ function createWindow() { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, + ...(savedWindowState ? { zoomFactor: savedWindowState.zoomFactor } : {}), spellcheck: !isMac, additionalArguments: ["--codenomad-window-context=local"], }, }) const window = mainWindow + const navigationController = new ClientStateNavigationController( + window, + { + clientStateManager, + isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedRendererOrigins(window)), + reportFlushError: (error) => { + console.warn("[client-state] renderer pre-navigation flush failed; continuing navigation", error) + }, + }, + ) + mainNavigationController = navigationController - setupNavigationGuards(window) + let windowStateTracker: WindowStateTracker | null = null + if (clientStateManager.isPrimary) { + restoreWindowState(window, savedWindowState, restoredBounds) + windowStateTracker = new WindowStateTracker(window, clientStateManager, savedWindowState) + } + installWindowZoomInput(window, (level) => { + if (windowStateTracker) windowStateTracker.setZoomLevel(level) + else window.webContents.setZoomLevel(level) + }) + + setupNavigationGuards(window, navigationController) if (isMac) { window.webContents.session.setSpellCheckerEnabled(false) @@ -348,23 +432,34 @@ function createWindow() { showingLoadingScreen = true currentCliUrl = null clearWindowAllowedOrigin(window) - loadLoadingScreen(window) + void loadLoadingScreen(window) if (process.env.NODE_ENV === "development") { window.webContents.openDevTools({ mode: "detach" }) } - createApplicationMenu(window) + createApplicationMenu(window, { + reload: () => { + void navigationController.navigate((target) => target.webContents.reload()) + }, + forceReload: () => { + void navigationController.navigate((target) => target.webContents.reloadIgnoringCache()) + }, + }) setupCliIPC(window, cliManager) + bindClientStateWindow(window) + clientStateLifecycle.attachMainWindow(window, windowStateTracker) window.on("closed", () => { destroyPreloadingView() clearWindowAllowedOrigin(window) clearWindowInsecureOrigin(window) mainWindow = null + if (mainNavigationController === navigationController) mainNavigationController = null currentCliUrl = null pendingCliUrl = null showingLoadingScreen = false + clientStateLifecycle.detachMainWindow(window) }) if (pendingCliUrl) { @@ -383,11 +478,19 @@ function showLoadingScreen(force = false) { return } - destroyPreloadingView() + const window = mainWindow + const wasShowingLoadingScreen = showingLoadingScreen showingLoadingScreen = true - currentCliUrl = null + destroyPreloadingView() pendingCliUrl = null - loadLoadingScreen(mainWindow) + void mainNavigationController?.navigate(async (target) => { + if (!(await loadLoadingScreen(target))) { + showingLoadingScreen = wasShowingLoadingScreen + return + } + currentCliUrl = null + clearWindowAllowedOrigin(target) + }) } function isBootstrapTokenUrl(url: string): boolean { @@ -460,16 +563,23 @@ function finalizeCliSwap(url: string) { return } - const window = mainWindow - showingLoadingScreen = false - currentCliUrl = url - setWindowAllowedOrigin(window, url) - pendingCliUrl = null - window.loadURL(url).catch((error) => { - if (isIgnorableNavigationError(error)) { - return + const navigate = async (target: BrowserWindow) => { + const rollbackOrigin = stageWindowAllowedOrigin(target, url) + try { + await target.loadURL(url) + } catch (error) { + rollbackOrigin() + throw error } - console.error("[cli] failed to load CLI view:", error) + showingLoadingScreen = false + currentCliUrl = url + setWindowAllowedOrigin(target, url) + pendingCliUrl = null + } + void mainNavigationController?.navigate(navigate).then(() => { + if (cliManager.getStatus().state !== "ready") showLoadingScreen() + }).catch((error) => { + if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load CLI view:", error) }) } @@ -727,13 +837,4 @@ app.whenReady().then(() => { }) }) -app.on("before-quit", async (event) => { - event.preventDefault() - await cliManager.stop().catch(() => {}) - app.exit(0) -}) - -app.on("window-all-closed", () => { - // CodeNomad supports a single window; closing it should quit the app on all platforms. - app.quit() -}) +clientStateLifecycle.registerAppEvents() diff --git a/packages/electron-app/electron/main/menu.ts b/packages/electron-app/electron/main/menu.ts index 37f9e340..7ecbc195 100644 --- a/packages/electron-app/electron/main/menu.ts +++ b/packages/electron-app/electron/main/menu.ts @@ -1,6 +1,11 @@ import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" -export function createApplicationMenu(mainWindow: BrowserWindow) { +interface ApplicationMenuActions { + reload(): void + forceReload(): void +} + +export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) { const isMac = process.platform === "darwin" const template: MenuItemConstructorOptions[] = [ @@ -51,8 +56,8 @@ export function createApplicationMenu(mainWindow: BrowserWindow) { { label: "View", submenu: [ - { role: "reload" as const }, - { role: "forceReload" as const }, + { label: "Reload", accelerator: "CmdOrCtrl+R", click: actions.reload }, + { label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: actions.forceReload }, { role: "toggleDevTools" as const }, { type: "separator" as const }, { role: "resetZoom" as const }, diff --git a/packages/electron-app/electron/main/permissions.ts b/packages/electron-app/electron/main/permissions.ts index 28652321..5e1bf4f8 100644 --- a/packages/electron-app/electron/main/permissions.ts +++ b/packages/electron-app/electron/main/permissions.ts @@ -1,20 +1,10 @@ import { session, systemPreferences } from "electron" +import { isAllowedRendererOrigin } from "./renderer-origin" + +export { isAllowedRendererOrigin } from "./renderer-origin" const isMac = process.platform === "darwin" -export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean { - if (!origin) { - return false - } - - try { - const normalized = new URL(origin).origin - return allowedOrigins.includes(normalized) - } catch { - return false - } -} - export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) { const isAudioMediaRequest = (permission: string, details?: unknown) => { if (permission !== "media") { diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index a1cd35e0..090de0d1 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -1,5 +1,5 @@ -import { spawn, spawnSync, type ChildProcess } from "child_process" -import { app, utilityProcess, type UtilityProcess } from "electron" +import { spawn, type ChildProcess } from "child_process" +import { app } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" import { existsSync, readFileSync } from "fs" @@ -8,6 +8,16 @@ import path from "path" import { fileURLToPath } from "url" import { parse as parseYaml } from "yaml" import { ensureManagedNodeBinary } from "./managed-node" +import { getProcessStartIdentityAsync } from "./client-state-process-identity" +import { + CLI_STOP_DEADLINE_MS, + captureInitialProcessTree, + captureProcessTree, + forceCapturedProcessTree, + mergeCapturedProcessTrees, + stopManagedChild, +} from "./process-stop" +import { SerializedLifecycle } from "./serialized-lifecycle" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) @@ -15,8 +25,9 @@ const mainFilename = fileURLToPath(import.meta.url) const mainDirname = path.dirname(mainFilename) const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" +const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" +const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" const SESSION_COOKIE_NAME_PREFIX = "codenomad_session" - type CliState = "starting" | "ready" | "error" | "stopped" type ListeningMode = "local" | "all" @@ -45,9 +56,6 @@ interface CliEntryResolution { nodeArgs?: string[] } -type ManagedChild = ChildProcess | UtilityProcess -type ChildLaunchMode = "spawn" | "utility" - const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" function isYamlPath(filePath: string): boolean { @@ -127,18 +135,42 @@ export declare interface CliProcessManager { } export class CliProcessManager extends EventEmitter { - private child?: ManagedChild - private childLaunchMode: ChildLaunchMode = "spawn" + private child?: ChildProcess + private childStartIdentity?: Promise private status: CliStatus = { state: "stopped" } private stdoutBuffer = "" private stderrBuffer = "" private bootstrapToken: string | null = null private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` private requestedStop = false + private shutdownStatus: "complete" | "incomplete" | null = null + private lifecycle = new SerializedLifecycle() - async start(options: StartOptions): Promise { + start(options: StartOptions): Promise { + return this.lifecycle.enqueue(() => this.startNow(options)) + } + + restart(options: StartOptions): Promise { + return this.lifecycle.enqueue(async () => { + await this.stopNow() + if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down") + return this.startNow(options) + }) + } + + stop(): Promise { + return this.lifecycle.enqueue(() => this.stopNow()) + } + + shutdown(): Promise { + return this.lifecycle.stop(() => this.stopNow()) + } + + private async startNow(options: StartOptions): Promise { + if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down") if (this.child) { - await this.stop() + await this.stopNow() + if (this.child) throw new Error("CLI process did not exit before restart") } this.stdoutBuffer = "" @@ -146,6 +178,8 @@ export class CliProcessManager extends EventEmitter { this.bootstrapToken = null this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` this.requestedStop = false + this.shutdownStatus = null + this.childStartIdentity = undefined this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) const listeningMode = this.resolveListeningMode() @@ -153,63 +187,32 @@ export class CliProcessManager extends EventEmitter { const args = this.buildCliArgs(options, host) const cliEntry = await this.resolveCliEntry(options) - let child: ManagedChild + console.info( + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, + ) - if (this.shouldUsePackagedShellSupervisor(options)) { - const runtimePath = this.resolveShellNodeCommand() - const entryPath = this.resolveBundledProdEntry() - const supervisorPath = this.resolveCliSupervisorPath() - const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env } - const shellTarget = this.buildCommand(cliEntry, args) - const shellCommand = buildUserShellCommand(`exec ${shellTarget}`) - const supervisorPayload = JSON.stringify({ - command: shellCommand.command, - args: shellCommand.args, - cwd: process.cwd(), - }) + const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } + env.ELECTRON_RUN_AS_NODE = "1" - console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`, - ) - console.info(`[cli] utility supervisor: ${supervisorPath}`) - console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`) + const spawnDetails = supportsUserShell() + ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) + : this.buildDirectSpawn(cliEntry, args) - child = utilityProcess.fork(supervisorPath, [supervisorPayload], { - env: { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" }, - stdio: "pipe", - serviceName: "CodeNomad CLI Supervisor", - }) - this.childLaunchMode = "utility" - } else { - console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, - ) + const child = spawn(spawnDetails.command, spawnDetails.args, { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + env, + shell: false, + detached: process.platform !== "win32", + }) - const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } - env.ELECTRON_RUN_AS_NODE = "1" - - const spawnDetails = supportsUserShell() - ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) - : this.buildDirectSpawn(cliEntry, args) - - const detached = process.platform !== "win32" - child = spawn(spawnDetails.command, spawnDetails.args, { - cwd: process.cwd(), - stdio: ["ignore", "pipe", "pipe"], - env, - shell: false, - detached, - }) - - console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) - this.childLaunchMode = "spawn" - } - - if (this.childLaunchMode === "spawn" && !child.pid) { + console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) + if (!child.pid) { console.error("[cli] spawn failed: no pid") } this.child = child + this.childStartIdentity = child.pid ? getProcessStartIdentityAsync(child.pid, 1_500) : Promise.resolve(undefined) this.updateStatus({ pid: child.pid ?? undefined }) const stdout = child.stdout as NodeJS.ReadableStream | undefined @@ -223,48 +226,25 @@ export class CliProcessManager extends EventEmitter { this.handleStream(data.toString(), "stderr") }) - if (this.childLaunchMode === "utility") { - const utilityChild = child as UtilityProcess + child.on("error", (error) => { + console.error("[cli] failed to start CLI:", error) + this.updateStatus({ state: "error", error: error.message }) + this.emit("error", error) + }) - utilityChild.on("error", (error) => { - const message = this.describeUtilityProcessError(error) - console.error("[cli] utility supervisor failed:", error) - this.updateStatus({ state: "error", error: message }) - this.emit("error", new Error(message)) - }) - - utilityChild.on("exit", (code) => { - const failed = this.status.state !== "ready" - const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined - console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`) - this.updateStatus({ state: failed ? "error" : "stopped", error }) - if (failed && error) { - this.emit("error", new Error(error)) - } - this.emit("exit", this.status) - this.child = undefined - }) - } else { - const spawnedChild = child as ChildProcess - - spawnedChild.on("error", (error) => { - console.error("[cli] failed to start CLI:", error) - this.updateStatus({ state: "error", error: error.message }) - this.emit("error", error) - }) - - spawnedChild.on("exit", (code, signal) => { - const failed = this.status.state !== "ready" - const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined - console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) - this.updateStatus({ state: failed ? "error" : "stopped", error }) - if (failed && error) { - this.emit("error", new Error(error)) - } - this.emit("exit", this.status) - this.child = undefined - }) - } + child.on("exit", (code, signal) => { + if (this.child !== child) return + const failed = this.status.state !== "ready" + const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined + console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) + this.updateStatus({ state: failed ? "error" : "stopped", error }) + if (failed && error) { + this.emit("error", new Error(error)) + } + this.emit("exit", this.status) + this.child = undefined + this.childStartIdentity = undefined + }) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -284,18 +264,14 @@ export class CliProcessManager extends EventEmitter { }) } - async stop(): Promise { + private async stopNow(): Promise { const child = this.child if (!child) { this.updateStatus({ state: "stopped" }) return } - if (this.childLaunchMode === "utility") { - return this.stopUtilityChild(child as UtilityProcess) - } - - const spawnedChild = child as ChildProcess + const spawnedChild = child this.requestedStop = true @@ -308,138 +284,64 @@ export class CliProcessManager extends EventEmitter { const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null - const tryKillPosixGroup = (signal: NodeJS.Signals) => { - try { - // Negative PID targets the process group (POSIX). - process.kill(-pid, signal) - return true - } catch (error) { - const err = error as NodeJS.ErrnoException - if (err?.code === "ESRCH") { - return true - } - return false - } + const deadlineAt = Date.now() + CLI_STOP_DEADLINE_MS + const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined) + const { tree: initialCapture, rootStartIdentity } = await captureInitialProcessTree( + pid, + process.platform, + undefined, + () => spawnedIdentity, + deadlineAt, + ) + let processTree = rootStartIdentity + ? mergeCapturedProcessTrees(undefined, initialCapture, pid, rootStartIdentity) + : initialCapture + let enforcement: Promise | null = null + const forceProcessTree = (enforcementDeadline = deadlineAt) => { + if (enforcement) return enforcement + enforcement = (async () => { + const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, enforcementDeadline - Date.now())) + processTree = mergeCapturedProcessTrees(processTree, latest, pid, rootStartIdentity) + return processTree ? forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt: enforcementDeadline }) : false + })().finally(() => { enforcement = null }) + return enforcement } - const tryKillSinglePid = (signal: NodeJS.Signals) => { - try { - process.kill(pid, signal) - return true - } catch (error) { - const err = error as NodeJS.ErrnoException - if (err?.code === "ESRCH") { - return true - } - return false - } - } - - const tryTaskkill = (force: boolean) => { - const args = ["/PID", String(pid), "/T"] - if (force) { - args.push("/F") - } - - try { - const result = spawnSync("taskkill", args, { encoding: "utf8" }) - const exitCode = result.status - if (exitCode === 0) { - return true - } - - // If the PID is already gone, treat it as success. - const stderr = (result.stderr ?? "").toString().toLowerCase() - const stdout = (result.stdout ?? "").toString().toLowerCase() - const combined = `${stdout}\n${stderr}` - if (combined.includes("not found") || combined.includes("no running instance")) { - return true - } - return false - } catch { - return false - } - } - - const sendStopSignal = (signal: NodeJS.Signals) => { - if (process.platform === "win32") { - tryTaskkill(signal === "SIGKILL") - return - } - - // Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron. - const groupOk = tryKillPosixGroup(signal) - if (!groupOk) { - tryKillSinglePid(signal) - } - } - - return new Promise((resolve) => { - const killTimeout = setTimeout(() => { - console.warn( - `[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`, - ) - sendStopSignal("SIGKILL") - }, 30000) - - spawnedChild.on("exit", () => { - clearTimeout(killTimeout) - this.child = undefined - console.info("[cli] CLI process exited") - this.updateStatus({ state: "stopped" }) - resolve() + let forceConfirmed = false + const enforceIncompleteCleanup = () => { + void forceProcessTree().then((confirmed) => { + forceConfirmed = confirmed + if (!confirmed) console.warn(`[cli] immediate enforcement after incomplete cleanup was not confirmed (pid=${pid})`) + }, (error) => { + console.warn(`[cli] immediate enforcement after incomplete cleanup failed (pid=${pid})`, error) + forceConfirmed = false }) + } + this.once("shutdownIncomplete", enforceIncompleteCleanup) - if (isAlreadyExited()) { - clearTimeout(killTimeout) - this.child = undefined - this.updateStatus({ state: "stopped" }) - resolve() - return - } - - sendStopSignal("SIGTERM") - }) - } - - private stopUtilityChild(child: UtilityProcess): Promise { - this.requestedStop = true - - const pid = child.pid - if (!pid) { + try { + await stopManagedChild({ + child: spawnedChild, + isExited: isAlreadyExited, + force: async (forceDeadline) => forceConfirmed || await forceProcessTree(forceDeadline), + isCleanupComplete: () => this.shutdownStatus === "complete", + deadlineMs: CLI_STOP_DEADLINE_MS, + deadlineAt, + forceReserveMs: 5_000, + warn: (message, error) => console.warn(`[cli] ${message} (pid=${pid})`, error ?? ""), + }) + } finally { + this.off("shutdownIncomplete", enforceIncompleteCleanup) + } + if (this.shutdownStatus !== "complete") { + console.warn(`[cli] CLI exited without a complete graceful-shutdown handshake (status=${this.shutdownStatus ?? "missing"})`) + } + console.info("[cli] CLI process exited") + if (this.child === spawnedChild) { this.child = undefined + this.childStartIdentity = undefined this.updateStatus({ state: "stopped" }) - return Promise.resolve() } - - return new Promise((resolve) => { - const killTimeout = setTimeout(() => { - console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`) - try { - process.kill(pid, "SIGKILL") - } catch { - // no-op - } - }, 30000) - - child.once("exit", () => { - clearTimeout(killTimeout) - this.child = undefined - console.info("[cli] CLI process exited") - this.updateStatus({ state: "stopped" }) - resolve() - }) - - if (child.pid === undefined) { - clearTimeout(killTimeout) - this.child = undefined - this.updateStatus({ state: "stopped" }) - resolve() - return - } - - child.kill() - }) } getStatus(): CliStatus { @@ -455,26 +357,23 @@ export class CliProcessManager extends EventEmitter { } private handleTimeout() { - if (this.child) { - const pid = this.child.pid - if (this.childLaunchMode === "utility") { - if (pid) { - try { - process.kill(pid, "SIGKILL") - } catch { - // no-op + const timedOutChild = this.child + if (timedOutChild) { + const pid = timedOutChild.pid + if (pid) { + const deadlineAt = Date.now() + 5_000 + const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined) + void captureInitialProcessTree(pid, process.platform, undefined, () => spawnedIdentity, deadlineAt).then(async ({ tree, rootStartIdentity }) => { + const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, deadlineAt - Date.now())) + const processTree = mergeCapturedProcessTrees(tree, latest, pid, rootStartIdentity) + const forced = processTree ? await forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt }) : false + if (!forced) console.warn(`[cli] startup-timeout process tree cleanup was not confirmed (pid=${pid})`) + else if (this.child === timedOutChild) { + this.child = undefined + this.childStartIdentity = undefined } - } - } else if (pid && process.platform !== "win32") { - try { - process.kill(-pid, "SIGKILL") - } catch { - ;(this.child as ChildProcess).kill("SIGKILL") - } - } else { - ;(this.child as ChildProcess).kill("SIGKILL") + }).catch((error) => console.warn(`[cli] startup-timeout process tree cleanup failed (pid=${pid})`, error)) } - this.child = undefined } this.updateStatus({ state: "error", error: "CLI did not start in time" }) this.emit("error", new Error("CLI did not start in time")) @@ -505,6 +404,19 @@ export class CliProcessManager extends EventEmitter { const trimmed = line.trim() if (!trimmed) continue + if (trimmed === SERVER_SHUTDOWN_COMPLETE) { + if (this.shutdownStatus === "incomplete") continue + this.shutdownStatus = "complete" + console.info("[cli] server confirmed graceful shutdown") + continue + } + if (trimmed === SERVER_SHUTDOWN_INCOMPLETE) { + if (this.shutdownStatus === "incomplete") continue + this.shutdownStatus = "incomplete" + console.warn("[cli] server reported incomplete cleanup; requesting final process-tree enforcement") + this.emit("shutdownIncomplete") + continue + } if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) { const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim() if (token && !this.bootstrapToken) { @@ -661,57 +573,4 @@ export class CliProcessManager extends EventEmitter { throw new Error("Unable to locate the packaged CodeNomad server entrypoint (dist/bin.js). Rebuild the desktop bundle.") } - private shouldUsePackagedShellSupervisor(options: StartOptions): boolean { - return false - } - - private resolveCliSupervisorPath(): string { - const candidates = [ - path.join(process.resourcesPath, "cli-supervisor.cjs"), - path.join(mainDirname, "../resources/cli-supervisor.cjs"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate - } - } - - throw new Error("Unable to locate CodeNomad CLI supervisor script.") - } - - private resolveShellNodeCommand(): string { - const configured = process.env.NODE_BINARY?.trim() - return configured && configured.length > 0 ? configured : "node" - } - - private resolveBundledProdEntry(): string { - const candidates = [ - path.join(process.resourcesPath, "server", "dist", "bin.js"), - path.join(mainDirname, "../resources/server/dist/bin.js"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate - } - } - - throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.") - } - - private describeUtilityProcessError(error: unknown): string { - if (error instanceof Error && error.message) { - return error.message - } - - if (error && typeof error === "object") { - const typed = error as { type?: unknown; location?: unknown } - if (typeof typed.type === "string") { - return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type - } - } - - return String(error) - } } diff --git a/packages/electron-app/electron/main/process-stop.test.ts b/packages/electron-app/electron/main/process-stop.test.ts new file mode 100644 index 00000000..898a79c3 --- /dev/null +++ b/packages/electron-app/electron/main/process-stop.test.ts @@ -0,0 +1,542 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { EventEmitter, once } from "node:events" +import { registerHooks } from "node:module" +import { setTimeout as delay } from "node:timers/promises" +import test from "node:test" +import { + CLI_SHUTDOWN_COMMAND, + CLI_STOP_DEADLINE_MS, + captureProcessTree, + forceCapturedProcessTree, + mergeCapturedProcessTrees, + stopManagedChild, +} from "./process-stop" + +class FakeChild extends EventEmitter { + writes: string[] = [] + exited = false + stdin = { + writable: true, + destroyed: false, + end: (chunk: string, callback: (error?: Error | null) => void) => { + this.writes.push(chunk) + callback() + }, + } +} + +test("force success terminates stop without requiring an exit event", async () => { + const child = new FakeChild() + let forces = 0 + let resolved = false + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + deadlineMs: 100, + forceReserveMs: 80, + force: () => { forces++; return true }, + }).then(() => { resolved = true }) + + assert.equal(CLI_STOP_DEADLINE_MS, 30_000) + assert.deepEqual(child.writes, [CLI_SHUTDOWN_COMMAND]) + await delay(50) + assert.equal(forces, 1) + await stopped + assert.equal(resolved, true) +}) + +test("an absolute stop deadline includes work completed before stopManagedChild starts", async () => { + const child = new FakeChild() + const deadlineAt = Date.now() + 500 + await delay(100) + const started = Date.now() + + await stopManagedChild({ + child, + isExited: () => false, + deadlineMs: 1_000, + deadlineAt, + forceReserveMs: 200, + force: () => true, + }) + + assert.ok(Date.now() - started < 800) +}) + +test("the hard stop deadline rejects even when force never settles", { timeout: 500 }, async () => { + const child = new FakeChild() + const started = Date.now() + + await assert.rejects(stopManagedChild({ + child, + isExited: () => false, + deadlineMs: 30, + forceReserveMs: 20, + force: () => new Promise(() => {}), + }), /overall deadline/) + assert.ok(Date.now() - started < 100) +}) + +test("the hard deadline also bounds enforcement for an already-exited child", { timeout: 500 }, async () => { + const child = new FakeChild() + child.exited = true + + await assert.rejects(stopManagedChild({ + child, + isExited: () => true, + isCleanupComplete: () => false, + deadlineMs: 20, + force: () => new Promise(() => {}), + }), /overall deadline/) +}) + +test("confirmed exit cancels the delayed force command", async () => { + const child = new FakeChild() + let forces = 0 + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + deadlineMs: 15, + force: () => { forces++; return true }, + }) + + child.exited = true + child.emit("exit") + await stopped + await delay(30) + assert.equal(forces, 0) +}) + +test("unconfirmed final enforcement retries until the process exits", async () => { + const child = new FakeChild() + let forces = 0 + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + deadlineMs: 100, + forceReserveMs: 90, + forceRetryMs: 5, + force: () => { + forces++ + if (forces < 2) return false + child.exited = true + child.emit("exit") + return true + }, + }) + + await stopped + assert.equal(forces, 2) +}) + +test("exit without a complete shutdown handshake enforces the captured tree", async () => { + const child = new FakeChild() + let forces = 0 + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + isCleanupComplete: () => false, + force: () => { forces++; return true }, + }) + + child.exited = true + child.emit("exit") + await stopped + assert.equal(forces, 1) +}) + +test("ending CLI stdin permits a real child to exit naturally", { timeout: 5_000 }, async () => { + const child = spawn(process.execPath, ["-e", ` + let buffer = "" + process.stdin.on("data", (chunk) => { + buffer += chunk + if (!buffer.includes(${JSON.stringify(CLI_SHUTDOWN_COMMAND.trim())})) return + process.stdin.removeAllListeners("data") + process.stdin.pause() + setTimeout(() => { process.exitCode = 0 }, 10) + }) + `], { stdio: ["pipe", "ignore", "inherit"] }) + let forces = 0 + + await stopManagedChild({ + child, + isExited: () => child.exitCode !== null || child.signalCode !== null, + deadlineMs: 1_000, + force: () => { forces++; child.kill("SIGKILL"); return true }, + }) + + assert.equal(child.exitCode, 0) + assert.equal(forces, 0) +}) + +test("tree capture records immutable root and nested descendant identities", async () => { + const list = (() => ({ status: 0, stdout: "100|1|linux:boot:10\n200|100|linux:boot:20\n201|200|linux:boot:21\n999|1|linux:boot:99\n", stderr: "", pid: 1, + signal: null, output: [] })) + const tree = await captureProcessTree(100, "linux", list) + assert.deepEqual(tree, { platform: "linux", members: [ + { pid: 100, startIdentity: "linux:boot:10" }, + { pid: 200, startIdentity: "linux:boot:20" }, + { pid: 201, startIdentity: "linux:boot:21" }, + ] }) +}) + +test("Windows tree capture ignores the system idle PID without rejecting the process table", async () => { + const list = (() => ({ + status: 0, + error: undefined, + stdout: "0|0|win32:system\n100|0|win32:100\n101|100|win32:101\n", + stderr: "", + })) + assert.deepEqual((await captureProcessTree(100, "win32", list))?.members, [ + { pid: 100, startIdentity: "win32:100" }, + { pid: 101, startIdentity: "win32:101" }, + ]) +}) + +test("a malformed descendant row invalidates the entire process snapshot", async () => { + const list = (() => ({ + status: 0, + stdout: "100|0|win32:100\n101|100|win32:\n", + stderr: "", + })) + + assert.equal(await captureProcessTree(100, "win32", list), undefined) +}) + +test("Windows verifies creation time and terminates through one native process handle", async () => { + const commands: Array<{ command: string; args: readonly string[] }> = [] + let terminated = false + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:638800000000000000" }] } + const runner = async (command: string, args: readonly string[]) => { + commands.push({ command, args }) + terminated = true + return { status: 0, stdout: "terminated\n", stderr: "" } + } + const lookup = async () => { + assert.equal(terminated, true, "identity was queried separately before the handle-bound termination") + return undefined + } + const kill = (() => { const error = new Error("gone") as NodeJS.ErrnoException; error.code = "ESRCH"; throw error }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, lookup, runner, kill), true) + assert.equal(commands.length, 1) + assert.equal(commands[0]!.command, "powershell.exe") + const script = commands[0]!.args.join(" ") + assert.match(script, /OpenProcess/) + assert.match(script, /GetProcessTimes/) + assert.match(script, /TerminateProcess/) + assert.match(script, /CloseHandle/) + assert.match(script, /638800000000000000/) + assert.doesNotMatch(script, /taskkill/i) +}) + +test("Windows native-handle termination refuses a live process with a mismatched creation time", { + skip: process.platform !== "win32", + timeout: 5_000, +}, async () => { + const tree = { platform: "win32" as const, members: [{ pid: process.pid, startIdentity: "win32:1" }] } + + assert.equal(await forceCapturedProcessTree(tree), true) + assert.doesNotThrow(() => process.kill(process.pid, 0)) +}) + +test("Windows native-handle termination accepts CIM precision for an owned process", { + skip: process.platform !== "win32", + timeout: 10_000, +}, async (t) => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") }) + assert.ok(child.pid) + const exited = once(child, "exit") + const tree = await captureProcessTree(child.pid, "win32") + assert.ok(tree) + + assert.equal(await forceCapturedProcessTree(tree), true) + await exited +}) + +test("Windows native-handle termination refuses a sub-microsecond identity mismatch", { + skip: process.platform !== "win32", + timeout: 10_000, +}, async (t) => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") }) + assert.ok(child.pid) + const tree = await captureProcessTree(child.pid, "win32") + assert.ok(tree) + tree.members[0]!.startIdentity = `win32:${BigInt(tree.members[0]!.startIdentity.slice(6)) + 1n}` + + assert.equal(await forceCapturedProcessTree(tree), true) + assert.doesNotThrow(() => process.kill(child.pid!, 0)) +}) + +test("PID reuse is identity-guarded on Windows and POSIX", async () => { + for (const platform of ["win32", "linux"] as const) { + const commands: string[][] = [] + const signals: number[] = [] + const tree = { platform, members: [{ pid: 42, startIdentity: platform === "win32" ? "win32:1" : "old" }] } + const runTaskkill = ((_command: string, args: readonly string[]) => { + commands.push([...args]) + return { status: 0, stdout: "mismatch\n", stderr: "", pid: 1, signal: null, output: [] } + }) + const kill = ((pid: number) => { signals.push(pid); return true }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, () => "reused", runTaskkill, kill), true) + assert.equal(commands.length, platform === "win32" ? 1 : 0) + assert.deepEqual(signals, []) + } +}) + +test("a stale captured identity cannot authorize termination after handle-bound PID reuse", async () => { + let commands = 0 + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] } + const runTaskkill = (() => { + commands++ + return { status: 0, stdout: "mismatch\n", stderr: "", pid: 1, signal: null, output: [] } + }) + + assert.equal(await forceCapturedProcessTree(tree, undefined, runTaskkill, process.kill, { + revalidateIdentity: async () => "reused", + }), true) + assert.equal(commands, 1) +}) + +test("a successful snapshot omission still requires a liveness check", async () => { + let lookups = 0 + let livenessChecks = 0 + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] } + const runTaskkill = (() => ({ status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] })) + const kill = ((_pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) livenessChecks++ + return true + }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree( + tree, + () => { lookups++; return undefined }, + runTaskkill, + kill, + ), false) + assert.equal(lookups, 1) + assert.equal(livenessChecks, 1) +}) + +test("enforcement awaits asynchronous commands without blocking timers", async () => { + let settled = false + let timerFired = false + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] } + const enforcement = Promise.resolve(forceCapturedProcessTree( + tree, + async () => undefined, + (async () => { + await delay(30) + return { status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] } + }), + (() => { const error = new Error("gone") as NodeJS.ErrnoException; error.code = "ESRCH"; throw error }) as typeof process.kill, + )).then((value) => { settled = true; return value }) + setTimeout(() => { timerFired = true }, 1) + + await delay(5) + assert.equal(timerFired, true) + assert.equal(settled, false) + assert.equal(await enforcement, true) +}) + +test("failed initial capture retains a bounded spawn-time root identity", async () => { + const module = await import("./process-stop") as typeof import("./process-stop") & { + captureInitialProcessTree(...args: unknown[]): Promise<{ tree?: unknown; rootStartIdentity?: string }> + } + const timeouts: number[] = [] + let lookupStarted = false + const result = await module.captureInitialProcessTree( + 100, + "win32", + async () => { + assert.equal(lookupStarted, true) + return { status: 1, stdout: "", stderr: "" } + }, + async (_pid: number, timeoutMs: number) => { lookupStarted = true; timeouts.push(timeoutMs); return "win32:100" }, + Date.now() + 500, + ) + + assert.equal(result.rootStartIdentity, "win32:100") + assert.ok(timeouts[0]! > 0 && timeouts[0]! <= 500) + assert.deepEqual(mergeCapturedProcessTrees(undefined, { + platform: "win32", + members: [{ pid: 100, startIdentity: "win32:100" }], + }, 100, result.rootStartIdentity), { + platform: "win32", + members: [{ pid: 100, startIdentity: "win32:100" }], + }) +}) + +test("tree enforcement stops launching commands when its deadline is spent", async () => { + const tree = { platform: "win32" as const, members: [ + { pid: 101, startIdentity: "win32:1" }, + { pid: 102, startIdentity: "win32:2" }, + { pid: 103, startIdentity: "win32:3" }, + ] } + const timeouts: number[] = [] + let now = 0 + const runTaskkill = ((_command: string, _args: readonly string[], options: { timeout?: number }) => { + timeouts.push(options.timeout ?? 0) + now += options.timeout ?? 0 + return { status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] } + }) + + assert.equal(await forceCapturedProcessTree(tree, undefined, runTaskkill, process.kill, { + deadlineAt: 2_500, + now: () => now, + }), false) + assert.deepEqual(timeouts, [1_500, 1_000]) +}) + +test("later captures preserve root ownership and add every descendant identity", async () => { + const captured = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "root" }, + { pid: 200, startIdentity: "old-child" }, + ] } + const latest = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "root" }, + { pid: 200, startIdentity: "reused-child" }, + { pid: 300, startIdentity: "new-child" }, + ] } + + const merged = mergeCapturedProcessTrees(captured, latest, 100)! + assert.deepEqual(merged.members, [ + { pid: 100, startIdentity: "root" }, + { pid: 200, startIdentity: "old-child" }, + { pid: 200, startIdentity: "reused-child" }, + { pid: 300, startIdentity: "new-child" }, + ]) + const identities = new Map([[100, "root"], [200, "reused-child"], [300, "new-child"]]) + const signals: number[] = [] + const kill = ((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (identities.has(pid)) return true + const error = new Error("gone") as NodeJS.ErrnoException + error.code = "ESRCH" + throw error + } + signals.push(pid) + identities.delete(pid) + return true + }) as typeof process.kill + assert.equal(await forceCapturedProcessTree(merged, (pid) => identities.get(pid), undefined, kill), true) + assert.deepEqual(signals, [300, 200, 100]) + + const survivingIdentities = new Map([[100, "root"], [200, "reused-child"], [300, "new-child"]]) + assert.equal(await forceCapturedProcessTree( + merged, + (pid) => survivingIdentities.get(pid), + undefined, + (() => true) as typeof process.kill, + ), false) + + const reusedRoot = mergeCapturedProcessTrees(captured, { + platform: "linux", + members: [{ pid: 100, startIdentity: "reused-root" }, { pid: 400, startIdentity: "foreign-child" }], + }, 100) + assert.deepEqual(reusedRoot, captured) + const rootSignals: number[] = [] + assert.equal(await forceCapturedProcessTree( + reusedRoot!, + (pid) => pid === 100 ? "reused-root" : undefined, + undefined, + ((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + const error = new Error("gone") as NodeJS.ErrnoException + error.code = "ESRCH" + throw error + } + rootSignals.push(pid) + return true + }) as typeof process.kill, + ), true) + assert.deepEqual(rootSignals, []) + assert.equal(mergeCapturedProcessTrees(undefined, latest, 100), undefined) +}) + +test("a matching late capture becomes the baseline after the initial capture fails", () => { + const latest = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "original-root" }, + { pid: 200, startIdentity: "child" }, + ] } + + assert.deepEqual(mergeCapturedProcessTrees(undefined, latest, 100, "original-root"), latest) + assert.equal(mergeCapturedProcessTrees(undefined, latest, 100, "reused-root"), undefined) +}) + +test("an exited root without a trustworthy capture cannot confirm containment", async () => { + const child = new FakeChild() + child.exited = true + let tree: ReturnType + + await assert.rejects(stopManagedChild({ + child, + isExited: () => child.exited, + isCleanupComplete: () => false, + forceAttempts: 1, + force: () => { + tree = mergeCapturedProcessTrees(tree, undefined, 100, "original-root") + return tree ? forceCapturedProcessTree(tree) : false + }, + }), /termination was not confirmed/) +}) + +test("captured descendants are forced individually in child-first order", async () => { + const signals: number[] = [] + const tree = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "a" }, { pid: 200, startIdentity: "b" }, { pid: 201, startIdentity: "c" }, + ] } + const identities = new Map([[100, "a"], [200, "b"], [201, "c"]]) + const kill = ((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (identities.has(pid)) return true + const error = new Error("gone") as NodeJS.ErrnoException + error.code = "ESRCH" + throw error + } + signals.push(pid) + identities.delete(pid) + return true + }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, (pid) => identities.get(pid), undefined, kill), true) + assert.deepEqual(signals, [201, 200, 100]) +}) + +test("signal dispatch is not confirmation while the captured identity remains", async () => { + const tree = { platform: "linux" as const, members: [{ pid: 42, startIdentity: "owned" }] } + const kill = (() => true) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, () => "owned", undefined, kill), false) +}) + +test("incomplete shutdown status remains terminal", async () => { + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "electron") { + return { shortCircuit: true, url: "data:text/javascript,export const app={isPackaged:false,getAppPath(){return ''}}" } + } + return nextResolve(specifier, context) + }, + }) + try { + const { CliProcessManager } = await import("./process-manager") + const manager = new CliProcessManager() + let enforcements = 0 + ;(manager as EventEmitter).on("shutdownIncomplete", () => { enforcements++ }) + + ;(manager as any).handleStream( + "CODENOMAD_SHUTDOWN_STATUS:incomplete\nCODENOMAD_SHUTDOWN_STATUS:complete\n", + "stdout", + ) + + assert.equal((manager as any).shutdownStatus, "incomplete") + assert.equal(enforcements, 1) + } finally { + hooks.deregister() + } +}) diff --git a/packages/electron-app/electron/main/process-stop.ts b/packages/electron-app/electron/main/process-stop.ts new file mode 100644 index 00000000..658bd7e8 --- /dev/null +++ b/packages/electron-app/electron/main/process-stop.ts @@ -0,0 +1,361 @@ +import { execFile } from "node:child_process" +import { getProcessStartIdentityAsync, type AsyncProcessStartIdentityLookup } from "./client-state-process-identity" + +export const CLI_SHUTDOWN_COMMAND = "codenomad:shutdown\n" +export const CLI_STOP_DEADLINE_MS = 30_000 + +interface ExitTrackedChild { + stdin?: { + destroyed?: boolean + writable?: boolean + end(chunk: string, callback: (error?: Error | null) => void): unknown + } | null + once(event: "exit", listener: () => void): unknown + off?(event: "exit", listener: () => void): unknown +} + +interface StopManagedChildOptions { + child: ExitTrackedChild + isExited(): boolean + force(deadlineAt?: number): Promise | boolean + isCleanupComplete?(): boolean + deadlineMs?: number + deadlineAt?: number + forceReserveMs?: number + forceRetryMs?: number + forceAttempts?: number + warn?(message: string, error?: unknown): void +} + +interface ProcessRow { + pid: number + parentPid: number + startIdentity: string +} + +export interface CapturedProcessTree { + platform: NodeJS.Platform + members: Array<{ pid: number; startIdentity: string }> +} + +interface AsyncCommandResult { + status: number | null + stdout: string + stderr: string + error?: Error +} + +type AsyncCommandRunner = ( + command: string, + args: readonly string[], + options: { encoding: "utf8"; timeout: number; windowsHide?: boolean; env?: NodeJS.ProcessEnv }, +) => Promise | AsyncCommandResult + +interface ForceCapturedProcessTreeOptions { + deadlineAt?: number + now?: () => number + revalidateIdentity?: AsyncProcessStartIdentityLookup +} + +export function mergeCapturedProcessTrees( + captured: CapturedProcessTree | undefined, + latest: CapturedProcessTree | undefined, + rootPid: number, + expectedRootIdentity?: string, +): CapturedProcessTree | undefined { + if (!latest || (captured && latest.platform !== captured.platform)) return captured + const capturedRoot = captured?.members.find((member) => member.pid === rootPid) + const latestRoot = latest.members.find((member) => member.pid === rootPid) + const rootIdentity = capturedRoot?.startIdentity ?? expectedRootIdentity + if (!rootIdentity || !latestRoot || rootIdentity !== latestRoot.startIdentity) return captured + if (!captured) return latest + + const identityKey = (member: { pid: number; startIdentity: string }) => `${member.pid}\0${member.startIdentity}` + const members = new Map(captured.members.map((member) => [identityKey(member), member])) + for (const member of latest.members) { + members.set(identityKey(member), member) + } + return { platform: captured.platform, members: [...members.values()] } +} + +function runCommand( + command: string, + args: readonly string[], + options: { encoding: "utf8"; timeout: number; windowsHide?: boolean; env?: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolve) => { + execFile(command, args, options, (error, stdout, stderr) => { + resolve({ status: error ? null : 0, stdout, stderr, error: error ?? undefined }) + }) + }) +} + +async function captureProcessRows( + platform: NodeJS.Platform, + runList: AsyncCommandRunner, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) return undefined + const result = platform === "win32" + ? await runList("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", + "Get-CimInstance Win32_Process | ForEach-Object { '{0}|{1}|win32:{2}' -f $_.ProcessId, $_.ParentProcessId, ([datetime]$_.CreationDate).ToUniversalTime().Ticks }"], + { encoding: "utf8", timeout: timeoutMs, windowsHide: true }) + : platform === "linux" + ? await runList("sh", ["-c", `boot=$(cat /proc/sys/kernel/random/boot_id) || exit 1 +for stat in /proc/[0-9]*/stat; do + line=$(cat "$stat" 2>/dev/null) || continue + pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest + ppid=$2; shift 19; printf '%s|%s|linux:%s:%s\n' "$pid" "$ppid" "$boot" "$1" +done`], { encoding: "utf8", timeout: timeoutMs }) + : await runList("ps", ["-A", "-o", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: timeoutMs, + env: { ...process.env, LC_ALL: "C", LANG: "C" } }) + if (result.status !== 0 || result.error) return undefined + + const rows: ProcessRow[] = [] + for (const line of String(result.stdout ?? "").split(/\r?\n/)) { + if (!line.trim()) continue + if (platform === "darwin" ? /^0(?:\s|$)/.test(line.trim()) : /^0(?:\||$)/.test(line.trim())) continue + const fields = platform === "darwin" + ? line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)?.slice(1) + : line.trim().split("|") + if (!fields || fields.length !== 3) return undefined + const [pidText, parentPidText, rawIdentity] = fields + const pid = Number(pidText), parentPid = Number(parentPidText) + const startIdentity = platform === "darwin" ? `darwin:${rawIdentity}` : rawIdentity + const validIdentity = platform === "win32" + ? /^win32:\d+$/.test(startIdentity) + : platform === "linux" + ? /^linux:[^:]+:\d+$/.test(startIdentity) + : Boolean(rawIdentity.trim()) + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !validIdentity) return undefined + rows.push({ pid, parentPid, startIdentity }) + } + return rows +} + +function processTreeFromRows(rootPid: number, platform: NodeJS.Platform, rows: ProcessRow[]): CapturedProcessTree | undefined { + const descendants = new Set([rootPid]) + let changed = true + while (changed) { + changed = false + for (const row of rows) { + if (!descendants.has(row.parentPid) || descendants.has(row.pid)) continue + descendants.add(row.pid) + changed = true + } + } + const members = rows.filter((row) => descendants.has(row.pid)) + .map(({ pid, startIdentity }) => ({ pid, startIdentity })) + return members.some((member) => member.pid === rootPid) ? { platform, members } : undefined +} + +export async function captureProcessTree( + rootPid: number, + platform: NodeJS.Platform = process.platform, + runList: AsyncCommandRunner = runCommand, + timeoutMs = 1_500, +): Promise { + const rows = await captureProcessRows(platform, runList, timeoutMs) + return rows ? processTreeFromRows(rootPid, platform, rows) : undefined +} + +export async function captureInitialProcessTree( + rootPid: number, + platform: NodeJS.Platform = process.platform, + runList: AsyncCommandRunner = runCommand, + lookup: AsyncProcessStartIdentityLookup = (pid, timeoutMs) => getProcessStartIdentityAsync(pid, timeoutMs, platform), + deadlineAt = Date.now() + 3_000, +): Promise<{ tree?: CapturedProcessTree; rootStartIdentity?: string }> { + const fallbackIdentity = Promise.resolve(lookup(rootPid, Math.min(1_500, deadlineAt - Date.now()))) + const captured = await captureProcessTree(rootPid, platform, runList, Math.min(1_500, deadlineAt - Date.now())) + const rootStartIdentity = await fallbackIdentity + const tree = rootStartIdentity + ? mergeCapturedProcessTrees(undefined, captured, rootPid, rootStartIdentity) + : undefined + return { tree, rootStartIdentity } +} + +export async function forceCapturedProcessTree( + tree: CapturedProcessTree, + lookup?: AsyncProcessStartIdentityLookup, + runTerminate: AsyncCommandRunner = runCommand, + kill: typeof process.kill = process.kill, + options: ForceCapturedProcessTreeOptions = {}, +): Promise { + const now = options.now ?? Date.now + const remainingMs = () => options.deadlineAt === undefined ? 1_500 : options.deadlineAt - now() + const currentIdentity = options.revalidateIdentity ?? lookup + ?? ((pid, timeoutMs) => getProcessStartIdentityAsync(pid, timeoutMs, tree.platform)) + let confirmed = true + const isGone = (pid: number) => { + try { + kill(pid, 0) + return false + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH" + } + } + for (const member of [...tree.members].reverse()) { + if (remainingMs() <= 0) return false + if (tree.platform === "win32") { + const expectedTicks = member.startIdentity.match(/^win32:(\d+)$/)?.[1] + if (!expectedTicks) { + confirmed = false + continue + } + const timeout = Math.min(1_500, remainingMs()) + if (timeout <= 0) return false + const script = `$source = @' +using System; +using System.Runtime.InteropServices; +public static class CodeNomadProcessHandle { + [StructLayout(LayoutKind.Sequential)] public struct FileTime { public uint Low; public uint High; } + [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint processId); + [DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetProcessTimes(IntPtr process, out FileTime creation, out FileTime exit, out FileTime kernel, out FileTime user); + [DllImport("kernel32.dll", SetLastError=true)] public static extern bool TerminateProcess(IntPtr process, uint exitCode); + [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle); +} +'@ +Add-Type -TypeDefinition $source +$handle = [CodeNomadProcessHandle]::OpenProcess(0x1001, $false, ${member.pid}) +if ($handle -eq [IntPtr]::Zero) { exit 3 } +try { + $creation = [CodeNomadProcessHandle+FileTime]::new() + $exit = [CodeNomadProcessHandle+FileTime]::new() + $kernel = [CodeNomadProcessHandle+FileTime]::new() + $user = [CodeNomadProcessHandle+FileTime]::new() + if (-not [CodeNomadProcessHandle]::GetProcessTimes($handle, [ref]$creation, [ref]$exit, [ref]$kernel, [ref]$user)) { exit 4 } + $fileTime = ([long]$creation.High -shl 32) -bor $creation.Low + $nativeTicks = [DateTime]::FromFileTimeUtc($fileTime).Ticks + $expectedTicks = [long]::Parse('${expectedTicks}') + $nativeTicks -= $nativeTicks % 10 + if ($nativeTicks -ne $expectedTicks) { 'mismatch'; exit 0 } + if (-not [CodeNomadProcessHandle]::TerminateProcess($handle, 1)) { exit 5 } + 'terminated' +} finally { + [void][CodeNomadProcessHandle]::CloseHandle($handle) +}` + const result = await runTerminate("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + timeout, + windowsHide: true, + }) + const outcome = result.status === 0 ? result.stdout.trim() : "" + if (outcome === "mismatch") continue + if (outcome !== "terminated" && !isGone(member.pid)) confirmed = false + continue + } + const identity = await currentIdentity(member.pid, Math.min(1_500, remainingMs())) + if (!identity) { + if (!isGone(member.pid)) confirmed = false + continue + } + if (identity !== member.startIdentity) continue + try { + kill(member.pid, "SIGKILL") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") confirmed = false + } + } + if (remainingMs() <= 0) return false + for (const member of tree.members) { + if (remainingMs() <= 0) return false + const remainingIdentity = await currentIdentity(member.pid, Math.min(1_500, remainingMs())) + if (remainingIdentity === member.startIdentity) confirmed = false + else if (!remainingIdentity && !isGone(member.pid)) confirmed = false + } + return confirmed +} + +export function stopManagedChild(options: StopManagedChildOptions): Promise { + return new Promise((resolve, reject) => { + let settled = false + let timer: ReturnType | undefined + let hardTimer: ReturnType | undefined + let attempts = 0 + const deadlineMs = options.deadlineMs ?? CLI_STOP_DEADLINE_MS + const deadlineAt = options.deadlineAt ?? Date.now() + deadlineMs + const cleanupComplete = options.isCleanupComplete ?? (() => true) + const removeListener = () => options.child.off?.("exit", onExit) + const finish = (error?: Error) => { + if (settled) return + settled = true + if (timer) clearTimeout(timer) + if (hardTimer) clearTimeout(hardTimer) + removeListener() + if (error) reject(error) + else resolve() + } + let forcing = false + const force = () => { + if (timer) clearTimeout(timer) + timer = undefined + if (settled) return + if (forcing) return + if (Date.now() >= deadlineAt) { + finish(new Error("CLI process tree termination exceeded its overall deadline")) + return + } + attempts += 1 + forcing = true + void Promise.resolve().then(() => options.force(deadlineAt)).then((confirmed) => { + forcing = false + if (settled) return + if (Date.now() > deadlineAt) { + finish(new Error("CLI process tree termination exceeded its overall deadline")) + return + } + if (confirmed) { + finish() + return + } + retry() + }, (error) => { + forcing = false + options.warn?.("Failed to force CLI process tree termination", error) + retry() + }) + } + const retry = () => { + const maxAttempts = options.forceAttempts ?? 3 + if (attempts >= maxAttempts) { + finish(new Error(`CLI process tree termination was not confirmed after ${attempts} attempts`)) + return + } + options.warn?.("CLI process tree termination was not confirmed; retrying") + const retryMs = options.forceRetryMs ?? 1_000 + timer = setTimeout(force, Math.min(retryMs, Math.max(0, deadlineAt - Date.now()))) + } + function onExit() { + if (cleanupComplete()) finish() + else force() + } + + options.child.once("exit", onExit) + hardTimer = setTimeout(() => { + finish(new Error("CLI process tree termination exceeded its overall deadline")) + }, Math.max(0, deadlineAt - Date.now())) + if (options.isExited()) { + onExit() + return + } + + const forceAt = deadlineAt - (options.forceReserveMs ?? Math.min(1_500, deadlineMs / 2)) + timer = setTimeout(() => { + options.warn?.("CLI cleanup reached its final enforcement window; forcing process tree termination") + force() + }, Math.max(0, forceAt - Date.now())) + const stdin = options.child.stdin + if (!stdin || stdin.destroyed || stdin.writable === false) { + options.warn?.("CLI stdin is not writable; waiting until the force deadline") + return + } + try { + stdin.end(CLI_SHUTDOWN_COMMAND, (error) => { + if (error) options.warn?.("Failed to send the CLI graceful shutdown command; waiting until the force deadline", error) + }) + } catch (error) { + options.warn?.("Failed to send the CLI graceful shutdown command; waiting until the force deadline", error) + } + }) +} diff --git a/packages/electron-app/electron/main/renderer-client-state-flush.test.ts b/packages/electron-app/electron/main/renderer-client-state-flush.test.ts new file mode 100644 index 00000000..3617e4d8 --- /dev/null +++ b/packages/electron-app/electron/main/renderer-client-state-flush.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { flushRendererClientStateBeforeShutdown, type RendererFlushWindow } from "./renderer-client-state-flush" + +const window = (executeJavaScript: (source: string) => Promise): RendererFlushWindow => ({ + isDestroyed: () => false, + webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript }, +}) + +test("renderer flush enforces primary/trusted access and invokes the registered callback", async () => { + let calls = 0 + let source = "" + const target = window(async (value) => { calls++; source = value }) + assert.equal(await flushRendererClientStateBeforeShutdown(target, false, () => true), "not-primary") + assert.equal(await flushRendererClientStateBeforeShutdown(target, true, () => false), "untrusted-origin") + assert.equal(calls, 0) + assert.equal(await flushRendererClientStateBeforeShutdown(target, true, () => true), "flushed") + assert.equal(calls, 1) + assert.match(source, /__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__/) + assert.match(source, /http:\/\/127\.0\.0\.1:3000/) +}) + +test("renderer flush rejects after its bounded timeout", async () => { + await assert.rejects(flushRendererClientStateBeforeShutdown(window(() => new Promise(() => {})), true, () => true, 10), /timed out after 10ms/) +}) diff --git a/packages/electron-app/electron/main/renderer-client-state-flush.ts b/packages/electron-app/electron/main/renderer-client-state-flush.ts new file mode 100644 index 00000000..1ab48365 --- /dev/null +++ b/packages/electron-app/electron/main/renderer-client-state-flush.ts @@ -0,0 +1,61 @@ +export const RENDERER_CLIENT_STATE_FLUSH_TIMEOUT_MS = 1_000 + +const RENDERER_CLIENT_STATE_FLUSH_CALLBACK = "__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__" + +interface RendererFlushWebContents { + isDestroyed(): boolean + getURL(): string + executeJavaScript(source: string, userGesture?: boolean): Promise +} + +export interface RendererFlushWindow { + isDestroyed(): boolean + webContents: RendererFlushWebContents +} + +export type RendererFlushResult = "flushed" | "not-primary" | "window-unavailable" | "untrusted-origin" + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Renderer client-state flush timed out after ${timeoutMs}ms`)), + timeoutMs, + ) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} + +export async function flushRendererClientStateBeforeShutdown( + window: RendererFlushWindow | null, + isPrimary: boolean, + isTrustedOrigin: (url: string) => boolean, + timeoutMs = RENDERER_CLIENT_STATE_FLUSH_TIMEOUT_MS, +): Promise { + if (!isPrimary) return "not-primary" + if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return "window-unavailable" + + const currentUrl = window.webContents.getURL() + if (!isTrustedOrigin(currentUrl)) return "untrusted-origin" + + const callbackName = JSON.stringify(RENDERER_CLIENT_STATE_FLUSH_CALLBACK) + const expectedOrigin = JSON.stringify(new URL(currentUrl).origin) + await withTimeout( + window.webContents.executeJavaScript(`(() => { + if (window.location.origin !== ${expectedOrigin}) throw new Error("Renderer origin changed before client-state flush"); + const flush = window[${callbackName}]; + if (typeof flush !== "function") throw new Error("Renderer client-state flush callback is unavailable"); + return flush(); + })()`), + timeoutMs, + ) + return "flushed" +} diff --git a/packages/electron-app/electron/main/renderer-origin.test.ts b/packages/electron-app/electron/main/renderer-origin.test.ts new file mode 100644 index 00000000..9080e7cc --- /dev/null +++ b/packages/electron-app/electron/main/renderer-origin.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { resolveConfiguredRendererOrigins } from "./renderer-origin" + +test("packaged renderer origins exclude development server environment URLs", () => { + assert.deepEqual( + resolveConfiguredRendererOrigins( + "https://127.0.0.1:43123/workspace", + true, + ["http://localhost:3000/app", "http://127.0.0.1:5173/loading.html"], + ), + ["https://127.0.0.1:43123"], + ) +}) + +test("development renderer origins include configured development servers", () => { + assert.deepEqual( + resolveConfiguredRendererOrigins( + "http://127.0.0.1:43123/workspace", + false, + ["http://localhost:3000/app", "http://localhost:3000/loading.html"], + ), + ["http://127.0.0.1:43123", "http://localhost:3000"], + ) +}) diff --git a/packages/electron-app/electron/main/renderer-origin.ts b/packages/electron-app/electron/main/renderer-origin.ts new file mode 100644 index 00000000..40f501b1 --- /dev/null +++ b/packages/electron-app/electron/main/renderer-origin.ts @@ -0,0 +1,24 @@ +export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean { + if (!origin) return false + try { + return allowedOrigins.includes(new URL(origin).origin) + } catch { + return false + } +} + +export function resolveConfiguredRendererOrigins( + currentCliUrl: string | null, + isPackaged: boolean, + devCandidates: Array, +): string[] { + const candidates = isPackaged ? [currentCliUrl] : [currentCliUrl, ...devCandidates] + const origins = new Set() + for (const candidate of candidates) { + if (!candidate) continue + try { + origins.add(new URL(candidate).origin) + } catch {} + } + return [...origins] +} diff --git a/packages/electron-app/electron/main/serialized-lifecycle.test.ts b/packages/electron-app/electron/main/serialized-lifecycle.test.ts new file mode 100644 index 00000000..24e7422f --- /dev/null +++ b/packages/electron-app/electron/main/serialized-lifecycle.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { SerializedLifecycle } from "./serialized-lifecycle" + +test("serializes operations and exposes shutdown before queued work resumes", async () => { + const lifecycle = new SerializedLifecycle() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let active = 0, maximum = 0 + const first = lifecycle.enqueue(async () => { + active += 1; maximum = Math.max(maximum, active) + await gate + active -= 1 + if (lifecycle.stopped) throw new Error("stopped") + }) + const second = lifecycle.enqueue(async () => { + active += 1; maximum = Math.max(maximum, active); active -= 1 + }) + const shutdown = lifecycle.stop(async () => {}) + release() + await assert.rejects(first, /stopped/) + await second + await shutdown + assert.equal(maximum, 1) +}) + +test("failed shutdown reopens the lifecycle before queued retries run", async () => { + const lifecycle = new SerializedLifecycle() + const shutdown = lifecycle.stop(async () => { throw new Error("cleanup unconfirmed") }) + const retry = lifecycle.enqueue(async () => { + assert.equal(lifecycle.stopped, false) + return "restarted" + }) + + await assert.rejects(shutdown, /cleanup unconfirmed/) + assert.equal(await retry, "restarted") + assert.equal(lifecycle.stopped, false) +}) diff --git a/packages/electron-app/electron/main/serialized-lifecycle.ts b/packages/electron-app/electron/main/serialized-lifecycle.ts new file mode 100644 index 00000000..1f72f5a7 --- /dev/null +++ b/packages/electron-app/electron/main/serialized-lifecycle.ts @@ -0,0 +1,22 @@ +export class SerializedLifecycle { + private tail: Promise = Promise.resolve() + stopped = false + + enqueue(operation: () => Promise): Promise { + const queued = this.tail.catch(() => {}).then(operation) + this.tail = queued.then(() => {}, () => {}) + return queued + } + + stop(operation: () => Promise): Promise { + this.stopped = true + return this.enqueue(async () => { + try { + return await operation() + } catch (error) { + this.stopped = false + throw error + } + }) + } +} diff --git a/packages/electron-app/electron/main/window-state.test.ts b/packages/electron-app/electron/main/window-state.test.ts new file mode 100644 index 00000000..fae89974 --- /dev/null +++ b/packages/electron-app/electron/main/window-state.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { clampWindowBounds, installWindowZoomInput, normalizeNativeWindowState, normalizeZoomFactor, restoreWindowState, WindowStateTracker } from "./window-state" +import type { BrowserWindow } from "electron" +import type { ClientStateManager } from "./client-state" + +const primaryDisplay = { x: 0, y: 0, width: 1920, height: 1080 } + +test("normalizes persisted window state", () => { + assert.equal(normalizeNativeWindowState({ bounds: { x: 0, y: 0, width: Number.NaN, height: 900 }, maximized: false, fullscreen: false, zoomFactor: 1 }), undefined) + assert.deepEqual(clampWindowBounds({ x: 4000, y: 2000, width: 1400, height: 900 }, [primaryDisplay]), { x: 520, y: 180, width: 1400, height: 900 }) + assert.deepEqual( + clampWindowBounds({ x: -2000, y: 100, width: 3000, height: 300 }, [{ x: -1280, y: 0, width: 1280, height: 1024 }, primaryDisplay]), + { x: -1280, y: 100, width: 1280, height: 600 }, + ) +}) + +test("normalizes unsafe zoom factors", () => { + assert.equal(normalizeZoomFactor(Number.POSITIVE_INFINITY), 1) + assert.equal(normalizeZoomFactor(0.01), 0.25) + assert.equal(normalizeZoomFactor(9), 5) +}) + +test("restores shared outer position and content size", () => { + const calls: unknown[] = [] + const window = { + setPosition: (x: number, y: number) => calls.push(["position", x, y]), + setContentSize: (width: number, height: number) => calls.push(["content", width, height]), + maximize: () => undefined, + setFullScreen: () => undefined, + webContents: { setZoomFactor: () => undefined }, + } as unknown as BrowserWindow + const bounds = { x: 10, y: 20, width: 1200, height: 800 } + restoreWindowState(window, { bounds, maximized: false, fullscreen: false, zoomFactor: 1 }, bounds) + assert.deepEqual(calls, [["position", 10, 20], ["content", 1200, 800]]) +}) + +test("flush captures the current native zoom", async () => { + let zoomLevel = -0.5 + const window = { + isDestroyed: () => false, + on: () => undefined, + getPosition: () => [0, 0], + getContentSize: () => [1200, 800], + isMaximized: () => false, + isFullScreen: () => false, + webContents: { + isDestroyed: () => false, + on: () => undefined, + setZoomLevel: (level: number) => { zoomLevel = level }, + getZoomLevel: () => zoomLevel, + setZoomFactor: (factor: number) => { zoomLevel = Math.log(factor) / Math.log(1.2) }, + getZoomFactor: () => 1.2 ** zoomLevel, + }, + } as unknown as BrowserWindow + const savedZoomFactors: number[] = [] + const manager = { + saveWindowState: async (state: { zoomFactor: number }) => { savedZoomFactors.push(state.zoomFactor); return true }, + flush: async () => undefined, + } as unknown as ClientStateManager + const tracker = new WindowStateTracker(window, manager, { bounds: { x: 0, y: 0, width: 1200, height: 800 }, maximized: false, fullscreen: false, zoomFactor: 1 }) + + await tracker.flush() + assert.ok(Math.abs(savedZoomFactors.at(-1)! - (1.2 ** -0.5)) < 0.000001) +}) + +test("Electron keyboard and wheel zoom input is applied explicitly", () => { + const events = new Map void>() + let zoomLevel = 0 + const prevented: string[] = [] + const window = { + webContents: { + on: (name: string, handler: (...args: any[]) => void) => events.set(name, handler), + getZoomLevel: () => zoomLevel, + }, + } as unknown as BrowserWindow + installWindowZoomInput(window, (level) => { zoomLevel = level }) + + events.get("before-input-event")?.({ preventDefault: () => prevented.push("keyboard") }, { + type: "keyDown", control: true, meta: false, alt: false, key: "=", + }) + assert.equal(zoomLevel, 0.5) + events.get("zoom-changed")?.({ preventDefault: () => prevented.push("wheel") }, "out") + assert.equal(zoomLevel, 0) + assert.deepEqual(prevented, ["keyboard", "wheel"]) +}) + +test("native menu zoom survives cross-origin navigation", () => { + const events = new Map void>() + let zoomLevel = -0.5 + const window = { + isDestroyed: () => false, + on: () => undefined, + webContents: { + isDestroyed: () => false, + on: (name: string, handler: (...args: any[]) => void) => events.set(name, handler), + getZoomFactor: () => 1.2 ** zoomLevel, + setZoomFactor: (factor: number) => { zoomLevel = Math.log(factor) / Math.log(1.2) }, + }, + } as unknown as BrowserWindow + const manager = { flush: async () => undefined } as unknown as ClientStateManager + new WindowStateTracker(window, manager, { + bounds: { x: 0, y: 0, width: 1200, height: 800 }, maximized: false, fullscreen: false, zoomFactor: 1, + }) + + events.get("did-start-navigation")?.({}, "http://next.test", false, true) + zoomLevel = 0 + events.get("did-finish-load")?.() + assert.ok(Math.abs(zoomLevel - (-0.5)) < 0.000001) +}) diff --git a/packages/electron-app/electron/main/window-state.ts b/packages/electron-app/electron/main/window-state.ts new file mode 100644 index 00000000..b595b495 --- /dev/null +++ b/packages/electron-app/electron/main/window-state.ts @@ -0,0 +1,252 @@ +import type { BrowserWindow } from "electron" +import type { ClientStateManager, NativeWindowState, WindowBounds } from "./client-state" + +export const DEFAULT_WINDOW_WIDTH = 1400 +export const DEFAULT_WINDOW_HEIGHT = 900 + +const MIN_WINDOW_WIDTH = 800 +const MIN_WINDOW_HEIGHT = 600 +const MIN_ZOOM_FACTOR = 0.25 +const MAX_ZOOM_FACTOR = 5 +const SAVE_DEBOUNCE_MS = 250 + +export interface DisplayWorkArea { + x: number + y: number + width: number + height: number +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum) +} + +function intersectionArea(bounds: WindowBounds, area: DisplayWorkArea): number { + const width = Math.max(0, Math.min(bounds.x + bounds.width, area.x + area.width) - Math.max(bounds.x, area.x)) + const height = Math.max(0, Math.min(bounds.y + bounds.height, area.y + area.height) - Math.max(bounds.y, area.y)) + return width * height +} + +function centerDistanceSquared(bounds: WindowBounds, area: DisplayWorkArea): number { + const x = bounds.x + bounds.width / 2 - (area.x + area.width / 2) + const y = bounds.y + bounds.height / 2 - (area.y + area.height / 2) + return x * x + y * y +} + +export function normalizeZoomFactor(value: unknown): number { + if (!isFiniteNumber(value) || value <= 0) { + return 1 + } + return clamp(value, MIN_ZOOM_FACTOR, MAX_ZOOM_FACTOR) +} + +export function normalizeNativeWindowState(value: unknown): NativeWindowState | undefined { + if (!value || typeof value !== "object") { + return undefined + } + + const candidate = value as Partial + const bounds = candidate.bounds + if ( + !bounds || + !isFiniteNumber(bounds.x) || + !isFiniteNumber(bounds.y) || + !isFiniteNumber(bounds.width) || + !isFiniteNumber(bounds.height) || + bounds.width <= 0 || + bounds.height <= 0 + ) { + return undefined + } + + return { + bounds: { + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }, + maximized: candidate.maximized === true, + fullscreen: candidate.fullscreen === true, + zoomFactor: normalizeZoomFactor(candidate.zoomFactor), + } +} + +export function clampWindowBounds(bounds: WindowBounds, displays: DisplayWorkArea[]): WindowBounds | undefined { + const normalized = normalizeNativeWindowState({ bounds, maximized: false, fullscreen: false, zoomFactor: 1 })?.bounds + const usableDisplays = displays.filter( + (area) => + isFiniteNumber(area.x) && + isFiniteNumber(area.y) && + isFiniteNumber(area.width) && + isFiniteNumber(area.height) && + area.width > 0 && + area.height > 0, + ) + if (!normalized || usableDisplays.length === 0) { + return undefined + } + + const display = usableDisplays.reduce((best, area) => { + const bestIntersection = intersectionArea(normalized, best) + const areaIntersection = intersectionArea(normalized, area) + if (areaIntersection !== bestIntersection) { + return areaIntersection > bestIntersection ? area : best + } + return centerDistanceSquared(normalized, area) < centerDistanceSquared(normalized, best) ? area : best + }) + + const maximumWidth = Math.max(1, Math.floor(display.width)) + const maximumHeight = Math.max(1, Math.floor(display.height)) + const minimumWidth = Math.min(MIN_WINDOW_WIDTH, maximumWidth) + const minimumHeight = Math.min(MIN_WINDOW_HEIGHT, maximumHeight) + const width = clamp(normalized.width, minimumWidth, maximumWidth) + const height = clamp(normalized.height, minimumHeight, maximumHeight) + const x = clamp(normalized.x, display.x, display.x + maximumWidth - width) + const y = clamp(normalized.y, display.y, display.y + maximumHeight - height) + + return { x, y, width, height } +} + +export function restoreWindowState(window: BrowserWindow, state: NativeWindowState | undefined, bounds: WindowBounds | undefined) { + if (!state) { + return + } + + if (bounds) { + window.setPosition(bounds.x, bounds.y) + window.setContentSize(bounds.width, bounds.height) + } + window.webContents.setZoomFactor(normalizeZoomFactor(state.zoomFactor)) + if (state.maximized) { + window.maximize() + } + if (state.fullscreen) { + window.setFullScreen(true) + } +} + +export function installWindowZoomInput(window: BrowserWindow, setZoomLevel: (level: number) => void): void { + const changeZoom = (delta: number) => setZoomLevel(window.webContents.getZoomLevel() + delta) + window.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || (!input.control && !input.meta) || input.alt) return + if (input.key === "+" || input.key === "=") { + event.preventDefault() + changeZoom(0.5) + } else if (input.key === "-") { + event.preventDefault() + changeZoom(-0.5) + } else if (input.key === "0") { + event.preventDefault() + setZoomLevel(0) + } + }) + window.webContents.on("zoom-changed", (event, direction) => { + event.preventDefault() + changeZoom(direction === "in" ? 0.5 : -0.5) + }) +} + +export class WindowStateTracker { + private saveTimer: ReturnType | undefined + private desiredZoomFactor: number + private normalBounds: WindowBounds + + constructor( + private readonly window: BrowserWindow, + private readonly clientState: ClientStateManager, + initialState?: NativeWindowState, + ) { + this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor) + const [x, y] = typeof window.getPosition === "function" ? window.getPosition() : [0, 0] + const [width, height] = typeof window.getContentSize === "function" + ? window.getContentSize() + : [DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT] + this.normalBounds = initialState?.bounds ?? { x, y, width, height } + + for (const event of ["move", "resize"]) { + window.on(event as "move", () => { + if (!window.isMaximized() && !window.isFullScreen()) this.captureNormalBounds() + this.scheduleSave() + }) + } + for (const event of ["maximize", "unmaximize", "enter-full-screen", "leave-full-screen"]) { + window.on(event as "maximize", () => this.scheduleSave()) + } + window.webContents.on("zoom-changed", () => this.scheduleSave()) + window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { + if (isMainFrame && !window.webContents.isDestroyed()) { + this.desiredZoomFactor = normalizeZoomFactor(window.webContents.getZoomFactor()) + } + }) + window.webContents.on("did-finish-load", () => { + if (!window.webContents.isDestroyed()) { + window.webContents.setZoomFactor(this.desiredZoomFactor) + } + }) + window.on("closed", () => this.clearTimer()) + } + + async flush(): Promise { + this.clearTimer() + if (!this.window.isDestroyed()) { + await this.captureAndQueue() + } + await this.clientState.flush() + } + + setZoomLevel(level: number): void { + if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) return + this.window.webContents.setZoomLevel(level) + this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) + this.scheduleSave() + } + + private scheduleSave() { + this.clearTimer() + this.saveTimer = setTimeout(() => { + this.saveTimer = undefined + void this.saveNow() + }, SAVE_DEBOUNCE_MS) + } + + private clearTimer() { + if (this.saveTimer) { + clearTimeout(this.saveTimer) + this.saveTimer = undefined + } + } + + private async saveNow() { + try { + await this.captureAndQueue() + } catch (error) { + console.warn("[client-state] failed to save window state", error) + } + } + + private captureAndQueue(): Promise { + if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) { + return Promise.resolve(false) + } + + this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) + if (!this.window.isMaximized() && !this.window.isFullScreen()) this.captureNormalBounds() + return this.clientState.saveWindowState({ + bounds: this.normalBounds, + maximized: this.window.isMaximized(), + fullscreen: this.window.isFullScreen(), + zoomFactor: this.desiredZoomFactor, + }) + } + + private captureNormalBounds(): void { + const [x, y] = this.window.getPosition() + const [width, height] = this.window.getContentSize() + this.normalBounds = { x, y, width, height } + } +} diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index b39fdea0..0a35f669 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -37,6 +37,12 @@ const localElectronAPI = { setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), + claimClientStateAccess: (token) => ipcRenderer.invoke("client-state:claimAccess", token), + loadClientState: (token) => ipcRenderer.invoke("client-state:load", token), + saveClientState: (token, snapshot) => ipcRenderer.invoke("client-state:save", token, snapshot), + setClientStateRestoreEnabled: (token, enabled) => + ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)), + clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token), } const remoteElectronAPI = { diff --git a/packages/electron-app/electron/resources/cli-supervisor.cjs b/packages/electron-app/electron/resources/cli-supervisor.cjs deleted file mode 100644 index 3ac319e3..00000000 --- a/packages/electron-app/electron/resources/cli-supervisor.cjs +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env node - -const { spawn } = require("child_process") - -const SHUTDOWN_GRACE_MS = 30_000 - -let child = null -let shutdownTimer = null - -function log(message, error) { - if (error) { - console.error(`[cli-supervisor] ${message}`, error) - return - } - console.log(`[cli-supervisor] ${message}`) -} - -function clearShutdownTimer() { - if (shutdownTimer) { - clearTimeout(shutdownTimer) - shutdownTimer = null - } -} - -function forwardStream(stream, target) { - if (!stream) return - stream.on("data", (chunk) => { - target.write(chunk) - }) -} - -function terminateChild(force) { - if (!child || child.exitCode !== null || child.signalCode !== null) { - return - } - - try { - child.kill(force ? "SIGKILL" : "SIGTERM") - } catch { - // no-op - } -} - -function requestShutdown(force = false) { - if (!child) { - process.exit(force ? 1 : 0) - return - } - - terminateChild(force) - if (force) { - process.exit(1) - return - } - - clearShutdownTimer() - shutdownTimer = setTimeout(() => { - log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`) - terminateChild(true) - }, SHUTDOWN_GRACE_MS) - shutdownTimer.unref() -} - -function installShutdownHandlers() { - process.on("SIGTERM", () => requestShutdown(false)) - process.on("SIGINT", () => requestShutdown(false)) - process.on("disconnect", () => requestShutdown(false)) - process.on("uncaughtException", (error) => { - log("uncaught exception", error) - requestShutdown(true) - }) - process.on("unhandledRejection", (error) => { - log("unhandled rejection", error) - requestShutdown(true) - }) -} - -function parsePayload() { - const raw = process.argv[2] - if (!raw) { - throw new Error("Supervisor payload is required") - } - - const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== "object") { - throw new Error("Supervisor payload must be an object") - } - if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) { - throw new Error("Supervisor payload command is required") - } - if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) { - throw new Error("Supervisor payload args must be a string array") - } - - return { - command: parsed.command, - args: parsed.args, - cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(), - } -} - -function main() { - installShutdownHandlers() - - const payload = parsePayload() - log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`) - - child = spawn(payload.command, payload.args, { - cwd: payload.cwd, - env: process.env, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }) - - forwardStream(child.stdout, process.stdout) - forwardStream(child.stderr, process.stderr) - - child.on("error", (error) => { - log("failed to spawn shell command", error) - process.exit(1) - }) - - child.on("exit", (code, signal) => { - clearShutdownTimer() - log(`child exited code=${code ?? ""} signal=${signal ?? ""}`) - process.exitCode = typeof code === "number" ? code : signal ? 1 : 0 - process.exit() - }) -} - -main() diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 8bb58526..339f56e8 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,6 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", @@ -131,12 +132,10 @@ "createStartMenuShortcut": true }, "linux": { + "executableName": "CodeNomad", "target": [ { - "target": "zip" - }, - { - "target": "AppImage" + "target": "tar.gz" } ], "artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}", diff --git a/packages/server/README.md b/packages/server/README.md index ad24868c..3f19b5a1 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -217,3 +217,15 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo - **Config**: `~/.config/codenomad/config.json` - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) + +### Provider Plan Usage + +The Status panel automatically displays quota information for the provider used by the active session. CodeNomad reads existing OpenCode credentials and never returns provider secrets through its API. + +Some optional usage integrations require credentials that OpenCode does not expose. They can be enabled without UI configuration through these environment variables: + +- Google token refresh: `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` +- Antigravity token refresh: `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET` +- Cursor: `CURSOR_ACCESS_TOKEN` or `CURSOR_TOKEN`, with optional `CURSOR_REFRESH_TOKEN` +- Ollama Cloud: `OLLAMA_CLOUD_COOKIE` +- OpenCode Go: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE` diff --git a/packages/server/THIRD_PARTY_NOTICES.md b/packages/server/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..f44c4033 --- /dev/null +++ b/packages/server/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-Party Notices + +The provider usage adapters are derived from OpenChamber: +https://github.com/openchamber/openchamber + +MIT License + +Copyright (c) 2025 Bohdan Triapitsyn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/server/package.json b/packages/server/package.json index 15228764..3ca35925 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d2349cde..d61b12f0 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -16,6 +16,8 @@ export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error" export interface WorkspaceDescriptor { id: string + /** Correlates creation events with the client request that initiated them. */ + requestId?: string /** Absolute path on the server host. */ path: string name?: string @@ -38,6 +40,9 @@ export interface WorkspaceDescriptor { export interface WorkspaceCreateRequest { path: string name?: string + binaryPath?: string + requestId?: string + forceNew?: boolean } export interface WorkspaceCloneRequest { @@ -50,7 +55,10 @@ export interface WorkspaceCloneResponse { path: string } -export type WorkspaceCreateResponse = WorkspaceDescriptor +export type WorkspaceCreateResponse = WorkspaceDescriptor & { + /** True when an active workspace with the same canonical path was returned. */ + reused?: true +} export type WorkspaceListResponse = WorkspaceDescriptor[] export type WorkspaceDetailResponse = WorkspaceDescriptor @@ -59,6 +67,26 @@ export interface WorkspaceDeleteResponse { status: WorkspaceStatus } +export interface ProviderUsageWindow { + usedPercent: number | null + remainingPercent: number | null + windowSeconds: number | null + resetAt: number | null + valueLabel?: string +} + +export interface ProviderUsageResponse { + requestedProviderId: string + providerId: string | null + providerName: string + modelId?: string + supported: boolean + configured: boolean + ok: boolean + windows: Record + fetchedAt: number +} + export type WorktreeKind = "root" | "worktree" export interface WorktreeDescriptor { @@ -328,6 +356,19 @@ export interface BinaryValidationResult { error?: string } +export interface OpenCodeUpdateStatus { + currentVersion: string + latestVersion: string | null + updateAvailable: boolean | null + canUpgrade: boolean + checkError?: "update_check_failed" +} + +export interface OpenCodeUpdateResponse { + success: boolean + version: string +} + export interface SpeechSegment { startMs: number endMs: number @@ -347,6 +388,11 @@ export interface SpeechCapabilitiesResponse { ttsVoice: string ttsFormats: string[] streamingTtsFormats: string[] + separateProviders?: boolean + sttConfigured?: boolean + ttsConfigured?: boolean + sttBaseUrl?: string + ttsBaseUrl?: string } export interface SpeechTranscriptionResponse { @@ -365,6 +411,14 @@ export interface VoiceModeStateResponse { enabled: boolean } +export interface YoloStateResponse { + enabled: boolean +} + +export interface SessionMetadataResponse { + metadata: Record +} + export interface RemoteServerProfile { id: string name: string @@ -414,12 +468,14 @@ export type WorkspaceEventType = | "instance.dataChanged" | "instance.event" | "instance.eventStatus" + | "yolo.stateChanged" + | "yolo.autoAccepted" export type WorkspaceEventPayload = | { type: "workspace.created"; workspace: WorkspaceDescriptor } | { type: "workspace.started"; workspace: WorkspaceDescriptor } | { type: "workspace.error"; workspace: WorkspaceDescriptor } - | { type: "workspace.stopped"; workspaceId: string } + | { type: "workspace.stopped"; workspaceId: string; reason?: "deleted" | "stopped" } | { type: "workspace.log"; entry: WorkspaceLogEntry } | { type: "sidecar.updated"; sidecar: SideCar } | { type: "sidecar.removed"; sidecarId: string } @@ -428,6 +484,8 @@ export type WorkspaceEventPayload = | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } + | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } export interface NetworkAddress { ip: string diff --git a/packages/server/src/config/schema.test.ts b/packages/server/src/config/schema.test.ts new file mode 100644 index 00000000..1e7cf13b --- /dev/null +++ b/packages/server/src/config/schema.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { PreferencesSchema } from "./schema" + +describe("chat visibility preferences", () => { + it("accepts hidden diagnostics and collapsed usage metrics", () => { + const preferences = PreferencesSchema.parse({ + diagnosticsExpansion: "hidden", + usageMetricsExpansion: "collapsed", + }) + + assert.equal(preferences.diagnosticsExpansion, "hidden") + assert.equal(preferences.usageMetricsExpansion, "collapsed") + }) +}) diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index ec65a5d2..c2ca2a0e 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -22,8 +22,9 @@ const PreferencesSchema = z modelThinkingSelections: z.record(z.string(), z.string()).default({}), diffViewMode: z.enum(["split", "unified"]).default("split"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), - diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), + diagnosticsExpansion: z.enum(["hidden", "expanded", "collapsed"]).default("expanded"), showUsageMetrics: z.boolean().default(true), + usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"), autoCleanupBlankSessions: z.boolean().default(true), listeningMode: z.enum(["local", "all"]).default("local"), logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), diff --git a/packages/server/src/events/bus.test.ts b/packages/server/src/events/bus.test.ts new file mode 100644 index 00000000..71757ceb --- /dev/null +++ b/packages/server/src/events/bus.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { EventBus } from "./bus" +import type { WorkspaceEventPayload } from "../api-types" + +describe("event bus instance status replay", () => { + it("replays the latest instance status to a late subscriber", () => { + const bus = new EventBus() + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" }) + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) + + const received: WorkspaceEventPayload[] = [] + bus.onEvent((event) => received.push(event)) + + assert.deepEqual(received, [ + { type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }, + ]) + }) + + it("delivers terminal disconnects live without replaying stopped workspaces", () => { + const bus = new EventBus() + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) + const live: WorkspaceEventPayload[] = [] + bus.onEvent((event) => live.push(event)) + live.length = 0 + + bus.publish({ + type: "instance.eventStatus", + instanceId: "workspace-1", + status: "disconnected", + reason: "workspace stopped", + }) + + assert.deepEqual(live, [{ + type: "instance.eventStatus", + instanceId: "workspace-1", + status: "disconnected", + reason: "workspace stopped", + }]) + const replayed: WorkspaceEventPayload[] = [] + bus.onEvent((event) => replayed.push(event)) + assert.deepEqual(replayed, []) + }) +}) diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index fd1e3ce6..637aad1d 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -3,11 +3,22 @@ import { WorkspaceEventPayload } from "../api-types" import { Logger } from "../logger" export class EventBus extends EventEmitter { + private readonly instanceStatuses = new Map>() + constructor(private readonly logger?: Logger) { super() } publish(event: WorkspaceEventPayload): boolean { + if (event.type === "instance.eventStatus") { + const terminal = event.status === "disconnected" + && (event.reason === "workspace stopped" || event.reason === "workspace error") + if (terminal) { + this.instanceStatuses.delete(event.instanceId) + } else { + this.instanceStatuses.set(event.instanceId, event) + } + } if (event.type !== "instance.event" && event.type !== "instance.eventStatus") { this.logger?.debug({ type: event.type }, "Publishing workspace event") if (this.logger?.isLevelEnabled("trace")) { @@ -31,6 +42,9 @@ export class EventBus extends EventEmitter { this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) + this.on("yolo.stateChanged", handler) + this.on("yolo.autoAccepted", handler) + for (const status of this.instanceStatuses.values()) listener(status) return () => { this.off("workspace.created", handler) this.off("workspace.started", handler) @@ -44,6 +58,8 @@ export class EventBus extends EventEmitter { this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) + this.off("yolo.stateChanged", handler) + this.off("yolo.autoAccepted", handler) } } } diff --git a/packages/server/src/filesystem/__tests__/search-cache.test.ts b/packages/server/src/filesystem/__tests__/search-cache.test.ts index 1823c5f8..8000cf48 100644 --- a/packages/server/src/filesystem/__tests__/search-cache.test.ts +++ b/packages/server/src/filesystem/__tests__/search-cache.test.ts @@ -17,10 +17,11 @@ describe("workspace search cache", () => { const workspacePath = "/tmp/workspace" const startTime = 1_000 - refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime) + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], startTime) const beforeExpiry = getWorkspaceCandidates( workspacePath, + "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1, ) assert.ok(beforeExpiry) @@ -29,6 +30,7 @@ describe("workspace search cache", () => { const afterExpiry = getWorkspaceCandidates( workspacePath, + "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1, ) assert.equal(afterExpiry, undefined) @@ -37,16 +39,32 @@ describe("workspace search cache", () => { it("replaces cached entries when manually refreshed", () => { const workspacePath = "/tmp/workspace" - refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000) - const initial = getWorkspaceCandidates(workspacePath, 5_001) + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000) + const initial = getWorkspaceCandidates(workspacePath, "query-a", 5_001) assert.ok(initial) assert.equal(initial[0].name, "file-a") - refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000) - const refreshed = getWorkspaceCandidates(workspacePath, 6_001) + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-b")], 6_000) + const refreshed = getWorkspaceCandidates(workspacePath, "query-a", 6_001) assert.ok(refreshed) assert.equal(refreshed[0].name, "file-b") }) + + it("does not reuse candidates across query scopes", () => { + const workspacePath = "/tmp/workspace" + + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000) + assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001)?.[0].name, "file-a") + assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined) + + refreshWorkspaceCandidates(workspacePath, "query-b", () => [createEntry("file-b")], 5_000) + assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined) + assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001)?.[0].name, "file-b") + + clearWorkspaceSearchCache(workspacePath) + assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined) + assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined) + }) }) function createEntry(name: string): FileSystemEntry { diff --git a/packages/server/src/filesystem/__tests__/search.test.ts b/packages/server/src/filesystem/__tests__/search.test.ts new file mode 100644 index 00000000..63fae4a7 --- /dev/null +++ b/packages/server/src/filesystem/__tests__/search.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { after, test } from "node:test" +import { searchWorkspaceFiles } from "../search" +import { getWorkspaceCandidates } from "../search-cache" + +const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-")) + +after(() => fs.rmSync(workspace, { recursive: true, force: true })) + +test("finds a matching file after more than 8000 non-matching entries", () => { + fs.mkdirSync(path.join(workspace, "a")) + fs.mkdirSync(path.join(workspace, "b")) + + const [targetDirName, fillerDirName] = fs.readdirSync(workspace) + const fillerDir = path.join(workspace, fillerDirName) + const targetDir = path.join(workspace, targetDirName) + + for (let index = 0; index < 8_001; index += 1) { + fs.writeFileSync(path.join(fillerDir, `filler-${index}.txt`), "") + } + fs.writeFileSync(path.join(targetDir, "unique-search-target.txt"), "") + + const results = searchWorkspaceFiles(workspace, "unique-search-target", { + type: "file", + refresh: true, + }) + + assert.equal(results.some((entry) => entry.name === "unique-search-target.txt"), true) + + searchWorkspaceFiles(workspace, "filler", { type: "file", refresh: true }) + assert.equal(getWorkspaceCandidates(workspace, "file\0filler")?.length, 8_000) +}) + +test("does not revisit directory links", () => { + const cyclicWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-cycle-")) + try { + fs.symlinkSync(cyclicWorkspace, path.join(cyclicWorkspace, "cycle"), process.platform === "win32" ? "junction" : "dir") + assert.deepEqual(searchWorkspaceFiles(cyclicWorkspace, "not-present", { refresh: true }), []) + } finally { + fs.rmSync(cyclicWorkspace, { recursive: true, force: true }) + } +}) + +test("indexes both real and linked directory paths", () => { + const linkedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-link-")) + try { + const realDirectory = path.join(linkedWorkspace, "b") + fs.mkdirSync(realDirectory) + fs.writeFileSync(path.join(realDirectory, "needle.txt"), "") + fs.symlinkSync(realDirectory, path.join(linkedWorkspace, "a"), process.platform === "win32" ? "junction" : "dir") + + const linkedResults = searchWorkspaceFiles(linkedWorkspace, "a/needle", { type: "file", refresh: true }) + const realResults = searchWorkspaceFiles(linkedWorkspace, "b/needle", { type: "file", refresh: true }) + + assert.equal(linkedResults.some((entry) => entry.path === "a/needle.txt"), true) + assert.equal(realResults.some((entry) => entry.path === "b/needle.txt"), true) + } finally { + fs.rmSync(linkedWorkspace, { recursive: true, force: true }) + } +}) diff --git a/packages/server/src/filesystem/search-cache.ts b/packages/server/src/filesystem/search-cache.ts index 5568204b..17cf4e16 100644 --- a/packages/server/src/filesystem/search-cache.ts +++ b/packages/server/src/filesystem/search-cache.ts @@ -4,16 +4,17 @@ import type { FileSystemEntry } from "../api-types" export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000 interface WorkspaceCandidateCacheEntry { + scope: string expiresAt: number candidates: FileSystemEntry[] } const workspaceCandidateCache = new Map() -export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined { +export function getWorkspaceCandidates(rootDir: string, scope: string, now = Date.now()): FileSystemEntry[] | undefined { const key = normalizeKey(rootDir) const cached = workspaceCandidateCache.get(key) - if (!cached) { + if (!cached || cached.scope !== scope) { return undefined } @@ -27,19 +28,16 @@ export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileS export function refreshWorkspaceCandidates( rootDir: string, + scope: string, builder: () => FileSystemEntry[], now = Date.now(), ): FileSystemEntry[] { const key = normalizeKey(rootDir) const freshCandidates = builder() - if (!freshCandidates || freshCandidates.length === 0) { - workspaceCandidateCache.delete(key) - return [] - } - const storedCandidates = cloneEntries(freshCandidates) workspaceCandidateCache.set(key, { + scope, expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS, candidates: storedCandidates, }) @@ -53,8 +51,7 @@ export function clearWorkspaceSearchCache(rootDir?: string) { return } - const key = normalizeKey(rootDir) - workspaceCandidateCache.delete(key) + workspaceCandidateCache.delete(normalizeKey(rootDir)) } function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] { diff --git a/packages/server/src/filesystem/search.ts b/packages/server/src/filesystem/search.ts index 77347b05..53da6066 100644 --- a/packages/server/src/filesystem/search.ts +++ b/packages/server/src/filesystem/search.ts @@ -40,16 +40,19 @@ export function searchWorkspaceFiles( const limit = normalizeLimit(options.limit) const typeFilter: WorkspaceFileSearchType = options.type ?? "all" const refreshRequested = options.refresh === true + const cacheScope = `${typeFilter}\0${trimmedQuery.toLowerCase()}` let entries: FileSystemEntry[] | undefined try { if (!refreshRequested) { - entries = getWorkspaceCandidates(normalizedRoot) + entries = getWorkspaceCandidates(normalizedRoot, cacheScope) } if (!entries) { - entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot)) + entries = refreshWorkspaceCandidates(normalizedRoot, cacheScope, () => + collectCandidates(normalizedRoot, trimmedQuery, typeFilter), + ) } } catch (error) { clearWorkspaceSearchCache(normalizedRoot) @@ -57,7 +60,6 @@ export function searchWorkspaceFiles( } if (!entries || entries.length === 0) { - clearWorkspaceSearchCache(normalizedRoot) return [] } @@ -80,16 +82,25 @@ export function searchWorkspaceFiles( } -function collectCandidates(rootDir: string): FileSystemEntry[] { - const queue: string[] = [""] +function collectCandidates(rootDir: string, query: string, filter: WorkspaceFileSearchType): FileSystemEntry[] { + const queue: Array<{ relativeDir: string; ancestors: ReadonlySet }> = [ + { relativeDir: "", ancestors: new Set() }, + ] const entries: FileSystemEntry[] = [] while (queue.length > 0 && entries.length < MAX_CANDIDATES) { - const relativeDir = queue.pop() || "" + const queuedDirectory = queue.pop()! + const { relativeDir, ancestors } = queuedDirectory const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir let dirents: fs.Dirent[] + let branchAncestors: ReadonlySet try { + const realDir = normalizeDirectoryIdentity(fs.realpathSync.native(absoluteDir)) + if (ancestors.has(realDir)) { + continue + } + branchAncestors = new Set([...ancestors, realDir]) dirents = fs.readdirSync(absoluteDir, { withFileTypes: true }) } catch { continue @@ -115,9 +126,7 @@ function collectCandidates(rootDir: string): FileSystemEntry[] { const isDirectory = stats.isDirectory() if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) { - if (entries.length < MAX_CANDIDATES) { - queue.push(relativePath) - } + queue.push({ relativeDir: relativePath, ancestors: branchAncestors }) } const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file" @@ -131,8 +140,11 @@ function collectCandidates(rootDir: string): FileSystemEntry[] { modifiedAt: stats.mtime.toISOString(), } - entries.push(entry) + if (!shouldInclude(entry.type, filter) || !fuzzysort.single(query, buildSearchKey(entry))) { + continue + } + entries.push(entry) if (entries.length >= MAX_CANDIDATES) { break } @@ -182,3 +194,7 @@ function normalizeRelativeEntryPath(relativePath: string): string { function buildSearchKey(entry: FileSystemEntry) { return entry.path.toLowerCase() } + +function normalizeDirectoryIdentity(directoryPath: string) { + return process.platform === "win32" ? directoryPath.toLowerCase() : directoryPath +} diff --git a/packages/server/src/index.test.ts b/packages/server/src/index.test.ts new file mode 100644 index 00000000..2e8f4cf8 --- /dev/null +++ b/packages/server/src/index.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { once } from "node:events" +import { describe, it } from "node:test" + +import { installShutdownSignalHandlers, installShutdownStdinHandler, STDIN_SHUTDOWN_COMMAND } from "./index" +import { createServerShutdownHandler } from "./shutdown" + +describe("CLI shutdown signal registration", () => { + it("routes a second process signal to forced nonzero escalation", async () => { + const listeners = new Map void>() + let finishCleanup!: () => void + const cleanup = new Promise((resolve) => { + finishCleanup = resolve + }) + const exits: number[] = [] + let handled: Promise | undefined + const shutdown = createServerShutdownHandler({ + shutdown: () => cleanup, + logger: { info() {}, warn() {}, error() {} }, + forceExit: (code) => exits.push(code), + setExitCode: () => undefined, + reportStatus: () => undefined, + }) + installShutdownSignalHandlers( + { on: (signal, listener) => listeners.set(signal, listener) }, + (signal) => (handled = shutdown(signal)), + ) + + listeners.get("SIGINT")?.() + listeners.get("SIGTERM")?.() + assert.deepEqual(exits, [1]) + + finishCleanup() + await handled + }) + + it("coalesces chunked and repeated stdin shutdown commands through the same handler", async () => { + const listeners = new Map void>() + const triggers: string[] = [] + let destroys = 0 + const source = { + on: (event: "data", listener: (chunk: Buffer | string) => void) => listeners.set(event, listener), + off: (event: "data", listener: (chunk: Buffer | string) => void) => { + if (listeners.get(event) === listener) listeners.delete(event) + }, + destroy: () => { destroys++ }, + } + installShutdownStdinHandler(source, async (trigger) => { triggers.push(trigger) }) + + const listener = listeners.get("data")! + listener(STDIN_SHUTDOWN_COMMAND.slice(0, 8)) + listener(`${STDIN_SHUTDOWN_COMMAND.slice(8)}\n${STDIN_SHUTDOWN_COMMAND}\n`) + listener(`${STDIN_SHUTDOWN_COMMAND}\n`) + await Promise.resolve() + + assert.deepEqual(triggers, ["stdin"]) + assert.equal(destroys, 1) + assert.equal(listeners.has("data"), false) + }) + + it("allows a real piped process to exit naturally after the shutdown command", { timeout: 5_000 }, async () => { + const moduleUrl = new URL("./index.ts", import.meta.url).href + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", ` + import { installShutdownStdinHandler } from ${JSON.stringify(moduleUrl)} + installShutdownStdinHandler(process.stdin, async () => { process.exitCode = 0 }) + `], { stdio: ["pipe", "ignore", "inherit"] }) + + child.stdin.end(`${STDIN_SHUTDOWN_COMMAND}\n`) + const [code] = await once(child, "exit") + assert.equal(code, 0) + }) +}) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 935cc040..0f06a2cf 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -32,6 +32,10 @@ import { ClientConnectionManager } from "./clients/connection-manager" import { PluginChannelManager } from "./plugins/channel" import { VoiceModeManager } from "./plugins/voice-mode" import { runCliUpgrade } from "./cli-upgrade" +import { createServerShutdownHandler, orchestrateServerShutdown, type ServerShutdownTrigger } from "./shutdown" +import { AutoAcceptManager } from "./permissions/auto-accept-manager" +import { createOpencodePermissionReplier } from "./permissions/opencode-replier" +import { createOpencodeYoloPersistence } from "./permissions/opencode-yolo-metadata" const require = createRequire(import.meta.url) @@ -73,6 +77,46 @@ const DEFAULT_HOST = "127.0.0.1" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_HTTPS_PORT = 9898 const DEFAULT_HTTP_PORT = 9899 +export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown" + +interface ShutdownSignalSource { + on: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown +} + +export function installShutdownSignalHandlers( + source: ShutdownSignalSource, + shutdown: (signal: ServerShutdownTrigger) => Promise, +): void { + source.on("SIGINT", () => void shutdown("SIGINT")) + source.on("SIGTERM", () => void shutdown("SIGTERM")) +} + +interface ShutdownStdinSource { + on(event: "data", listener: (chunk: Buffer | string) => void): unknown + off?(event: "data", listener: (chunk: Buffer | string) => void): unknown + destroy?(): unknown +} + +export function installShutdownStdinHandler( + source: ShutdownStdinSource, + shutdown: (signal: ServerShutdownTrigger) => Promise, +): void { + let buffer = "" + let requested = false + const onData = (chunk: Buffer | string) => { + if (requested) return + buffer += chunk.toString() + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() ?? "" + if (!lines.some((line) => line.trim() === STDIN_SHUTDOWN_COMMAND)) return + + requested = true + source.off?.("data", onData) + source.destroy?.() + void shutdown("stdin") + } + source.on("data", onData) +} function parseCliOptions(argv: string[]): CliOptions { const program = new Command() @@ -343,6 +387,15 @@ async function main() { logger: logger.child({ component: "sidecars" }), }) const previewManager = new PreviewManager() + const yoloLogger = logger.child({ component: "yolo" }) + const sessionMetadataPersistence = createOpencodeYoloPersistence(workspaceManager) + const yoloManager = new AutoAcceptManager({ + eventBus, + logger: yoloLogger, + replier: createOpencodePermissionReplier({ workspaceManager, logger: yoloLogger }), + persistence: sessionMetadataPersistence, + }) + yoloManager.start() const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, @@ -444,6 +497,8 @@ async function main() { pluginChannel, voiceModeManager, remoteProxySessionManager, + yoloManager, + sessionMetadataPersistence, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, @@ -471,6 +526,8 @@ async function main() { pluginChannel, voiceModeManager, remoteProxySessionManager, + yoloManager, + sessionMetadataPersistence, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, @@ -555,68 +612,46 @@ async function main() { await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" })) } - let shuttingDown = false + const shutdown = createServerShutdownHandler({ + logger, + holdAfterFailure: () => new Promise(() => { setInterval(() => undefined, 60_000) }), + setExitCode: (code) => { + process.stdin.destroy() + process.exitCode = code + }, + shutdown: () => + orchestrateServerShutdown( + { + stopInstanceEventBridge: () => instanceEventBridge.shutdown(), + stopSidecars: () => sidecarManager.shutdown(), + stopClientConnections: () => clientConnectionManager.shutdown(), + stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(), + stopWorkspaces: () => workspaceManager.shutdown(), + stopHttpServers: async () => { + yoloManager.stop() + const results = await Promise.allSettled(servers.map((srv) => srv.stop())) + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length > 0) { + const error = new Error("One or more HTTP servers failed to stop") as Error & { failures: unknown[] } + error.failures = failures + throw error + } + logger.info("HTTP server(s) stopped") + }, + stopReleaseMonitor: () => devReleaseMonitor?.stop(), + }, + logger, + ), + }) - const shutdown = async () => { - if (shuttingDown) { - logger.info("Shutdown already in progress, ignoring signal") - return - } - shuttingDown = true - logger.info("Received shutdown signal, stopping workspaces and server") - - const shutdownWorkspaces = (async () => { - try { - instanceEventBridge.shutdown() - } catch (error) { - logger.warn({ err: error }, "Instance event bridge shutdown failed") - } - - try { - await sidecarManager.shutdown() - } catch (error) { - logger.error({ err: error }, "SideCar manager shutdown failed") - } - - try { - clientConnectionManager.shutdown() - } catch (error) { - logger.warn({ err: error }, "Client connection manager shutdown failed") - } - - try { - await workspaceManager.shutdown() - logger.info("Workspace manager shutdown complete") - } catch (error) { - logger.error({ err: error }, "Workspace manager shutdown failed") - } - })() - - const shutdownHttp = (async () => { - try { - await Promise.allSettled(servers.map((srv) => srv.stop())) - logger.info("HTTP server(s) stopped") - } catch (error) { - logger.error({ err: error }, "Failed to stop HTTP server") - } - })() - - await Promise.allSettled([shutdownWorkspaces, shutdownHttp]) - - // no-op: remote UI manifest replaces GitHub release monitor - - devReleaseMonitor?.stop() - - logger.info("Exiting process") - process.exit(0) - } - - process.on("SIGINT", shutdown) - process.on("SIGTERM", shutdown) + installShutdownSignalHandlers(process, shutdown) + installShutdownStdinHandler(process.stdin, shutdown) } -main().catch((error) => { - const logger = createLogger({ component: "app" }) - logger.error({ err: error }, "CLI server crashed") - process.exit(1) -}) +if (path.resolve(process.argv[1] ?? "") === __filename) { + main().catch((error) => { + const logger = createLogger({ component: "app" }) + logger.error({ err: error }, "CLI server crashed") + process.exit(1) + }) +} diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts new file mode 100644 index 00000000..2b631529 --- /dev/null +++ b/packages/server/src/opencode-update/service.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { OpenCodeUpdateError, OpenCodeUpdateService, type OpenCodeUpdateServiceDeps } from "./service" + +function createDeps(overrides: Partial = {}): OpenCodeUpdateServiceDeps { + let currentVersion = "1.0.0" + return { + resolveBinary: () => ({ path: "opencode", label: "OpenCode" }), + probeBinary: () => ({ valid: true, version: currentVersion }), + findReadyInstanceId: () => "workspace-1", + fetchLatestVersion: async () => "1.1.0", + upgradeInstance: async (_instanceId, target) => { + currentVersion = target + return { success: true, version: target } + }, + ...overrides, + } +} + +test("reports an available update and caches the latest version", async () => { + let checks = 0 + const service = new OpenCodeUpdateService(createDeps({ + fetchLatestVersion: async () => { + checks += 1 + return "1.1.0" + }, + })) + + assert.deepEqual(await service.getStatus(), { + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateAvailable: true, + canUpgrade: true, + }) + await service.getStatus() + assert.equal(checks, 1) +}) + +test("keeps the update visible when no matching instance is ready", async () => { + const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined })) + + assert.deepEqual(await service.getStatus(), { + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateAvailable: true, + canUpgrade: false, + }) +}) + +test("preserves the installed version when the registry check fails", async () => { + const service = new OpenCodeUpdateService(createDeps({ + fetchLatestVersion: async () => { + throw new Error("registry unavailable") + }, + })) + + assert.deepEqual(await service.getStatus(), { + currentVersion: "1.0.0", + latestVersion: null, + updateAvailable: null, + canUpgrade: false, + checkError: "update_check_failed", + }) +}) + +test("upgrades through the matching OpenCode instance to the advertised version", async () => { + const calls: Array<{ instanceId: string; target: string }> = [] + let currentVersion = "1.0.0" + const service = new OpenCodeUpdateService(createDeps({ + probeBinary: () => ({ valid: true, version: currentVersion }), + upgradeInstance: async (instanceId, target) => { + calls.push({ instanceId, target }) + currentVersion = target + return { success: true, version: target } + }, + })) + + assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" }) + assert.deepEqual(calls, [{ instanceId: "workspace-1", target: "1.1.0" }]) +}) + +test("rejects success when the configured binary was not updated", async () => { + const service = new OpenCodeUpdateService(createDeps({ + probeBinary: () => ({ valid: true, version: "1.0.0" }), + upgradeInstance: async (_instanceId, target) => ({ success: true, version: target }), + })) + + await assert.rejects( + () => service.upgrade(), + (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed", + ) +}) + +test("joins concurrent upgrades for the same binary", async () => { + let currentVersion = "1.0.0" + let upgrades = 0 + let finishUpgrade: (() => void) | undefined + const gate = new Promise((resolve) => { + finishUpgrade = resolve + }) + const service = new OpenCodeUpdateService(createDeps({ + probeBinary: () => ({ valid: true, version: currentVersion }), + upgradeInstance: async (_instanceId, target) => { + upgrades += 1 + await gate + currentVersion = target + return { success: true, version: target } + }, + })) + + const first = service.upgrade() + const second = service.upgrade() + finishUpgrade?.() + + assert.deepEqual(await Promise.all([first, second]), [ + { success: true, version: "1.1.0" }, + { success: true, version: "1.1.0" }, + ]) + assert.equal(upgrades, 1) +}) + +test("rejects an upgrade when no matching OpenCode instance is running", async () => { + const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined })) + + await assert.rejects( + () => service.upgrade(), + (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "no_ready_instance", + ) +}) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts new file mode 100644 index 00000000..c84167d2 --- /dev/null +++ b/packages/server/src/opencode-update/service.ts @@ -0,0 +1,194 @@ +import { fetch } from "undici" +import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types" +import type { SettingsService } from "../settings/service" +import { BinaryResolver, type ResolvedBinary } from "../settings/binaries" +import type { WorkspaceManager } from "../workspaces/manager" +import { createInstanceClient } from "../workspaces/instance-client" +import { probeBinaryVersion } from "../workspaces/spawn" +import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor" + +const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest" +const LATEST_VERSION_CACHE_MS = 5 * 60_000 +const UPGRADE_TIMEOUT_MS = 10 * 60_000 +const inFlightUpgrades = new Map>() + +type UpgradeResult = { success: true; version: string } | { success: false; error: string } + +export interface OpenCodeUpdateServiceDeps { + resolveBinary: () => ResolvedBinary + probeBinary: typeof probeBinaryVersion + findReadyInstanceId: (binaryPath: string) => string | undefined + upgradeInstance: (instanceId: string, target: string) => Promise + fetchLatestVersion: () => Promise + now?: () => number +} + +export class OpenCodeUpdateError extends Error { + constructor( + readonly code: + | "binary_unavailable" + | "no_ready_instance" + | "update_check_failed" + | "upgrade_failed" + | "upgrade_verification_failed", + message: string, + ) { + super(message) + this.name = "OpenCodeUpdateError" + } +} + +export class OpenCodeUpdateService { + private latestVersionCache: { version: string; expiresAt: number } | null = null + + constructor(private readonly deps: OpenCodeUpdateServiceDeps) {} + + async getStatus(): Promise { + const binary = this.deps.resolveBinary() + const currentVersion = this.readCurrentVersion(binary.path) + let latestVersion: string + try { + latestVersion = await this.readLatestVersion() + } catch (error) { + if (!(error instanceof OpenCodeUpdateError) || error.code !== "update_check_failed") throw error + return { + currentVersion, + latestVersion: null, + updateAvailable: null, + canUpgrade: false, + checkError: "update_check_failed", + } + } + const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0 + const readyInstanceId = this.deps.findReadyInstanceId(binary.path) + + return { + currentVersion, + latestVersion, + updateAvailable, + canUpgrade: updateAvailable && Boolean(readyInstanceId), + } + } + + upgrade(): Promise { + const binary = this.deps.resolveBinary() + const existing = inFlightUpgrades.get(binary.path) + if (existing) return existing + + const pending = this.performUpgrade(binary).finally(() => { + if (inFlightUpgrades.get(binary.path) === pending) inFlightUpgrades.delete(binary.path) + }) + inFlightUpgrades.set(binary.path, pending) + return pending + } + + private async performUpgrade(binary: ResolvedBinary): Promise { + const currentVersion = this.readCurrentVersion(binary.path) + const latestVersion = await this.readLatestVersion() + + if (compareVersionStrings(latestVersion, currentVersion) <= 0) { + return { success: true, version: currentVersion } + } + + const instanceId = this.deps.findReadyInstanceId(binary.path) + if (!instanceId) { + throw new OpenCodeUpdateError( + "no_ready_instance", + "No running OpenCode instance uses the configured binary", + ) + } + + try { + const result = await this.deps.upgradeInstance(instanceId, latestVersion) + if (!result.success) { + throw new OpenCodeUpdateError("upgrade_failed", result.error) + } + const installedVersion = this.readCurrentVersion(binary.path) + if (compareVersionStrings(installedVersion, latestVersion) !== 0) { + throw new OpenCodeUpdateError( + "upgrade_verification_failed", + `OpenCode reported ${result.version}, but the configured binary is still ${installedVersion}`, + ) + } + return { success: true, version: installedVersion } + } catch (error) { + if (error instanceof OpenCodeUpdateError) throw error + throw new OpenCodeUpdateError( + "upgrade_failed", + error instanceof Error ? error.message : "OpenCode upgrade failed", + ) + } + } + + private readCurrentVersion(binaryPath: string): string { + if (process.platform === "win32" && /["\r\n]/.test(binaryPath)) { + throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid") + } + const result = this.deps.probeBinary(binaryPath) + const version = stripTagPrefix(result.version) + if (!result.valid || !version) { + throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version") + } + return version + } + + private async readLatestVersion(): Promise { + const now = (this.deps.now ?? Date.now)() + if (this.latestVersionCache && this.latestVersionCache.expiresAt > now) { + return this.latestVersionCache.version + } + + try { + const version = stripTagPrefix(await this.deps.fetchLatestVersion()) + if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error("OpenCode registry returned an invalid version") + } + this.latestVersionCache = { version, expiresAt: now + LATEST_VERSION_CACHE_MS } + return version + } catch (error) { + throw new OpenCodeUpdateError( + "update_check_failed", + error instanceof Error ? error.message : "Unable to check the latest OpenCode version", + ) + } + } +} + +export async function fetchLatestOpenCodeVersion(): Promise { + const response = await fetch(OPENCODE_LATEST_URL, { + headers: { Accept: "application/json", "User-Agent": "CodeNomad-CLI" }, + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) { + throw new Error(`OpenCode registry responded with ${response.status}`) + } + const payload = (await response.json()) as { version?: unknown } + if (typeof payload.version !== "string") { + throw new Error("OpenCode registry response did not include a version") + } + return payload.version +} + +export function createOpenCodeUpdateService( + settings: SettingsService, + workspaceManager: WorkspaceManager, +): OpenCodeUpdateService { + const binaryResolver = new BinaryResolver(settings) + return new OpenCodeUpdateService({ + resolveBinary: () => { + const binary = binaryResolver.resolveDefault() + return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) } + }, + probeBinary: probeBinaryVersion, + findReadyInstanceId: (binaryPath) => workspaceManager.findReadyInstanceIdByBinary(binaryPath), + fetchLatestVersion: fetchLatestOpenCodeVersion, + upgradeInstance: async (instanceId, target) => { + const client = createInstanceClient(workspaceManager, instanceId, { timeoutMs: UPGRADE_TIMEOUT_MS }) + if (!client) { + throw new OpenCodeUpdateError("no_ready_instance", "OpenCode instance is not ready") + } + const { data } = await client.global.upgrade({ target }, { throwOnError: true }) + return data + }, + }) +} diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts new file mode 100644 index 00000000..d803587b --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -0,0 +1,881 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { EventBus } from "../events/bus" +import { AutoAcceptManager, type AutoAcceptPersistence, type PermissionReplier, type AutoAcceptReply } from "./auto-accept-manager" +import type { InstanceStreamEvent } from "../api-types" +import type { Logger } from "../logger" + +const noopLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + trace() {}, + isLevelEnabled() { + return false + }, + child() { + return noopLogger + }, +} as unknown as Logger + +function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record) { + bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent }) +} + +/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */ +function publishSession( + bus: EventBus, + instanceId: string, + eventType: "session.updated" | "session.created" | "session.deleted", + info: Record, +) { + publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } }) +} + +describe("AutoAcceptManager session tree", () => { + it("ingests session.updated to build the parent chain", () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.toggle("inst", "child") + assert.equal(manager.isEnabled("inst", "child"), true) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) + + it("treats a session with revert as a fork root", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { + id: "fork", + parentID: "master", + revert: { messageID: "m", partID: "p" }, + }) + + manager.toggle("inst", "fork") + assert.equal(manager.isEnabled("inst", "fork"), true) + assert.equal(manager.isEnabled("inst", "master"), false) + + manager.stop() + }) + + it("session.deleted removes the tree entry but keeps the toggle", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + publishSession(bus, "inst", "session.deleted", { id: "master" }) + + // toggle is independent of the tree (survives deletion) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) +}) + +describe("AutoAcceptManager persistence", () => { + it("hydrates a persisted family root before processing queued permissions", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const persistence: AutoAcceptPersistence = { + async loadSessions() { + await gate + return [ + { id: "root", parentId: null, yoloEnabled: true }, + { id: "child", parentId: "root", yoloEnabled: false }, + ] + }, + async persist() {}, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence }) + manager.start() + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "root" }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "permission", sessionID: "child" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + release() + await manager.hydrateInstance("inst") + await flushMicrotasks() + assert.equal(manager.isEnabled("inst", "child"), true) + assert.equal(replier.calls.length, 1) + manager.stop() + }) + + it("persists before enabling memory and emitting feedback", async () => { + const bus = new EventBus(noopLogger) + const changes: Record[] = [] + bus.on("yolo.stateChanged", (event) => changes.push(event)) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const writes: unknown[][] = [] + const persistence: AutoAcceptPersistence = { + async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async persist(...args) { writes.push(args); await gate }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + const toggle = manager.toggle("inst", "root") + await flushMicrotasks() + assert.deepEqual(writes, [["inst", "root", true, undefined]]) + assert.equal(manager.isEnabled("inst", "root"), false) + assert.equal(changes.length, 0) + release() + assert.equal(await toggle, true) + assert.equal(manager.isEnabled("inst", "root"), true) + assert.equal(changes.length, 1) + }) + + it("serializes concurrent toggles", async () => { + const bus = new EventBus(noopLogger) + const writes: boolean[] = [] + const persistence: AutoAcceptPersistence = { + async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async persist(_instanceId, _rootSessionId, enabled) { writes.push(enabled) }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + assert.deepEqual(await Promise.all([manager.toggle("inst", "root"), manager.toggle("inst", "root")]), [true, false]) + assert.deepEqual(writes, [true, false]) + assert.equal(manager.isEnabled("inst", "root"), false) + }) + + it("keeps queued permissions until a failed hydration can retry", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + let attempts = 0 + const persistence: AutoAcceptPersistence = { + async loadSessions() { + if (++attempts === 1) throw new Error("temporary failure") + return [{ id: "root", parentId: null, yoloEnabled: true }] + }, + async persist() {}, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence }) + manager.start() + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "permission", sessionID: "root" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + await manager.hydrateInstance("inst") + await flushMicrotasks() + assert.equal(replier.calls.length, 1) + manager.stop() + }) + + it("does not re-enable memory when a persisted toggle finishes after cleanup", async () => { + const bus = new EventBus(noopLogger) + const changes: Record[] = [] + bus.on("yolo.stateChanged", (event) => changes.push(event)) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let writes = 0 + const persistence: AutoAcceptPersistence = { + async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async persist() { writes += 1; await gate }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + const toggle = manager.toggle("inst", "root") + const queued = manager.toggle("inst", "root") + await flushMicrotasks() + manager.clearInstance("inst") + release() + assert.equal(await toggle, false) + assert.equal(await queued, false) + assert.equal(writes, 1) + assert.equal(manager.isEnabled("inst", "root"), false) + assert.equal(changes.length, 0) + }) + + it("moves persisted Yolo state when late ancestry changes the family root", async () => { + const bus = new EventBus(noopLogger) + const writes: unknown[][] = [] + const persistence: AutoAcceptPersistence = { + async loadSessions() { + return [ + { id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false }, + { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true }, + ] + }, + async persist(...args) { writes.push(args) }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + manager.start() + await manager.hydrateInstance("inst") + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "parent", workspaceID: "workspace" }) + await flushMicrotasks() + assert.equal(manager.isEnabled("inst", "parent"), true) + assert.deepEqual(writes, [ + ["inst", "parent", true, "workspace"], + ["inst", "child", false, "workspace"], + ]) + manager.stop() + }) + + it("does not re-enable a family when ancestry repeatedly changes during a disable", async () => { + const bus = new EventBus(noopLogger) + let releaseFirst!: () => void + let releaseSecond!: () => void + const firstGate = new Promise((resolve) => { releaseFirst = resolve }) + const secondGate = new Promise((resolve) => { releaseSecond = resolve }) + const writes: unknown[][] = [] + const persistence: AutoAcceptPersistence = { + async loadSessions() { + return [ + { id: "grandparent", parentId: null, workspaceId: "workspace", yoloEnabled: false }, + { id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false }, + { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true }, + ] + }, + async persist(...args) { + writes.push(args) + if (writes.length === 1) await firstGate + if (writes.length === 2) await secondGate + }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + manager.start() + await manager.hydrateInstance("inst") + const toggle = manager.toggle("inst", "child") + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "parent", workspaceID: "workspace" }) + releaseFirst() + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "grandparent", workspaceID: "workspace" }) + releaseSecond() + assert.equal(await toggle, false) + await flushMicrotasks() + assert.equal(manager.isEnabled("inst", "grandparent"), false) + assert.equal(writes.some(([, , enabled]) => enabled === true), false, JSON.stringify(writes)) + manager.stop() + }) + + it("allows a queued toggle to continue after an earlier persistence failure", async () => { + const bus = new EventBus(noopLogger) + let attempts = 0 + const persistence: AutoAcceptPersistence = { + async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async persist() { if (++attempts === 1) throw new Error("write failed") }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + const first = manager.toggle("inst", "root") + const second = manager.toggle("inst", "root") + await assert.rejects(Promise.resolve(first), /write failed/) + assert.equal(await second, true) + assert.equal(manager.isEnabled("inst", "root"), true) + }) + + it("does not restore a late hydration after workspace cleanup", async () => { + const bus = new EventBus(noopLogger) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const persistence: AutoAcceptPersistence = { + async loadSessions() { await gate; return [{ id: "root", parentId: null, yoloEnabled: true }] }, + async persist() {}, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + const hydration = manager.hydrateInstance("inst") + manager.clearInstance("inst") + release() + await hydration + assert.equal(manager.isEnabled("inst", "root"), false) + }) +}) + +describe("AutoAcceptManager permission interception", () => { + it("auto-replies to a v2 permission on an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const accepted: Record[] = [] + bus.on("yolo.autoAccepted", (e) => accepted.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + manager.toggle("inst", "child") // enable the whole family root + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + const call = replier.calls[0] + assert.equal(call.instanceId, "inst") + assert.equal(call.permissionId, "perm-1") + assert.equal(call.sessionId, "child") + assert.equal(call.source, "v2") + assert.equal(call.reply, "once") + assert.equal(accepted.length, 1) + assert.equal((accepted[0] as any).permissionId, "perm-1") + + manager.stop() + }) + + it("auto-replies to a legacy permission.asked event", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-2", sessionID: "solo", type: "bash" }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "legacy") + assert.equal(replier.calls[0].permissionId, "perm-2") + + manager.stop() + }) + + it("does not reply when the family is disabled", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-3", sessionID: "solo" }, + }) + + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.stop() + }) + + it("ignores permission events without an id or sessionID", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } }) + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("deduplicates repeated emission of the same permission", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + for (let i = 0; i < 3; i++) { + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-dup", sessionID: "solo" }, + }) + } + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + manager.stop() + }) + + it("clears in-flight tracking after the reply resolves so it can retry", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 2) + manager.stop() + }) +}) + +describe("AutoAcceptManager state events", () => { + it("publishes yolo.stateChanged with the new enabled value on toggle", () => { + const bus = new EventBus(noopLogger) + const changes: Record[] = [] + bus.on("yolo.stateChanged", (e) => changes.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + + manager.toggle("inst", "master") + manager.toggle("inst", "master") + + assert.equal(changes.length, 2) + assert.equal((changes[0] as any).enabled, true) + assert.equal((changes[1] as any).enabled, false) + assert.equal((changes[0] as any).sessionId, "master") + assert.equal((changes[0] as any).instanceId, "inst") + + manager.stop() + }) +}) + +describe("AutoAcceptManager lifecycle", () => { + it("clearInstance drops tree and enabled state for the instance", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + manager.clearInstance("inst") + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("clears state when the workspace stops", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + bus.publish({ type: "workspace.stopped", workspaceId: "inst" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("stop() unsubscribes so no further events are processed", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + manager.stop() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "p", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + }) +}) + +describe("AutoAcceptManager pending permissions drain", () => { + it("drains a pending permission that arrived before enable", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-pending", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // enabling yolo should drain the pending permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-pending") + + manager.stop() + }) + + it("drains pending permissions for the same family only", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-a", sessionID: "root-a" }, + }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-b", sessionID: "root-b" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.toggle("inst", "root-a") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-a") + + manager.stop() + }) + + it("does not re-drain already-auto-accepted permissions", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 1) + + // toggling off then on should not re-drain the already-replied permission + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain runs but pending set is empty + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + + manager.stop() + }) + + it("re-drains pending when late session ancestry joins an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + // master exists, yolo enabled on master + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + + // child permission arrives BEFORE child's session ancestry is known. + // At this point "child" is unknown to the tree, so it resolves as its + // own root and the permission is NOT auto-accepted. + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-late", sessionID: "child" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // child session ancestry arrives — child.parentID = "master". + // ingestSession → upsertSession → migrateEnabledRoots makes "child" + // resolve to "master" (enabled). drainPending should fire and accept + // the previously-pending permission. + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-late") + assert.equal(replier.calls[0].sessionId, "child") + + manager.stop() + }) +}) + +describe("AutoAcceptManager permission replied cleanup", () => { + it("removes a pending permission on permission.v2.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-x", sessionID: "solo" }, + }) + await flushMicrotasks() + + // user manually replies → replied event cleans up pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.replied", + properties: { id: "perm-x" }, + }) + + // enabling yolo now should NOT drain the already-replied permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("removes a pending permission on legacy permission.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-y", sessionID: "solo" }, + }) + await flushMicrotasks() + + publishInstanceEvent(bus, "inst", { + type: "permission.replied", + properties: { requestID: "perm-y" }, + }) + + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager clearInstance clears pending", () => { + it("drops pending permissions on clearInstance", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-z", sessionID: "solo" }, + }) + await flushMicrotasks() + + manager.clearInstance("inst") + + // re-create session and enable — pending set should be empty + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager permission.updated source inference", () => { + it("preserves the original v2 source when permission.updated arrives", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + // yolo is OFF — permission goes to pending with source "v2" + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + // enable yolo, then send permission.updated — should keep source "v2" + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "v2") + + manager.stop() + }) + + it("skips permission.updated for a permission not in pending", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + // permission.updated for a permission that was never asked (not in pending) + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-unknown", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager replier failure handling", () => { + it("keeps permission pending and does not emit autoAccepted on failure", async () => { + const bus = new EventBus(noopLogger) + const autoAccepted: unknown[] = [] + bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e)) + const failingReplier: PermissionReplier = async () => { + throw new Error("connection refused") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-fail", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + // permission should still be pending (enabling again would try again) + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain retries + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + + manager.stop() + }) + + it("stops retrying after MAX_REPLY_ATTEMPTS", async () => { + const bus = new EventBus(noopLogger) + let callCount = 0 + const failingReplier: PermissionReplier = async () => { + callCount++ + throw new Error("timeout") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-stuck", sessionID: "solo" }, + }) + await flushMicrotasks() + const attemptsAfterFirst = callCount + + // trigger drains via session events + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + + // should have attempted at most MAX_REPLY_ATTEMPTS times total + assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`) + assert.equal(callCount, attemptsAfterFirst + 2) // 2 more retries (total 3), then stops + + manager.stop() + }) +}) + +describe("AutoAcceptManager workspace.error cleanup", () => { + it("clears state when the workspace errors", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + assert.equal(manager.isEnabled("inst", "solo"), true) + + bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any }) + + assert.equal(manager.isEnabled("inst", "solo"), false) + manager.stop() + }) +}) + +describe("AutoAcceptManager session.deleted clears pending", () => { + it("removes pending permissions for a deleted session", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF → goes to pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-doomed", sessionID: "solo" }, + }) + await flushMicrotasks() + + // session deleted → pending should be cleared + publishSession(bus, "inst", "session.deleted", { id: "solo" }) + + // enabling yolo should not drain the deleted session's permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +function makeRecordingReplier() { + const calls: AutoAcceptReply[] = [] + const replier: PermissionReplier = async (reply) => { + calls.push(reply) + } + return Object.assign(replier, { calls }) as PermissionReplier & { calls: AutoAcceptReply[] } +} + +function flushMicrotasks() { + return new Promise((resolve) => setImmediate(resolve)) +} diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts new file mode 100644 index 00000000..3fc7e7e6 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -0,0 +1,475 @@ +import type { EventBus } from "../events/bus" +import type { Logger } from "../logger" +import { AutoAcceptStore, type AutoAcceptSessionInfo } from "./auto-accept-store" + +/** + * Server-side owner of Yolo (permission auto-accept). + * + * Subscribes to the instance SSE stream that the server already consumes + * (`InstanceEventBridge` -> EventBus `instance.event`) and: + * - maintains a per-instance session tree so family-root inheritance can + * be resolved identically to the previous frontend implementation + * - when a permission request arrives for an enabled family, auto-replies + * via the injected {@link PermissionReplier} (same `"once"` semantics the + * UI used to send) + * - emits `yolo.stateChanged` / `yolo.autoAccepted` events on the EventBus + * so the UI stays a pure view + */ + +export type PermissionSource = "v2" | "legacy" +export type PermissionReplyValue = "once" + +export interface AutoAcceptReply { + instanceId: string + permissionId: string + sessionId: string + source: PermissionSource + reply: PermissionReplyValue +} + +export type PermissionReplier = (reply: AutoAcceptReply) => Promise + +interface PendingPermission { + permissionId: string + sessionId: string + source: PermissionSource +} + +interface AutoAcceptManagerDeps { + eventBus: EventBus + logger: Logger + replier: PermissionReplier + persistence?: AutoAcceptPersistence +} + +export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo { + yoloEnabled: boolean + workspaceId?: string +} + +export interface AutoAcceptPersistence { + loadSessions(instanceId: string): Promise + persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise +} + +const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) +const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) +const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) +const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) + +export class AutoAcceptManager { + private static readonly MAX_REPLY_ATTEMPTS = 3 + private readonly store = new AutoAcceptStore() + /** instanceId:permissionId entries currently being replied, to dedupe re-emissions */ + private readonly inFlight = new Set() + /** instanceId -> (permissionId -> pending permission) awaiting a reply */ + private readonly pending = new Map>() + /** instanceId:permissionId -> failure count, to stop retrying stuck permissions */ + private readonly replyAttempts = new Map() + private readonly hydratedInstances = new Set() + private readonly hydration = new Map>() + private readonly queuedEvents = new Map() + private readonly instanceGeneration = new Map() + private readonly sessionWorkspaces = new Map>() + private readonly mutations = new Map>() + private unsubscribe?: () => void + + constructor(private readonly deps: AutoAcceptManagerDeps) {} + + start(): void { + if (this.unsubscribe) return + const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => { + if (!payload || !payload.instanceId || !payload.event) return + if (this.deps.persistence && !this.hydratedInstances.has(payload.instanceId)) { + const queued = this.queuedEvents.get(payload.instanceId) ?? [] + queued.push(payload.event) + this.queuedEvents.set(payload.instanceId, queued) + void this.hydrateInstance(payload.instanceId).catch((error) => { + this.deps.logger.warn({ instanceId: payload.instanceId, err: error }, "Failed to hydrate persisted Yolo state") + }) + return + } + this.handleInstanceEvent(payload.instanceId, payload.event) + } + const onStarted = (event: { workspace?: { id?: string } }) => { + const instanceId = event.workspace?.id + if (!instanceId) return + void this.hydrateInstance(instanceId).catch((error) => { + this.deps.logger.warn({ instanceId, err: error }, "Failed to hydrate persisted Yolo state") + }) + } + const onStopped = (event: { workspaceId?: string }) => { + if (event?.workspaceId) this.clearInstance(event.workspaceId) + } + const onError = (event: { workspace?: { id?: string } }) => { + if (event?.workspace?.id) this.clearInstance(event.workspace.id) + } + this.deps.eventBus.on("instance.event", handler) + this.deps.eventBus.on("workspace.started", onStarted) + this.deps.eventBus.on("workspace.stopped", onStopped) + this.deps.eventBus.on("workspace.error", onError) + this.unsubscribe = () => { + this.deps.eventBus.off("instance.event", handler) + this.deps.eventBus.off("workspace.started", onStarted) + this.deps.eventBus.off("workspace.stopped", onStopped) + this.deps.eventBus.off("workspace.error", onError) + } + } + + stop(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + isEnabled(instanceId: string, sessionId: string): boolean { + return this.store.isEnabled(instanceId, sessionId) + } + + hydrateInstance(instanceId: string): Promise { + if (!this.deps.persistence || this.hydratedInstances.has(instanceId)) return Promise.resolve() + const existing = this.hydration.get(instanceId) + if (existing) return existing + const generation = this.instanceGeneration.get(instanceId) ?? 0 + let hydrated = false + const pending = this.deps.persistence.loadSessions(instanceId).then((sessions) => { + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return + this.store.clearInstance(instanceId) + const workspaces = new Map() + for (const session of sessions) { + this.store.upsertSession(instanceId, session) + if (session.workspaceId) workspaces.set(session.id, session.workspaceId) + } + this.sessionWorkspaces.set(instanceId, workspaces) + for (const session of sessions) { + if (!session.yoloEnabled || this.store.familyRoot(instanceId, session.id) !== session.id) continue + this.store.setEnabled(instanceId, session.id, true) + this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: session.id, enabled: true }) + this.drainPending(instanceId, session.id) + } + this.hydratedInstances.add(instanceId) + hydrated = true + }).finally(() => { + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return + this.hydration.delete(instanceId) + if (!hydrated) return + const queued = this.queuedEvents.get(instanceId) ?? [] + this.queuedEvents.delete(instanceId) + for (const event of queued) this.handleInstanceEvent(instanceId, event) + }) + this.hydration.set(instanceId, pending) + return pending + } + + toggle(instanceId: string, sessionId: string): boolean | Promise { + if (this.deps.persistence) return this.togglePersisted(instanceId, sessionId) + const enabled = this.store.toggle(instanceId, sessionId) + this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled }) + if (enabled) { + this.drainPending(instanceId, sessionId) + } + return enabled + } + + private async togglePersisted(instanceId: string, sessionId: string): Promise { + await this.hydrateInstance(instanceId) + const generation = this.instanceGeneration.get(instanceId) ?? 0 + const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => { + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { + return this.store.isEnabled(instanceId, sessionId) + } + const rootSessionId = this.store.familyRoot(instanceId, sessionId) + const traversedRootSessionIds = new Set([rootSessionId]) + const enabled = !this.store.isEnabled(instanceId, rootSessionId) + await this.deps.persistence!.persist( + instanceId, + rootSessionId, + enabled, + this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), + ) + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { + return this.store.isEnabled(instanceId, rootSessionId) + } + let persistedRootSessionId = rootSessionId + let currentRootSessionId = this.store.familyRoot(instanceId, sessionId) + while (currentRootSessionId !== persistedRootSessionId) { + traversedRootSessionIds.add(currentRootSessionId) + await this.deps.persistence!.persist( + instanceId, + currentRootSessionId, + enabled, + this.sessionWorkspaces.get(instanceId)?.get(currentRootSessionId), + ) + if (enabled) { + await this.deps.persistence!.persist( + instanceId, + persistedRootSessionId, + false, + this.sessionWorkspaces.get(instanceId)?.get(persistedRootSessionId), + ) + } + persistedRootSessionId = currentRootSessionId + currentRootSessionId = this.store.familyRoot(instanceId, sessionId) + } + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { + return this.store.isEnabled(instanceId, currentRootSessionId) + } + if (enabled) { + this.store.setEnabled(instanceId, currentRootSessionId, true) + } else { + for (const traversedRootSessionId of traversedRootSessionIds) { + this.store.setEnabled(instanceId, traversedRootSessionId, false) + } + } + this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: currentRootSessionId, enabled }) + if (enabled) this.drainPending(instanceId, currentRootSessionId) + return enabled + }) + const settled = mutation.finally(() => { + if (this.mutations.get(instanceId) === settled) this.mutations.delete(instanceId) + }) + this.mutations.set(instanceId, settled) + return settled + } + + clearInstance(instanceId: string): void { + this.instanceGeneration.set(instanceId, (this.instanceGeneration.get(instanceId) ?? 0) + 1) + this.hydratedInstances.delete(instanceId) + this.hydration.delete(instanceId) + this.queuedEvents.delete(instanceId) + this.sessionWorkspaces.delete(instanceId) + this.mutations.delete(instanceId) + this.store.clearInstance(instanceId) + this.pending.delete(instanceId) + const prefix = `${instanceId}:` + for (const key of Array.from(this.inFlight.keys())) { + if (key.startsWith(prefix)) this.inFlight.delete(key) + } + for (const key of Array.from(this.replyAttempts.keys())) { + if (key.startsWith(prefix)) this.replyAttempts.delete(key) + } + } + + handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void { + if (!event || typeof event.type !== "string") return + + if (SESSION_UPSERT_TYPES.has(event.type)) { + this.ingestSession(instanceId, event.properties) + return + } + if (SESSION_REMOVE_TYPES.has(event.type)) { + const info = (event.properties as { info?: SessionProperties } | undefined)?.info + const id = readString(info?.id) ?? readString(event.properties?.id) + if (id) { + this.store.removeSession(instanceId, id) + this.removePendingForSession(instanceId, id) + } + return + } + if (PERMISSION_REPLIED_TYPES.has(event.type)) { + this.handlePermissionReplied(instanceId, event.properties) + return + } + if (PERMISSION_ASK_TYPES.has(event.type)) { + this.handlePermissionRequest(instanceId, event.type, event.properties) + } + } + + private ingestSession(instanceId: string, properties: unknown): void { + // OpenCode wraps session records under `properties.info` for + // session.created/updated/deleted (see SDK EventSessionUpdated). Accept a + // flat fallback only for defensive compatibility. + const info = (properties as { info?: SessionProperties } | SessionProperties | undefined) + const session = (info && typeof info === "object" && "info" in info ? info.info : info) as + | SessionProperties + | undefined + if (!session || typeof session.id !== "string") return + const parentId = session.parentID ?? session.parentId ?? null + const revert = session.revert ?? undefined + const enabledBefore = this.store.enabledRoots(instanceId) + this.store.upsertSession(instanceId, { id: session.id, parentId, revert }) + if (typeof session.workspaceID === "string" && session.workspaceID) { + const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map() + workspaces.set(session.id, session.workspaceID) + this.sessionWorkspaces.set(instanceId, workspaces) + } + this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId)) + // Session ancestry may have changed (parent discovered, revert toggled). + // Re-drain pending permissions whose family root may have migrated into + // an enabled family — mirrors the old UI's drainAutoAcceptPermissions- + // ForInstance trigger on session.updated (#497). + this.drainPending(instanceId, session.id) + } + + private persistRootMigration(instanceId: string, before: readonly string[], after: readonly string[]): void { + if (!this.deps.persistence) return + const removed = before.filter((id) => !after.includes(id)) + const added = after.filter((id) => !before.includes(id)) + if (removed.length === 0 && added.length === 0) return + const generation = this.instanceGeneration.get(instanceId) ?? 0 + const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => { + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return false + const enabledRoots = new Set(this.store.enabledRoots(instanceId)) + for (const rootSessionId of added) { + if (!enabledRoots.has(rootSessionId)) continue + await this.deps.persistence!.persist( + instanceId, rootSessionId, true, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), + ) + } + for (const rootSessionId of removed) { + if (enabledRoots.has(rootSessionId)) continue + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return false + await this.deps.persistence!.persist( + instanceId, rootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), + ) + } + return false + }) + const settled = mutation.finally(() => { + if (this.mutations.get(instanceId) === settled) this.mutations.delete(instanceId) + }) + this.mutations.set(instanceId, settled) + void settled.catch((error) => { + this.deps.logger.warn({ instanceId, err: error }, "Failed to migrate persisted Yolo family root") + }) + } + + private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void { + const request = permission as PermissionProperties | undefined + if (!request) return + const permissionId = readString(request.id) + const sessionId = readString(request.sessionID) ?? readString(request.sessionId) + if (!permissionId || !sessionId) return + + // Infer source from the event type, but prefer the already-tracked source + // for permission.updated (which may belong to a v2 permission). + const existing = this.pending.get(instanceId)?.get(permissionId) + const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy") + + // `permission.updated` represents a detail change for a permission that + // is *already* pending. If it is no longer in our pending set it was + // already replied to (by us or the user) — skip to avoid a duplicate reply. + if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) { + return + } + + this.addPending(instanceId, { permissionId, sessionId, source }) + + if (!this.store.isEnabled(instanceId, sessionId)) return + this.tryAutoAccept(instanceId, permissionId, sessionId, source) + } + + private handlePermissionReplied(instanceId: string, properties: unknown): void { + const request = (properties as PermissionRepliedProperties | undefined) ?? {} + const permissionId = + readString(request.id) ?? + readString(request.requestID) ?? + readString(request.permissionID) ?? + readString(request.requestId) ?? + readString(request.permissionId) + if (permissionId) this.removePending(instanceId, permissionId) + } + + private tryAutoAccept( + instanceId: string, + permissionId: string, + sessionId: string, + source: PermissionSource, + ): void { + const key = `${instanceId}:${permissionId}` + if (this.inFlight.has(key)) return + const attempts = this.replyAttempts.get(key) ?? 0 + if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return + this.inFlight.add(key) + this.replyAttempts.set(key, attempts + 1) + + const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" } + + void this.deps.replier(reply) + .then(() => { + this.replyAttempts.delete(key) + this.removePending(instanceId, permissionId) + this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId }) + }) + .catch((error) => { + this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed") + if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) { + this.removePending(instanceId, permissionId) + } + }) + .finally(() => { + this.inFlight.delete(key) + }) + } + + /** Auto-accept all pending permissions belonging to the same family root. */ + private drainPending(instanceId: string, sessionId: string): void { + const instancePending = this.pending.get(instanceId) + if (!instancePending || instancePending.size === 0) return + const root = this.store.familyRoot(instanceId, sessionId) + for (const entry of Array.from(instancePending.values())) { + if (this.store.familyRoot(instanceId, entry.sessionId) === root) { + this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source) + } + } + } + + private addPending(instanceId: string, entry: PendingPermission): void { + let instancePending = this.pending.get(instanceId) + if (!instancePending) { + instancePending = new Map() + this.pending.set(instanceId, instancePending) + } + instancePending.set(entry.permissionId, entry) + } + + private removePending(instanceId: string, permissionId: string): void { + const instancePending = this.pending.get(instanceId) + if (instancePending?.delete(permissionId)) { + this.replyAttempts.delete(`${instanceId}:${permissionId}`) + if (instancePending.size === 0) this.pending.delete(instanceId) + } + } + + private removePendingForSession(instanceId: string, sessionId: string): void { + const instancePending = this.pending.get(instanceId) + if (!instancePending) return + for (const [permId, entry] of Array.from(instancePending)) { + if (entry.sessionId === sessionId) { + instancePending.delete(permId) + this.replyAttempts.delete(`${instanceId}:${permId}`) + } + } + if (instancePending.size === 0) this.pending.delete(instanceId) + } +} + +interface InstanceStreamPayload { + type?: string + properties?: Record +} + +interface SessionProperties { + id?: string + parentID?: string | null + parentId?: string | null + revert?: unknown + workspaceID?: string +} + +interface PermissionProperties { + id?: string + sessionID?: string + sessionId?: string +} + +interface PermissionRepliedProperties { + id?: string + requestID?: string + permissionID?: string + requestId?: string + permissionId?: string +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts new file mode 100644 index 00000000..5ccfd540 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store" + +describe("resolveFamilyRoot", () => { + it("returns the session id itself when no info is known", () => { + assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan") + }) + + it("keeps a loaded child as root when its parent is missing", () => { + const root = resolveFamilyRoot("child", (id) => + id === "child" ? { id: "child", parentId: "parent" } : undefined, + ) + assert.equal(root, "child") + }) + + it("resolves to the master session when the full parent chain is loaded", () => { + const root = resolveFamilyRoot("grandchild", (id) => { + if (id === "grandchild") return { id: "grandchild", parentId: "child" } + if (id === "child") return { id: "child", parentId: "master" } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "master") + }) + + it("keeps a fork session (with revert) as its own root", () => { + const root = resolveFamilyRoot("fork", (id) => { + if (id === "fork") + return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "fork") + }) + + it("terminates on cyclic parent chains without looping forever", () => { + const root = resolveFamilyRoot("a", (id) => { + if (id === "a") return { id: "a", parentId: "b" } + if (id === "b") return { id: "b", parentId: "a" } + return undefined + }) + // cycle: last known id before re-entering the cycle is returned + assert.ok(root === "a" || root === "b") + }) +}) + +describe("AutoAcceptStore inheritance", () => { + it("is disabled by default for an unknown session", () => { + const store = new AutoAcceptStore() + assert.equal(store.isEnabled("inst", "s1"), false) + }) + + it("enabling a parent enables every descendant that resolves to it", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.upsertSession("inst", { id: "grandchild", parentId: "child" }) + + store.setEnabled("inst", "master", true) + + assert.equal(store.isEnabled("inst", "master"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.isEnabled("inst", "grandchild"), true) + }) + + it("enabling a child also covers the parent family root and siblings", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child-a", parentId: "master" }) + store.upsertSession("inst", { id: "child-b", parentId: "master" }) + + store.setEnabled("inst", "child-a", true) + + assert.equal(store.isEnabled("inst", "child-a"), true) + assert.equal(store.isEnabled("inst", "child-b"), true) + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("a fork session is isolated: enabling it does not enable its parent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { + id: "fork", + parentId: "master", + revert: { messageID: "msg", partID: "part" }, + }) + + store.setEnabled("inst", "fork", true) + + assert.equal(store.isEnabled("inst", "fork"), true) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("disabling the family root clears the setting for all descendants", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + store.setEnabled("inst", "child", true) + assert.equal(store.isEnabled("inst", "child"), true) + + store.setEnabled("inst", "master", false) + assert.equal(store.isEnabled("inst", "child"), false) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("toggle flips the resolved family-root state and reports the new value", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + assert.equal(store.toggle("inst", "child"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.toggle("inst", "master"), false) + assert.equal(store.isEnabled("inst", "child"), false) + }) + + it("keeps per-instance state independent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst-a", { id: "root", parentId: null }) + store.upsertSession("inst-b", { id: "root", parentId: null }) + + store.setEnabled("inst-a", "root", true) + assert.equal(store.isEnabled("inst-a", "root"), true) + assert.equal(store.isEnabled("inst-b", "root"), false) + }) +}) + +describe("AutoAcceptStore session tree maintenance", () => { + it("migrates enabled root when a parent is discovered later", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "child", parentId: "parent" }) + // parent unknown -> child is its own root + store.setEnabled("inst", "child", true) + + // later the parent shows up — root should migrate from "child" to "parent" + store.upsertSession("inst", { id: "parent", parentId: null }) + assert.equal(store.isEnabled("inst", "parent"), true) + assert.equal(store.isEnabled("inst", "child"), true) + }) + + it("removing a session does not clear an enabled family root", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.removeSession("inst", "master") + // the toggle is independent of the session tree: it survives session deletion + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("clearInstance drops both tree and enabled state", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.clearInstance("inst") + assert.equal(store.isEnabled("inst", "master"), false) + store.upsertSession("inst", { id: "master", parentId: null }) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("changing revert status re-roots a session as a fork", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.setEnabled("inst", "child", true) + // parent family enabled + assert.equal(store.isEnabled("inst", "master"), true) + + // child becomes a fork + store.upsertSession("inst", { + id: "child", + parentId: "master", + revert: { messageID: "m", partID: "p" }, + }) + // now child resolves to itself; the family setting was on "master" so still on for master + assert.equal(store.isEnabled("inst", "master"), true) + // child is its own root now, not enabled unless toggled + assert.equal(store.isEnabled("inst", "child"), false) + }) +}) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts new file mode 100644 index 00000000..b53e6258 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -0,0 +1,132 @@ +/** + * In-memory permission auto-accept (Yolo) state, owned by the server. + * + * This is a faithful port of the previous frontend implementation + * (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance + * semantics are preserved exactly: + * - state is keyed by the resolved *family root* session id + * - a session with a `revert` snapshot is treated as its own root (fork) + * - enabling any session enables its whole family root and vice-versa + * + * This store remains in-memory; AutoAcceptManager hydrates and persists it + * through OpenCode session metadata. + */ + +export interface AutoAcceptSessionInfo { + id: string + parentId?: string | null + /** Truthy value marks the session as a fork that roots at itself. */ + revert?: unknown +} + +type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined + +/** + * Resolve the family-root session id for `sessionId` by walking the parent + * chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so + * inheritance behaviour does not change. + */ +export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): string { + let currentId = sessionId + let lastKnownId = sessionId + const seen = new Set() + + while (currentId && !seen.has(currentId)) { + seen.add(currentId) + const session = getSession(currentId) + if (!session) return lastKnownId + lastKnownId = session.id + if (session.revert) return session.id + if (!session.parentId) return session.id + currentId = session.parentId + } + + return currentId || sessionId +} + +export class AutoAcceptStore { + /** instanceId -> set of enabled family-root session ids */ + private readonly enabled = new Map>() + /** instanceId -> (sessionId -> info) */ + private readonly sessions = new Map>() + + isEnabled(instanceId: string, sessionId: string): boolean { + const root = this.familyRoot(instanceId, sessionId) + return this.enabled.get(instanceId)?.has(root) ?? false + } + + setEnabled(instanceId: string, sessionId: string, enabled: boolean): void { + const root = this.familyRoot(instanceId, sessionId) + let roots = this.enabled.get(instanceId) + if (!roots) { + if (!enabled) return + roots = new Set() + this.enabled.set(instanceId, roots) + } + if (enabled) { + roots.add(root) + } else { + roots.delete(root) + if (roots.size === 0) { + this.enabled.delete(instanceId) + } + } + } + + toggle(instanceId: string, sessionId: string): boolean { + const next = !this.isEnabled(instanceId, sessionId) + this.setEnabled(instanceId, sessionId, next) + return next + } + + upsertSession(instanceId: string, info: AutoAcceptSessionInfo): void { + let tree = this.sessions.get(instanceId) + if (!tree) { + tree = new Map() + this.sessions.set(instanceId, tree) + } + tree.set(info.id, { + id: info.id, + parentId: info.parentId ?? null, + revert: info.revert, + }) + this.migrateEnabledRoots(instanceId) + } + + removeSession(instanceId: string, sessionId: string): void { + this.sessions.get(instanceId)?.delete(sessionId) + } + + clearInstance(instanceId: string): void { + this.sessions.delete(instanceId) + this.enabled.delete(instanceId) + } + + /** Resolves the family-root session id for the given session. */ + familyRoot(instanceId: string, sessionId: string): string { + const tree = this.sessions.get(instanceId) + return resolveFamilyRoot(sessionId, (id) => tree?.get(id)) + } + + enabledRoots(instanceId: string): string[] { + return [...(this.enabled.get(instanceId) ?? [])] + } + + /** + * Re-resolves every enabled family root for an instance after the session + * tree changes (new session, updated parent/revert). If a root now resolves + * to a different id, the enabled entry is migrated so toggles survive late + * ancestry discovery. + */ + private migrateEnabledRoots(instanceId: string): void { + const roots = this.enabled.get(instanceId) + if (!roots || roots.size === 0) return + for (const oldRoot of Array.from(roots)) { + const newRoot = this.familyRoot(instanceId, oldRoot) + if (newRoot !== oldRoot) { + roots.delete(oldRoot) + roots.add(newRoot) + } + } + } +} diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts new file mode 100644 index 00000000..2ed9bfc9 --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.ts @@ -0,0 +1,47 @@ +import type { WorkspaceManager } from "../workspaces/manager" +import type { Logger } from "../logger" +import { createInstanceClient } from "../workspaces/instance-client" +import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" + +interface OpencodeReplierDeps { + workspaceManager: WorkspaceManager + logger: Logger +} + +/** + * Default {@link PermissionReplier} that calls the OpenCode instance via the + * generated SDK client over loopback, using the same `"once"` reply the UI + * previously sent. + * + * Uses `createInstanceClient` so routes and body shapes are always correct + * for the installed SDK version — no hand-assembled URLs. + */ +export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { + return async (reply: AutoAcceptReply) => { + const client = createInstanceClient(deps.workspaceManager, reply.instanceId) + if (!client) { + throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) + } + + const opts = { throwOnError: true } as const + + if (reply.source === "v2") { + await client.v2.session.permission.reply( + { + sessionID: reply.sessionId, + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } else { + await client.permission.reply( + { + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } + } +} diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts new file mode 100644 index 00000000..d31d5652 --- /dev/null +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { createOpencodeYoloPersistence, hasPersistedYolo, mergePersistedYolo } from "./opencode-yolo-metadata" + +describe("OpenCode Yolo metadata", () => { + it("preserves unrelated metadata while replacing Yolo state", () => { + assert.deepEqual( + mergePersistedYolo({ thirdParty: { keep: true }, codenomad: { version: 1, worktreeSlug: "feature" } }, "root", true), + { + thirdParty: { keep: true }, + codenomad: { version: 1, worktreeSlug: "feature", yolo: { enabled: true, rootSessionId: "root" } }, + }, + ) + }) + + it("accepts only a marker owned by its session", () => { + const metadata = mergePersistedYolo({}, "root", true) + assert.equal(hasPersistedYolo("root", metadata), true) + assert.equal(hasPersistedYolo("fork", metadata), false) + assert.equal(hasPersistedYolo("root", mergePersistedYolo({}, "root", false)), false) + }) + + it("uses the session workspace for metadata updates", async () => { + const calls: Array> = [] + const client = { + session: { + async list() { return { data: [{ id: "root", parentID: null, workspaceID: "workspace", metadata: {} }] } }, + async get(parameters: Record) { calls.push(parameters); return { data: { metadata: {} } } }, + async update(parameters: Record) { calls.push(parameters); return { data: {} } }, + }, + } + const persistence = createOpencodeYoloPersistence({} as never, () => client as never) + const [session] = await persistence.loadSessions("instance") + await persistence.persist("instance", "root", true, session?.workspaceId) + assert.equal(session?.workspaceId, "workspace") + assert.equal(calls[0]?.workspace, "workspace") + assert.equal(calls[1]?.workspace, "workspace") + }) + + it("serializes Yolo and worktree metadata writes across instances", async () => { + let metadata: Record = { thirdParty: true } + const client = { + session: { + async get() { return { data: { metadata } } }, + async update(parameters: Record) { + metadata = parameters.metadata as Record + return { data: { metadata } } + }, + }, + } + const persistence = createOpencodeYoloPersistence({} as never, () => client as never) + await Promise.all([ + persistence.persist("instance-a", "root", true), + persistence.setWorktreeSlug("instance-b", "root", "feature"), + ]) + assert.deepEqual(metadata, { + thirdParty: true, + codenomad: { version: 1, yolo: { enabled: true, rootSessionId: "root" }, worktreeSlug: "feature" }, + }) + }) +}) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts new file mode 100644 index 00000000..bca92901 --- /dev/null +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -0,0 +1,111 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { WorkspaceManager } from "../workspaces/manager" +import { createInstanceClient } from "../workspaces/instance-client" +import type { AutoAcceptPersistence, PersistedAutoAcceptSession } from "./auto-accept-manager" + +const CODENOMAD_METADATA_VERSION = 1 +const SESSION_LIST_LIMIT = 10_000 + +type Metadata = Record + +export interface OpencodeYoloPersistence extends AutoAcceptPersistence { + hasProjectSession(instanceId: string, sessionId: string): Promise + setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string): Promise +} + +function record(value: unknown): Metadata { + return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Metadata) } : {} +} + +export function hasPersistedYolo(sessionId: string, metadata: unknown): boolean { + const codenomad = record(record(metadata).codenomad) + const yolo = record(codenomad.yolo) + return codenomad.version === CODENOMAD_METADATA_VERSION + && yolo.enabled === true + && yolo.rootSessionId === sessionId +} + +export function mergePersistedYolo(metadata: unknown, rootSessionId: string, enabled: boolean): Metadata { + const current = record(metadata) + const codenomad = record(current.codenomad) + return { + ...current, + codenomad: { + ...codenomad, + version: CODENOMAD_METADATA_VERSION, + yolo: { enabled, rootSessionId }, + }, + } +} + +export function mergePersistedWorktreeSlug(metadata: unknown, worktreeSlug: string): Metadata { + const current = record(metadata) + const codenomad = record(current.codenomad) + return { + ...current, + codenomad: { ...codenomad, version: CODENOMAD_METADATA_VERSION, worktreeSlug }, + } +} + +export function createOpencodeYoloPersistence( + workspaceManager: WorkspaceManager, + createClient: (manager: WorkspaceManager, instanceId: string) => OpencodeClient | null = createInstanceClient, +): OpencodeYoloPersistence { + const writes = new Map>() + const clientFor = (instanceId: string) => { + const client = createClient(workspaceManager, instanceId) + if (!client) throw new Error(`Yolo: instance ${instanceId} has no open port`) + return client + } + const updateMetadata = ( + instanceId: string, + sessionId: string, + workspaceId: string | undefined, + update: (metadata: unknown) => Metadata, + ): Promise => { + const writeKey = sessionId + const write = (writes.get(writeKey) ?? Promise.resolve()).catch(() => undefined).then(async () => { + const client = clientFor(instanceId) + const scope = { sessionID: sessionId, ...(workspaceId ? { workspace: workspaceId } : {}) } + const { data: session } = await client.session.get(scope, { throwOnError: true }) + const metadata = update(session.metadata) + const { data } = await client.session.update({ ...scope, metadata }, { throwOnError: true }) + return record(data?.metadata ?? metadata) + }) + const settled = write.finally(() => { + if (writes.get(writeKey) === settled) writes.delete(writeKey) + }) + writes.set(writeKey, settled) + return settled + } + return { + async loadSessions(instanceId): Promise { + const { data } = await clientFor(instanceId).session.list( + { scope: "project", limit: SESSION_LIST_LIMIT }, + { throwOnError: true }, + ) + return (data ?? []).map((session) => ({ + id: session.id, + parentId: session.parentID ?? null, + revert: session.revert, + workspaceId: session.workspaceID, + yoloEnabled: hasPersistedYolo(session.id, session.metadata), + })) + }, + persist(instanceId, rootSessionId, enabled, workspaceId): Promise { + return updateMetadata(instanceId, rootSessionId, workspaceId, + (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined) + }, + async hasProjectSession(instanceId, sessionId): Promise { + const { data } = await clientFor(instanceId).session.list( + { scope: "project", limit: SESSION_LIST_LIMIT }, + { throwOnError: true }, + ) + return (data ?? []).some((session) => session.id === sessionId) + }, + setWorktreeSlug(instanceId, sessionId, worktreeSlug): Promise { + return updateMetadata(instanceId, sessionId, undefined, + (metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug)) + }, + } +} diff --git a/packages/server/src/server/__tests__/remote-proxy.test.ts b/packages/server/src/server/__tests__/remote-proxy.test.ts index 5daac01a..f4e5053d 100644 --- a/packages/server/src/server/__tests__/remote-proxy.test.ts +++ b/packages/server/src/server/__tests__/remote-proxy.test.ts @@ -13,32 +13,20 @@ import { RemoteProxySessionManager } from "../remote-proxy" import { resolveHttpsOptions } from "../tls" const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-")) -const sharedTls = resolveHttpsOptions({ - enabled: true, - configDir: sharedTempDir, - host: "127.0.0.1", - logger: createStubLogger(), -}) - -if (!sharedTls) { - throw new Error("Failed to generate HTTPS options for remote proxy tests") -} - +const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() }) +if (!sharedTls) throw new Error("Failed to generate HTTPS options for remote proxy tests") const sharedHttpsOptions = sharedTls.httpsOptions - const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) const managers = new Set() afterEach(async () => { - for (const manager of managers) { - await disposeManager(manager) - } + for (const manager of managers) await manager.shutdown().catch(() => undefined) managers.clear() }) -after(() => { +after(async () => { fs.rmSync(sharedTempDir, { recursive: true, force: true }) - httpsDispatcher.close().catch(() => {}) + await httpsDispatcher.destroy().catch(() => {}) }) describe("RemoteProxySessionManager", () => { @@ -47,10 +35,8 @@ describe("RemoteProxySessionManager", () => { const manager = createSessionManager() const session1 = await createSession(manager, `${upstreamBaseUrl}/base`) const session2 = await createSession(manager, `${upstreamBaseUrl}/base`) - const blocked = await proxyFetch(`${session1.proxyOrigin}/status`) assert.equal(blocked.status, 403) - const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, { method: "POST", headers: { "content-type": "application/json" }, @@ -70,9 +56,7 @@ describe("RemoteProxySessionManager", () => { await withUpstreamServer(async (upstreamBaseUrl) => { const manager = createSessionManager() const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`) assert.equal(apiResponse.status, 200) assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar") @@ -84,10 +68,8 @@ describe("RemoteProxySessionManager", () => { const requestUrl = req.url ?? "" if (requestUrl === "/base/redirect") { res.writeHead(302, { location: "/base/after?ok=1" }) - res.end() - return + return res.end() } - res.writeHead(200, { "content-type": "text/plain" }) res.end(requestUrl) }) @@ -97,21 +79,17 @@ describe("RemoteProxySessionManager", () => { await withUpstreamServer(async (upstreamBaseUrl) => { const manager = createSessionManager() const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`) assert.equal(loginResponse.status, 200) const setCookie = getSetCookie(loginResponse)[0] assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i) assert.doesNotMatch(setCookie, /domain=/i) - const cookieHeader = setCookie.split(";", 1)[0] const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, { headers: { cookie: cookieHeader }, }) - assert.equal(await whoamiResponse.text(), "session=abc123") }, (req, res) => { const requestUrl = req.url ?? "" @@ -120,16 +98,12 @@ describe("RemoteProxySessionManager", () => { "content-type": "text/plain", "set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1", }) - res.end("ok") - return + return res.end("ok") } - if (requestUrl === "/base/whoami") { res.writeHead(200, { "content-type": "text/plain" }) - res.end(req.headers.cookie ?? "") - return + return res.end(req.headers.cookie ?? "") } - res.writeHead(404, { "content-type": "text/plain" }) res.end(requestUrl) }) @@ -139,17 +113,13 @@ describe("RemoteProxySessionManager", () => { await withUpstreamServer(async (upstreamBaseUrl) => { const manager = createSessionManager() const session = await createSession(manager, `${upstreamBaseUrl}/base`) - assert.equal(await manager.deleteSession(session.sessionId), true) assert.equal(await manager.deleteSession(session.sessionId), false) - const session3 = await createSession(manager, `${upstreamBaseUrl}/base`) const internalSessions = (manager as any).sessions as Map const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise - internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000 await internalCleanup.call(manager) - assert.equal(internalSessions.has(session3.sessionId), false) assert.equal(await manager.deleteSession(session3.sessionId), false) }, (_req, res) => { @@ -157,20 +127,153 @@ describe("RemoteProxySessionManager", () => { res.end("ok") }) }) + + it("closes every session listener during shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const first = await createSession(manager, `${upstreamBaseUrl}/base`) + await createSession(manager, `${upstreamBaseUrl}/other`) + await manager.shutdown() + assert.equal((manager as any).sessions.size, 0) + await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`)) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("waits for in-flight idle cleanup during shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager({ disposalTimeoutMs: 1_000 }) + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + const internalSession = (manager as any).sessions.get(session.sessionId) + const closeGate = deferred() + const originalClose = internalSession.app.close.bind(internalSession.app) + internalSession.app.close = async () => { + await closeGate.promise + return originalClose() + } + internalSession.lastAccessAt = Date.now() - 31 * 60_000 + const cleanup = (manager as any).cleanupExpiredSessions() as Promise + let shutdownSettled = false + const shutdown = manager.shutdown().then(() => { + shutdownSettled = true + }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(shutdownSettled, false) + closeGate.resolve() + await cleanup + await shutdown + assert.equal((manager as any).disposals.size, 0) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("aborts a stalled event stream during bounded shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager({ disposalTimeoutMs: 100 }) + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + await activateSession(session) + const response = await proxyFetch(`${session.proxyOrigin}/events`) + assert.equal(response.status, 200) + await Promise.race([ + manager.shutdown(), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)), + ]) + assert.equal((manager as any).sessions.size, 0) + assert.equal((manager as any).disposals.size, 0) + }, (req, res) => { + if (req.url === "/base/events") { + res.writeHead(200, { "content-type": "text/event-stream" }) + return void res.write("data: connected\n\n") + } + res.writeHead(200).end("ok") + }) + }) + + it("surfaces listener disposal failures", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + const internalSession = (manager as any).sessions.get(session.sessionId) + const originalClose = internalSession.app.close.bind(internalSession.app) + let failClose = true + internalSession.app.close = async () => { + await originalClose() + if (failClose) { failClose = false; throw new Error("close failed") } + } + await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/) + // A completed deletion failure predating shutdown must not poison it. + await manager.shutdown() + assert.equal((manager as any).sessions.size, 0) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("waits for in-flight creation and rejects sessions that cross shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const creation = manager.createSession(`${upstreamBaseUrl}/base`, false) + const shutdown = manager.shutdown() + await assert.rejects(creation, /shutting down/) + await shutdown + assert.equal((manager as any).creations.size, 0) + assert.equal((manager as any).sessions.size, 0) + await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false) + const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true) + const sessions = (manager as any).sessions as Map + assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent) + assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent) + assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher) + + const closeGate = deferred() + const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app) + let failClose = true + sessions.get(verified.sessionId).app.close = async () => { + await closeGate.promise + await originalClose() + if (failClose) { failClose = false; throw new Error("current close failed") } + } + const disposal = manager.deleteSession(verified.sessionId); const first = manager.shutdown() + const concurrent = manager.shutdown() + assert.strictEqual(first, concurrent) + closeGate.resolve() + await assert.rejects(disposal, /Remote proxy disposal failed/) + await assert.rejects(first, (error: unknown) => error instanceof AggregateError && error.errors.some((cause) => + cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested))))) + await manager.shutdown() + assert.equal(sessions.size, 0) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) }) -function createSessionManager() { +function createSessionManager(options: { disposalTimeoutMs?: number } = {}) { const manager = new RemoteProxySessionManager({ - authManager: { - isLoopbackRequest: () => true, - } as unknown as AuthManager, - logger: createStubLogger(), - httpsOptions: sharedHttpsOptions, + authManager: { isLoopbackRequest: () => true } as unknown as AuthManager, + logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options, }) managers.add(manager) return manager } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } +} + async function createSession(manager: RemoteProxySessionManager, baseUrl: string) { const created = await manager.createSession(baseUrl, false) const windowUrl = new URL(created.windowUrl) @@ -188,18 +291,14 @@ async function activateSession(session: { proxyOrigin: string; token: string }) headers: { "content-type": "application/json" }, body: JSON.stringify({ token: session.token }), }) - if (!response.ok) { - return false - } + if (!response.ok) return false const body = (await response.json()) as { ok?: boolean } return body.ok === true } function getSetCookie(response: Awaited>): string[] { const values = (response.headers as any).getSetCookie?.() as string[] | undefined - if (Array.isArray(values) && values.length > 0) { - return values - } + if (Array.isArray(values) && values.length > 0) return values const fallback = response.headers.get("set-cookie") return fallback ? [fallback] : [] } @@ -208,26 +307,15 @@ async function proxyFetch(url: string, init?: Parameters[1]) { return fetch(url, { dispatcher: httpsDispatcher, ...init }) } -async function disposeManager(manager: RemoteProxySessionManager) { - const sessions = Array.from(((manager as any).sessions as Map).keys()) - for (const sessionId of sessions) { - await manager.deleteSession(sessionId) - } - clearInterval((manager as any).cleanupTimer as NodeJS.Timeout) -} - async function withUpstreamServer( callback: (baseUrl: string) => Promise, handler: (req: IncomingMessage, res: ServerResponse) => void, ) { const server = http.createServer(handler) await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) - try { const address = server.address() - if (!address || typeof address === "string") { - throw new Error("Failed to resolve upstream server address") - } + if (!address || typeof address === "string") throw new Error("Failed to resolve upstream server address") await callback(`http://127.0.0.1:${address.port}`) } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) @@ -235,14 +323,6 @@ async function withUpstreamServer( } function createStubLogger(): Logger { - const logger = { - info() {}, - warn() {}, - error() {}, - child() { - return logger - }, - } - + const logger = { info() {}, warn() {}, error() {}, child() { return logger } } return logger as unknown as Logger } diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 5dd85468..8df2832b 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -22,15 +22,20 @@ import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" +import { registerYoloRoutes } from "./routes/yolo" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" +import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update" import { registerRemoteServerRoutes } from "./routes/remote-servers" import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerSideCarRoutes } from "./routes/sidecars" import { registerPreviewRoutes } from "./routes/previews" +import { registerUsageRoutes } from "./routes/usage" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" +import type { AutoAcceptManager } from "../permissions/auto-accept-manager" +import type { OpencodeYoloPersistence } from "../permissions/opencode-yolo-metadata" import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" @@ -41,6 +46,7 @@ import { VoiceModeManager } from "../plugins/voice-mode" import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" import type { RemoteProxySessionManager } from "./remote-proxy" +import { createOpenCodeUpdateService } from "../opencode-update/service" interface HttpServerDeps { bindHost: string @@ -63,6 +69,8 @@ interface HttpServerDeps { pluginChannel: PluginChannelManager voiceModeManager: VoiceModeManager remoteProxySessionManager: RemoteProxySessionManager + yoloManager: AutoAcceptManager + sessionMetadataPersistence: OpencodeYoloPersistence uiStaticDir: string uiDevServerUrl?: string logger: Logger @@ -269,6 +277,10 @@ export function createHttpServer(deps: HttpServerDeps) { registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) + registerOpenCodeUpdateRoutes(app, { + service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager), + logger: apiLogger, + }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerConfigFileRoutes(app) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) @@ -278,7 +290,10 @@ export function createHttpServer(deps: HttpServerDeps) { logger: sseLogger, connectionManager: deps.clientConnectionManager, }) - registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) + registerWorktreeRoutes(app, { + workspaceManager: deps.workspaceManager, + sessionMetadataPersistence: deps.sessionMetadataPersistence, + }) registerStorageRoutes(app, { instanceStore: deps.instanceStore, eventBus: deps.eventBus, @@ -289,6 +304,7 @@ export function createHttpServer(deps: HttpServerDeps) { registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) registerPreviewRoutes(app, { previewManager: deps.previewManager }) + registerUsageRoutes(app) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger }) setupSideCarWebSocketProxy(app, { @@ -309,6 +325,7 @@ export function createHttpServer(deps: HttpServerDeps) { voiceModeManager: deps.voiceModeManager, }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) + registerYoloRoutes(app, { yoloManager: deps.yoloManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/remote-proxy.ts b/packages/server/src/server/remote-proxy.ts index 93e6ab5e..a4c47aac 100644 --- a/packages/server/src/server/remote-proxy.ts +++ b/packages/server/src/server/remote-proxy.ts @@ -10,20 +10,18 @@ const LOOPBACK_HOST = "127.0.0.1" const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token" const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token" const SESSION_IDLE_TTL_MS = 30 * 60_000 +const SESSION_DISPOSAL_TIMEOUT_MS = 5_000 interface RemoteProxySession { id: string bootstrapToken: string targetBaseUrl: URL - skipTlsVerify: boolean localBaseUrl: URL - entryUrl: URL - bootstrapUrl: string activated: boolean cookiePrefix: string app: FastifyInstance dispatcher?: Agent - createdAt: number + abortController: AbortController lastAccessAt: number } @@ -31,6 +29,7 @@ export interface RemoteProxySessionManagerOptions { authManager: AuthManager logger: Logger httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } + disposalTimeoutMs?: number } export interface RemoteProxySessionCreateResult { @@ -40,16 +39,26 @@ export interface RemoteProxySessionCreateResult { export class RemoteProxySessionManager { private readonly sessions = new Map() + private readonly creations = new Set>() + private readonly disposals = new Set>() + private readonly sessionDisposals = new Map>() private readonly cleanupTimer: NodeJS.Timeout + private shuttingDown = false + private shutdownPromise?: Promise constructor(private readonly options: RemoteProxySessionManagerOptions) { - this.cleanupTimer = setInterval(() => { - void this.cleanupExpiredSessions() - }, 60_000) + this.cleanupTimer = setInterval(() => void this.cleanupExpiredSessions().catch((error) => + this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session")), 60_000) this.cleanupTimer.unref() } async createSession(baseUrl: string, skipTlsVerify: boolean): Promise { + if (this.shuttingDown) throw new Error("Remote proxy session manager is shutting down") + + return this.track(this.creations, this.createSessionInternal(baseUrl, skipTlsVerify)) + } + + private async createSessionInternal(baseUrl: string, skipTlsVerify: boolean): Promise { if (!this.options.httpsOptions) { throw new Error("Local HTTPS is required for remote proxy sessions") } @@ -57,8 +66,9 @@ export class RemoteProxySessionManager { const targetBaseUrl = normalizeBaseUrl(baseUrl) const sessionId = randomUUID() const bootstrapToken = randomBytes(32).toString("base64url") - const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined - const app = Fastify({ logger: false, https: this.options.httpsOptions }) + const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {}) + const abortController = new AbortController() + const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true }) let session: RemoteProxySession | null = null app.removeAllContentTypeParsers() @@ -99,7 +109,7 @@ export class RemoteProxySessionManager { reply.send({ ok: true }) }) - app.all("/*", async (request, reply) => { + const handleProxyRequest = async (request: FastifyRequest, reply: FastifyReply) => { if (!session) { reply.code(503).send({ error: "Remote proxy session is unavailable" }) return @@ -112,58 +122,72 @@ export class RemoteProxySessionManager { session.lastAccessAt = Date.now() await proxyRequest({ request, reply, session, logger: this.options.logger }) - }) - - app.setNotFoundHandler(async (request, reply) => { - if (!session) { - reply.code(503).send({ error: "Remote proxy session is unavailable" }) - return - } - - if (!session.activated) { - reply.code(403).send({ error: "Remote proxy session is not activated" }) - return - } - - session.lastAccessAt = Date.now() - await proxyRequest({ request, reply, session, logger: this.options.logger }) - }) + } + app.all("/*", handleProxyRequest) + app.setNotFoundHandler(handleProxyRequest) const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 }) const address = new URL(addressInfo) const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`) const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl) const returnTo = buildReturnToTarget(entryUrl) + const bootstrapUrl = `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}` session = { id: sessionId, bootstrapToken, targetBaseUrl, - skipTlsVerify, localBaseUrl, - entryUrl, - bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`, activated: false, cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`, app, dispatcher, - createdAt: Date.now(), + abortController, lastAccessAt: Date.now(), } this.sessions.set(sessionId, session) + if (this.shuttingDown) { + await this.disposeSession(sessionId) + throw new Error("Remote proxy session manager is shutting down") + } this.options.logger.info( { sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() }, "Created remote proxy session", ) - return { sessionId, windowUrl: session.bootstrapUrl } + return { sessionId, windowUrl: bootstrapUrl } } async deleteSession(sessionId: string): Promise { return this.disposeSession(sessionId) } + shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise + this.shuttingDown = true + clearInterval(this.cleanupTimer) + const shutdown = this.drainShutdown() + this.shutdownPromise = shutdown + void shutdown.finally(() => { + if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined + }).catch(() => undefined) + return shutdown + } + + private async drainShutdown(): Promise { + const disposals = new Set(this.disposals) + while (this.creations.size > 0) { + await Promise.allSettled([...this.creations]) + for (const disposal of this.disposals) disposals.add(disposal) + } + const pendingResults = await Promise.allSettled(disposals) + const results = await Promise.allSettled(Array.from(this.sessions.keys(), (id) => this.disposeSession(id))) + const failures = [...pendingResults, ...results] + .flatMap((result) => result.status === "rejected" ? [result.reason] : []) + if (failures.length) throw new AggregateError(failures, "Remote proxy shutdown failed") + } + private async cleanupExpiredSessions() { const now = Date.now() for (const session of Array.from(this.sessions.values())) { @@ -174,17 +198,47 @@ export class RemoteProxySessionManager { } } - private async disposeSession(sessionId: string): Promise { + private disposeSession(sessionId: string): Promise { + const pending = this.sessionDisposals.get(sessionId) + if (pending) return pending const session = this.sessions.get(sessionId) - if (!session) { - return false - } + if (!session) return Promise.resolve(false) - this.sessions.delete(sessionId) - session.dispatcher?.close().catch(() => {}) - await session.app.close().catch(() => {}) - this.options.logger.info({ sessionId }, "Disposed remote proxy session") - return true + session.abortController.abort() + const disposal = this.trackDisposal(this.disposeResources(session.app, session.dispatcher).then(() => { + if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId) + this.options.logger.info({ sessionId }, "Disposed remote proxy session") + return true + })) + this.sessionDisposals.set(sessionId, disposal) + void disposal.finally(() => { + if (this.sessionDisposals.get(sessionId) === disposal) this.sessionDisposals.delete(sessionId) + }).catch(() => undefined) + return disposal + } + + private async disposeResources(app: FastifyInstance, dispatcher?: Agent): Promise { + app.server.closeAllConnections?.() + const results = await Promise.race([ + Promise.allSettled([app.close(), dispatcher?.destroy()]), + new Promise((_resolve, reject) => AbortSignal.timeout( + Math.max(1, this.options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS), + ).addEventListener( + "abort", () => reject(new Error("Remote proxy disposal timed out")), + )), + ]) + const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) + if (failures.length) throw new AggregateError(failures, "Remote proxy disposal failed") + } + + private track(operations: Set>, operation: Promise): Promise { + operations.add(operation) + void operation.finally(() => operations.delete(operation)).catch(() => undefined) + return operation + } + + private trackDisposal(operation: Promise): Promise { + return this.track(this.disposals, operation) } } @@ -325,6 +379,7 @@ async function proxyRequest(args: { method: request.method, headers, dispatcher: session.dispatcher, + signal: session.abortController.signal, redirect: "manual", } diff --git a/packages/server/src/server/routes/auth-pages/login.html b/packages/server/src/server/routes/auth-pages/login.html index 5dea9c70..6aab2083 100644 --- a/packages/server/src/server/routes/auth-pages/login.html +++ b/packages/server/src/server/routes/auth-pages/login.html @@ -72,10 +72,10 @@

This CodeNomad server is protected. Enter your credentials to continue.

- + - + @@ -95,7 +95,7 @@ async function submit() { hideError() - const username = $("username").value.trim() + const username = $("username").value const password = $("password").value if (!username || !password) { showError("Username and password are required.") diff --git a/packages/server/src/server/routes/auth.test.ts b/packages/server/src/server/routes/auth.test.ts new file mode 100644 index 00000000..6d444a09 --- /dev/null +++ b/packages/server/src/server/routes/auth.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import { it } from "node:test" +import Fastify from "fastify" +import type { AuthManager } from "../../auth/manager" +import { registerAuthRoutes } from "./auth" + +it("renders the escaped default username literally as the login field value", async () => { + const app = Fastify({ logger: false }) + const username = " code$&\"nomad " + const authManager = { + getSessionFromRequest: () => null, + getStatus: () => ({ username }), + } as unknown as AuthManager + registerAuthRoutes(app, { authManager }) + + const response = await app.inject({ method: "GET", url: "/login" }) + + assert.equal(response.statusCode, 200) + assert.ok(response.body.includes('value=" code$&"nomad "')) + assert.match(response.body, /const username = \$\("username"\)\.value\s*\n/) + assert.equal(response.body.match(/autocapitalize="none"/g)?.length, 2) + assert.equal(response.body.match(/autocorrect="off"/g)?.length, 2) + assert.equal(response.body.match(/spellcheck="false"/g)?.length, 2) + await app.close() +}) diff --git a/packages/server/src/server/routes/auth.ts b/packages/server/src/server/routes/auth.ts index 6bb7d3d3..a346124a 100644 --- a/packages/server/src/server/routes/auth.ts +++ b/packages/server/src/server/routes/auth.ts @@ -39,7 +39,7 @@ function getLoginHtml(defaultUsername: string): string { } const escapedUsername = escapeHtml(defaultUsername) - return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername) + return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, () => escapedUsername) } function getTokenHtml(): string { diff --git a/packages/server/src/server/routes/opencode-update.test.ts b/packages/server/src/server/routes/opencode-update.test.ts new file mode 100644 index 00000000..ef0f6004 --- /dev/null +++ b/packages/server/src/server/routes/opencode-update.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import type { Logger } from "../../logger" +import type { OpenCodeUpdateService } from "../../opencode-update/service" +import { registerOpenCodeUpdateRoutes } from "./opencode-update" + +test("does not pass request-controlled binary paths to the update service", async () => { + const calls: Array<{ method: string; args: unknown[] }> = [] + const service = { + getStatus: async (...args: unknown[]) => { + calls.push({ method: "status", args }) + return { + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateAvailable: true, + canUpgrade: true, + } + }, + upgrade: async (...args: unknown[]) => { + calls.push({ method: "upgrade", args }) + return { success: true, version: "1.1.0" } + }, + } as unknown as OpenCodeUpdateService + const app = Fastify() + registerOpenCodeUpdateRoutes(app, { + service, + logger: { warn() {} } as unknown as Logger, + }) + + await app.inject({ method: "GET", url: "/api/opencode/update?binary=untrusted.cmd" }) + await app.inject({ method: "POST", url: "/api/opencode/update", payload: { binary: "untrusted.cmd" } }) + + assert.deepEqual(calls, [ + { method: "status", args: [] }, + { method: "upgrade", args: [] }, + ]) + await app.close() +}) diff --git a/packages/server/src/server/routes/opencode-update.ts b/packages/server/src/server/routes/opencode-update.ts new file mode 100644 index 00000000..58e328d0 --- /dev/null +++ b/packages/server/src/server/routes/opencode-update.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance } from "fastify" +import type { Logger } from "../../logger" +import { OpenCodeUpdateError, type OpenCodeUpdateService } from "../../opencode-update/service" + +interface RouteDeps { + service: OpenCodeUpdateService + logger: Logger +} + +function statusCode(error: OpenCodeUpdateError): number { + if (error.code === "no_ready_instance") return 409 + if (error.code === "binary_unavailable") return 422 + return 502 +} + +function requestError(error: unknown, fallback: string): { status: number; code: string } { + if (error instanceof OpenCodeUpdateError) return { status: statusCode(error), code: error.code } + return { status: 500, code: fallback } +} + +export function registerOpenCodeUpdateRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/opencode/update", async (_request, reply) => { + try { + return await deps.service.getStatus() + } catch (error) { + deps.logger.warn({ err: error }, "Failed to check OpenCode update status") + const failure = requestError(error, "update_check_failed") + reply.code(failure.status) + return { error: failure.code } + } + }) + + app.post("/api/opencode/update", async (_request, reply) => { + try { + return await deps.service.upgrade() + } catch (error) { + deps.logger.warn({ err: error }, "Failed to update OpenCode") + const failure = requestError(error, "upgrade_failed") + reply.code(failure.status) + return { success: false, error: failure.code } + } + }) +} diff --git a/packages/server/src/server/routes/settings.test.ts b/packages/server/src/server/routes/settings.test.ts new file mode 100644 index 00000000..53d27cbf --- /dev/null +++ b/packages/server/src/server/routes/settings.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { enforceSpeechCredentialPairing } from "./settings" + +describe("enforceSpeechCredentialPairing", () => { + it("clears shared key when baseUrl differs from stored", () => { + const result = enforceSpeechCredentialPairing( + { speech: { baseUrl: "https://new.com/v1" } }, + { speech: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } }, + ) as any + assert.equal(result.speech.apiKey, null) + }) + + it("does NOT clear shared key when baseUrl matches stored (old client full patch)", () => { + const result = enforceSpeechCredentialPairing( + { speech: { baseUrl: "https://api.openai.com/v1", ttsVoice: "alloy", playbackMode: "streaming" } }, + { speech: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-stored" } }, + ) as any + assert.equal("apiKey" in result.speech, false, "must not clear key when URL unchanged") + }) + + it("clears directional key when directional baseUrl differs from stored", () => { + const result = enforceSpeechCredentialPairing( + { speech: { stt: { baseUrl: "https://new.com/v1" } } }, + { speech: { stt: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } } }, + ) as any + assert.equal(result.speech.stt.apiKey, null) + }) + + it("does NOT clear directional key when baseUrl matches stored", () => { + const result = enforceSpeechCredentialPairing( + { speech: { stt: { baseUrl: "https://same.com/v1", model: "whisper-1" } } }, + { speech: { stt: { baseUrl: "https://same.com/v1", apiKey: "sk-stored" } } }, + ) as any + assert.equal("apiKey" in result.speech.stt, false, "must not clear directional key when URL unchanged") + }) + + it("preserves apiKey when both baseUrl and apiKey are in patch", () => { + const result = enforceSpeechCredentialPairing( + { speech: { baseUrl: "https://new.com/v1", apiKey: "sk-new" } }, + { speech: { baseUrl: "https://old.com/v1", apiKey: "sk-old" } }, + ) as any + assert.equal(result.speech.apiKey, "sk-new") + }) + + it("handles both stt and tts independently against stored values", () => { + const result = enforceSpeechCredentialPairing( + { speech: { stt: { baseUrl: "https://stt-same.com/v1", apiKey: "sk-stt" }, tts: { baseUrl: "https://tts-new.com/v1" } } }, + { speech: { stt: { baseUrl: "https://stt-same.com/v1", apiKey: "sk-stt" }, tts: { baseUrl: "https://tts-old.com/v1", apiKey: "sk-tts" } } }, + ) as any + assert.equal(result.speech.stt.apiKey, "sk-stt") + assert.equal(result.speech.tts.apiKey, null) + }) + + it("passes through non-speech patches unchanged", () => { + const body = { logLevel: "INFO" } + const result = enforceSpeechCredentialPairing(body, { speech: {} }) + assert.deepEqual(result, body) + }) + + it("passes through null/undefined body", () => { + assert.equal(enforceSpeechCredentialPairing(null), null) + assert.equal(enforceSpeechCredentialPairing(undefined), undefined) + }) + + it("does not mutate the original body", () => { + const original = { speech: { baseUrl: "https://new.com/v1" } } + const originalCopy = JSON.parse(JSON.stringify(original)) + enforceSpeechCredentialPairing(original, { speech: { baseUrl: "https://old.com/v1" } }) + assert.deepEqual(original, originalCopy) + }) + + it("doc-level payload: clears key when server.speech.baseUrl changes", () => { + const docBody = { server: { speech: { baseUrl: "https://new.com/v1" } } } + const currentServer = { speech: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } } + const result = enforceSpeechCredentialPairing(docBody.server, currentServer) as any + assert.equal(result.speech.apiKey, null, "doc-level server.speech must have key cleared on URL change") + assert.equal(result.speech.baseUrl, "https://new.com/v1") + }) + + it("doc-level payload: preserves key when server.speech.baseUrl unchanged", () => { + const docBody = { server: { speech: { baseUrl: "https://same.com/v1", ttsVoice: "alloy" } } } + const currentServer = { speech: { baseUrl: "https://same.com/v1", apiKey: "sk-stored" } } + const result = enforceSpeechCredentialPairing(docBody.server, currentServer) as any + assert.equal("apiKey" in result.speech, false, "key must not be cleared when URL unchanged in doc-level patch") + }) +}) diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index 5d18e4fd..57de9545 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -19,12 +19,52 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str return { valid: result.valid, version: result.version, error: result.error } } +export function enforceSpeechCredentialPairing(body: unknown, currentSpeech?: unknown): unknown { + if (!body || typeof body !== "object") return body + const patch = { ...(body as Record) } + const speech = patch.speech + if (!speech || typeof speech !== "object") return patch + const speechPatch = { ...(speech as Record) } + const cur = (currentSpeech && typeof currentSpeech === "object") ? currentSpeech as Record : {} + const curSpeech = (cur.speech && typeof cur.speech === "object") ? cur.speech as Record : cur + + if ("baseUrl" in speechPatch && !("apiKey" in speechPatch)) { + if ((speechPatch.baseUrl ?? "") !== (curSpeech.baseUrl ?? "")) { + speechPatch.apiKey = null + } + } + for (const dir of ["stt", "tts"] as const) { + if (dir in speechPatch) { + const dirPatch = { ...(speechPatch[dir] as Record) } + if ("baseUrl" in dirPatch && !("apiKey" in dirPatch)) { + const curDir = (curSpeech[dir] && typeof curSpeech[dir] === "object") ? curSpeech[dir] as Record : {} + if ((dirPatch.baseUrl ?? "") !== (curDir.baseUrl ?? "")) { + dirPatch.apiKey = null + } + speechPatch[dir] = dirPatch + } + } + } + patch.speech = speechPatch + return patch +} + export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { // Full-document access app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config"))) app.patch("/api/storage/config", async (request, reply) => { try { - return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {})) + let body = request.body ?? {} + if (body && typeof body === "object" && "server" in body) { + const bodyObj = { ...(body as Record) } + const serverPatch = bodyObj.server + if (serverPatch && typeof serverPatch === "object") { + const currentServer = deps.settings.getOwner("config", "server") + bodyObj.server = enforceSpeechCredentialPairing(serverPatch, currentServer) + } + body = bodyObj + } + return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", body)) } catch (error) { reply.code(400) return { error: error instanceof Error ? error.message : "Invalid patch" } @@ -37,9 +77,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => { try { + const currentOwner = request.params.owner === "server" + ? deps.settings.getOwner("config", "server") + : undefined + const processed = request.params.owner === "server" + ? enforceSpeechCredentialPairing(request.body ?? {}, currentOwner) + : request.body ?? {} return sanitizeConfigOwner( request.params.owner, - deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}), + deps.settings.mergePatchOwner("config", request.params.owner, processed), ) } catch (error) { reply.code(400) diff --git a/packages/server/src/server/routes/usage.test.ts b/packages/server/src/server/routes/usage.test.ts new file mode 100644 index 00000000..ef4ca118 --- /dev/null +++ b/packages/server/src/server/routes/usage.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" + +import { registerUsageRoutes } from "./usage" + +test("returns a typed unsupported result without exposing server details", async () => { + const app = Fastify() + registerUsageRoutes(app) + + try { + const response = await app.inject({ method: "GET", url: "/api/usage/unknown-provider?modelId=test-model" }) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + requestedProviderId: "unknown-provider", + providerId: null, + providerName: "unknown-provider", + modelId: "test-model", + supported: false, + configured: false, + ok: false, + windows: {}, + fetchedAt: response.json().fetchedAt, + }) + } finally { + await app.close() + } +}) diff --git a/packages/server/src/server/routes/usage.ts b/packages/server/src/server/routes/usage.ts new file mode 100644 index 00000000..24e41473 --- /dev/null +++ b/packages/server/src/server/routes/usage.ts @@ -0,0 +1,24 @@ +import type { FastifyInstance } from "fastify" +import { z } from "zod" + +import { getProviderUsage } from "../../usage/service" + +const UsageParamsSchema = z.object({ providerId: z.string().trim().min(1) }) +const UsageQuerySchema = z.object({ modelId: z.string().trim().optional() }) + +export function registerUsageRoutes(app: FastifyInstance) { + app.get<{ Params: { providerId: string }; Querystring: { modelId?: string } }>( + "/api/usage/:providerId", + async (request, reply) => { + try { + const params = UsageParamsSchema.parse(request.params) + const query = UsageQuerySchema.parse(request.query ?? {}) + return await getProviderUsage(params.providerId, { modelId: query.modelId }) + } catch (error) { + request.log.error({ err: error }, "Failed to fetch provider usage") + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to fetch provider usage" } + } + }, + ) +} diff --git a/packages/server/src/server/routes/workspaces.test.ts b/packages/server/src/server/routes/workspaces.test.ts new file mode 100644 index 00000000..e115c5b7 --- /dev/null +++ b/packages/server/src/server/routes/workspaces.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import Fastify from "fastify" + +import type { WorkspaceDescriptor } from "../../api-types" +import type { WorkspaceManager } from "../../workspaces/manager" +import { registerWorkspaceRoutes } from "./workspaces" + +describe("workspace routes", () => { + it("forwards a validated explicit binary path when creating a workspace", async () => { + const calls: unknown[][] = [] + const app = Fastify({ logger: false }) + const descriptor: WorkspaceDescriptor = { + id: "workspace", + path: "C:/work", + status: "ready", + proxyPath: "/workspaces/workspace/instance", + binaryId: "C:/tools/opencode.exe", + binaryLabel: "opencode.exe", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } + const workspaceManager = { + create: async (...args: unknown[]) => { + calls.push(args) + return { workspace: descriptor, created: true } + }, + releaseCreationRequest: (workspaceId: string, requestId: string) => + workspaceId === descriptor.id && requestId === "restore-request", + cancelCreationRequest: async (requestId: string) => { + calls.push(["cancel", requestId]) + }, + } as unknown as WorkspaceManager + registerWorkspaceRoutes(app, { workspaceManager }) + + const response = await app.inject({ + method: "POST", + url: "/api/workspaces", + payload: { + path: "C:/work", + name: "Work", + binaryPath: " C:/tools/opencode.exe ", + requestId: " restore-request ", + forceNew: true, + }, + }) + + assert.equal(response.statusCode, 201) + assert.deepEqual(calls, [["C:/work", "Work", { + binaryPath: "C:/tools/opencode.exe", + requestId: "restore-request", + forceNew: true, + }]]) + + const released = await app.inject({ + method: "POST", + url: "/api/workspaces/workspace/creation/release", + payload: { requestId: "restore-request" }, + }) + assert.equal(released.statusCode, 204) + + const wrongRelease = await app.inject({ + method: "POST", + url: "/api/workspaces/workspace/creation/release", + payload: { requestId: "other-request" }, + }) + assert.equal(wrongRelease.statusCode, 404) + + const cancelled = await app.inject({ + method: "POST", + url: "/api/workspaces/creation/cancel", + payload: { requestId: "restore-request" }, + }) + assert.equal(cancelled.statusCode, 204) + assert.deepEqual(calls.at(-1), ["cancel", "restore-request"]) + + const invalid = await app.inject({ + method: "POST", + url: "/api/workspaces", + payload: { path: "C:/work", binaryPath: "x".repeat(4097) }, + }) + assert.equal(invalid.statusCode, 400) + assert.equal(calls.length, 2) + await app.close() + }) + + it("rejects release after cancellation wins while deletion is still pending", async () => { + const app = Fastify({ logger: false }) + let state: "active" | "cancelled" | "released" = "active" + let cancellationStarted!: () => void + let finishDeletion!: () => void + const started = new Promise((resolve) => { cancellationStarted = resolve }) + const deletion = new Promise((resolve) => { finishDeletion = resolve }) + const workspaceManager = { + cancelCreationRequest: async () => { + state = "cancelled" + cancellationStarted() + await deletion + }, + releaseCreationRequest: () => { + if (state === "cancelled") return false + state = "released" + return true + }, + } as unknown as WorkspaceManager + registerWorkspaceRoutes(app, { workspaceManager }) + + const cancellation = app.inject({ + method: "POST", + url: "/api/workspaces/creation/cancel", + payload: { requestId: "restore-request" }, + }) + await started + const release = await app.inject({ + method: "POST", + url: "/api/workspaces/workspace/creation/release", + payload: { requestId: "restore-request" }, + }) + + assert.equal(release.statusCode, 404) + assert.equal(release.body, "Workspace creation request not found") + finishDeletion() + assert.equal((await cancellation).statusCode, 204) + await app.close() + }) +}) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 7bb8a1b0..e7f05213 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -14,6 +14,9 @@ interface RouteDeps { const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), + binaryPath: z.string().trim().min(1).max(4096).optional(), + requestId: z.string().trim().min(1).max(128).optional(), + forceNew: z.boolean().optional(), }) const WorkspaceCloneSchema = z.object({ @@ -22,6 +25,10 @@ const WorkspaceCloneSchema = z.object({ cleanup: z.boolean().optional(), }) +const WorkspaceCreationReleaseSchema = z.object({ + requestId: z.string().trim().min(1).max(128), +}) + const WorkspaceFilesQuerySchema = z.object({ path: z.string().optional(), }) @@ -68,9 +75,13 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.post("/api/workspaces", async (request, reply) => { try { const body = WorkspaceCreateSchema.parse(request.body ?? {}) - const workspace = await deps.workspaceManager.create(body.path, body.name) + const result = await deps.workspaceManager.create(body.path, body.name, { + binaryPath: body.binaryPath, + requestId: body.requestId, + forceNew: body.forceNew, + }) reply.code(201) - return workspace + return result.created ? result.workspace : { ...result.workspace, reused: true as const } } catch (error) { request.log.error({ err: error }, "Failed to create workspace") const message = error instanceof Error ? error.message : "Failed to create workspace" @@ -103,6 +114,30 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(204) }) + app.post("/api/workspaces/creation/cancel", async (request, reply) => { + const parsed = WorkspaceCreationReleaseSchema.safeParse(request.body ?? {}) + if (!parsed.success) { + reply.code(400).type("text/plain").send("Invalid workspace creation request") + return + } + await deps.workspaceManager.cancelCreationRequest(parsed.data.requestId) + reply.code(204) + }) + + app.post<{ Params: { id: string } }>("/api/workspaces/:id/creation/release", async (request, reply) => { + const parsed = WorkspaceCreationReleaseSchema.safeParse(request.body ?? {}) + if (!parsed.success) { + reply.code(400).type("text/plain").send("Invalid workspace creation request") + return + } + const body = parsed.data + if (!deps.workspaceManager.releaseCreationRequest(request.params.id, body.requestId)) { + reply.code(404).type("text/plain").send("Workspace creation request not found") + return + } + reply.code(204) + }) + app.get<{ Params: { id: string } Querystring: { path?: string } diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index 9e76319b..d48d9ddb 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -9,10 +9,12 @@ import { removeWorktree, } from "../../workspaces/git-worktrees" import type { WorktreeListResponse, WorktreeMap } from "../../api-types" +import type { OpencodeYoloPersistence } from "../../permissions/opencode-yolo-metadata" import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map" interface RouteDeps { workspaceManager: WorkspaceManager + sessionMetadataPersistence: OpencodeYoloPersistence } const WorktreeMapSchema = z.object({ @@ -26,7 +28,34 @@ const WorktreeCreateSchema = z.object({ branch: z.string().trim().min(1).optional(), }) +const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) }) + export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { + app.put<{ Params: { id: string; sessionId: string }; Body: unknown }>( + "/api/workspaces/:id/worktrees/sessions/:sessionId", + async (request, reply) => { + if (!deps.workspaceManager.get(request.params.id)) { + reply.code(404) + return { error: "Workspace not found" } + } + try { + const body = WorktreeSessionSchema.parse(request.body) + if (!await deps.sessionMetadataPersistence.hasProjectSession(request.params.id, request.params.sessionId)) { + reply.code(404) + return { error: "Session not found" } + } + const metadata = await deps.sessionMetadataPersistence.setWorktreeSlug( + request.params.id, + request.params.sessionId, + body.worktreeSlug, + ) + return { metadata } + } catch (error) { + return handleError(error, reply) + } + }, + ) + app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => { const workspace = deps.workspaceManager.get(request.params.id) if (!workspace) { diff --git a/packages/server/src/server/routes/yolo.ts b/packages/server/src/server/routes/yolo.ts new file mode 100644 index 00000000..42341431 --- /dev/null +++ b/packages/server/src/server/routes/yolo.ts @@ -0,0 +1,27 @@ +import { FastifyInstance } from "fastify" +import type { AutoAcceptManager } from "../../permissions/auto-accept-manager" + +interface RouteDeps { + yoloManager: AutoAcceptManager +} + +export function registerYoloRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId", + async (request) => { + const { id, sessionId } = request.params + await deps.yoloManager.hydrateInstance(id) + return { enabled: deps.yoloManager.isEnabled(id, sessionId) } + }, + ) + + app.post<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId/toggle", + async (request, reply) => { + const { id, sessionId } = request.params + const enabled = await deps.yoloManager.toggle(id, sessionId) + reply.code(200) + return { enabled } + }, + ) +} diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts new file mode 100644 index 00000000..5c3fc86d --- /dev/null +++ b/packages/server/src/settings/binaries.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { BinaryResolver } from "./binaries" +import type { SettingsService } from "./service" + +describe("BinaryResolver", () => { + it("uses an explicit workspace binary without changing the configured default", () => { + const settings = { + getOwner(scope: string, owner: string) { + if (scope === "config" && owner === "server") return { opencodeBinary: "default-opencode" } + if (scope === "state" && owner === "ui") return { opencodeBinaries: [{ path: "saved-opencode", label: "Saved", version: "1.2.3" }] } + return {} + }, + } as unknown as SettingsService + const resolver = new BinaryResolver(settings) + assert.deepEqual(resolver.resolve("saved-opencode"), { path: "saved-opencode", label: "Saved", version: "1.2.3" }) + assert.equal(resolver.resolveDefault().path, "default-opencode") + }) +}) diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index e4b25960..d637ac95 100644 --- a/packages/server/src/settings/binaries.ts +++ b/packages/server/src/settings/binaries.ts @@ -40,10 +40,14 @@ export class BinaryResolver { } resolveDefault(): ResolvedBinary { + return this.resolve() + } + + resolve(explicitPath?: string): ResolvedBinary { const binaries = this.list() const configuredDefault = readDefaultBinaryPath(this.settings) const fallback = binaries[0]?.path - const path = configuredDefault ?? fallback ?? "opencode" + const path = explicitPath?.trim() || configuredDefault || fallback || "opencode" const entry = binaries.find((b) => b.path === path) return { diff --git a/packages/server/src/settings/public-config.test.ts b/packages/server/src/settings/public-config.test.ts new file mode 100644 index 00000000..afe66640 --- /dev/null +++ b/packages/server/src/settings/public-config.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { sanitizeConfigOwner } from "./public-config" + +describe("sanitizeConfigOwner server speech", () => { + it("strips shared apiKey and sets hasApiKey", () => { + const result = sanitizeConfigOwner("server", { + speech: { apiKey: "sk-secret", sttModel: "whisper-1" }, + }) + assert.equal((result.speech as any).apiKey, undefined) + assert.equal((result.speech as any).hasApiKey, true) + }) + + it("strips stt.apiKey and sets stt.hasApiKey when separateProviders is true", () => { + const result = sanitizeConfigOwner("server", { + speech: { + separateProviders: true, + apiKey: "sk-shared", + stt: { apiKey: "sk-stt-secret", baseUrl: "https://groq.com" }, + tts: { apiKey: "sk-tts-secret", baseUrl: "https://openai.com" }, + }, + }) + assert.equal((result.speech as any).apiKey, undefined) + assert.equal((result.speech as any).hasApiKey, true) + assert.equal((result.speech as any).stt.apiKey, undefined) + assert.equal((result.speech as any).stt.hasApiKey, true) + assert.equal((result.speech as any).tts.apiKey, undefined) + assert.equal((result.speech as any).tts.hasApiKey, true) + }) + + it("sets hasApiKey=false when per-direction apiKey is absent", () => { + const result = sanitizeConfigOwner("server", { + speech: { + separateProviders: true, + stt: { baseUrl: "https://groq.com" }, + tts: { baseUrl: "https://openai.com" }, + }, + }) + assert.equal((result.speech as any).stt.hasApiKey, false) + assert.equal((result.speech as any).tts.hasApiKey, false) + }) + + it("preserves stt/tts sub-objects that have no apiKey", () => { + const result = sanitizeConfigOwner("server", { + speech: { + separateProviders: true, + stt: { model: "whisper-large-v3" }, + tts: { model: "tts-1" }, + }, + }) + assert.equal((result.speech as any).stt.model, "whisper-large-v3") + assert.equal((result.speech as any).stt.hasApiKey, false) + assert.equal((result.speech as any).tts.model, "tts-1") + assert.equal((result.speech as any).tts.hasApiKey, false) + }) + + it("passes through non-server owners unchanged", () => { + const result = sanitizeConfigOwner("ui", { foo: "bar" }) + assert.deepEqual(result, { foo: "bar" }) + }) +}) diff --git a/packages/server/src/settings/public-config.ts b/packages/server/src/settings/public-config.ts index a79a0222..a3eb26a7 100644 --- a/packages/server/src/settings/public-config.ts +++ b/packages/server/src/settings/public-config.ts @@ -20,6 +20,20 @@ function sanitizeServerOwner(value: SettingsDoc): SettingsDoc { speech.hasApiKey = false } + for (const dir of ["stt", "tts"] as const) { + if (isPlainObject(speech[dir])) { + const sub = { ...speech[dir] } as SettingsDoc + const dirKey = typeof sub.apiKey === "string" ? sub.apiKey.trim() : "" + if (dirKey) { + delete sub.apiKey + sub.hasApiKey = true + } else if (!("hasApiKey" in sub)) { + sub.hasApiKey = false + } + speech[dir] = sub + } + } + next.speech = speech return next } diff --git a/packages/server/src/shutdown.test.ts b/packages/server/src/shutdown.test.ts new file mode 100644 index 00000000..446e7791 --- /dev/null +++ b/packages/server/src/shutdown.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { + createServerShutdownHandler, + orchestrateServerShutdown, + SERVER_SHUTDOWN_COMPLETE, + SERVER_SHUTDOWN_INCOMPLETE, + type ServerShutdownOperations, +} from "./shutdown" + +const logger = { info() {}, warn() {}, error() {} } +const operations = (overrides: Partial = {}): ServerShutdownOperations => ({ + stopInstanceEventBridge() {}, stopSidecars() {}, stopClientConnections() {}, + stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, + ...overrides, +}) + +describe("server shutdown orchestration", () => { + it("retries workspace cleanup and preserves shutdown order", async () => { + const calls: string[] = [] + let attempts = 0 + await orchestrateServerShutdown(operations({ + stopRemoteProxySessions: () => { calls.push("remote-proxy") }, + stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") }, + stopHttpServers: () => { calls.push("http") }, + }), logger) + assert.deepEqual(calls, ["workspaces-1", "remote-proxy", "workspaces-2", "http"]) + }) + + it("closes remaining resources and aggregates the concrete current error", async () => { + const failure = new Error("workspace abc POSIX process group is still alive") + const closed: string[] = [] + let attempts = 0 + await assert.rejects(orchestrateServerShutdown(operations({ + stopWorkspaces: () => { attempts++; throw failure }, + stopHttpServers: () => { closed.push("http") }, + stopReleaseMonitor: () => { closed.push("release-monitor") }, + }), logger), (error: unknown) => { + assert.ok(error instanceof AggregateError) + assert.equal(error.errors.length, 1) + assert.match(error.errors[0].message, /^stopWorkspaces failed:/) + assert.strictEqual(error.errors[0].cause, failure) + return true + }) + assert.deepEqual([attempts, closed], [2, ["http", "release-monitor"]]) + }) + + it("starts workspace cleanup without waiting for preliminary shutdown", async () => { + let releasePreliminary!: () => void + const preliminary = new Promise((resolve) => { releasePreliminary = resolve }) + let workspaceStarted = false + const shutdown = orchestrateServerShutdown(operations({ + stopRemoteProxySessions: () => preliminary, + stopWorkspaces: () => { workspaceStarted = true }, + }), logger) + + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(workspaceStarted, true) + releasePreliminary() + await shutdown + }) +}) + +describe("server shutdown signal boundary", () => { + it("reports incomplete cleanup and holds for final tree enforcement", async () => { + const calls: string[] = [] + const exits: number[] = [] + const statuses: string[] = [] + let releaseHold!: () => void + const hold = new Promise((resolve) => { releaseHold = resolve }) + const handler = createServerShutdownHandler({ + shutdown: async () => { calls.push("cleanup"); throw new Error("retained child survived") }, + logger: { info: () => calls.push("info"), warn() {}, error: () => calls.push("error") }, + forceExit: (code) => { calls.push("force-exit"); exits.push(code) }, + setExitCode: () => undefined, + reportStatus: (status) => statuses.push(status), + holdAfterFailure: () => hold, + retryAttempts: 0, + }) + const pending = handler("SIGTERM") + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual([exits, statuses, calls], [[], [SERVER_SHUTDOWN_INCOMPLETE], ["info", "cleanup", "error", "error"]]) + handler("SIGTERM") + assert.deepEqual(exits, [1]) + releaseHold() + await pending + }) + + it("reports complete cleanup before allowing natural exit", async () => { + const statuses: string[] = [], exitCodes: number[] = [] + const handler = createServerShutdownHandler({ + shutdown: async () => undefined, + logger, + reportStatus: (status) => statuses.push(status), + setExitCode: (code) => exitCodes.push(code), + }) + await handler("stdin") + assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE]) + assert.deepEqual(exitCodes, [0]) + }) + + it("keeps retrying identity-aware cleanup during the final enforcement budget", async () => { + let attempts = 0 + const statuses: string[] = [], exitCodes: number[] = [] + const handler = createServerShutdownHandler({ + shutdown: async () => { + attempts++ + if (attempts === 1) throw new Error("detached group still alive") + }, + logger, + reportStatus: (status) => statuses.push(status), + setExitCode: (code) => exitCodes.push(code), + retryDelayMs: 0, + }) + + await handler("stdin") + assert.equal(attempts, 2) + assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE]) + assert.deepEqual(exitCodes, [0]) + }) + + it("preserves containment after the standalone cleanup retry budget", async () => { + let attempts = 0 + const statuses: string[] = [], exitCodes: number[] = [], forcedExits: number[] = [] + let releaseHold!: () => void + const hold = new Promise((resolve) => { releaseHold = resolve }) + const handler = createServerShutdownHandler({ + shutdown: async () => { attempts++; throw new Error("process tree remains alive") }, + logger, + reportStatus: (status) => statuses.push(status), + setExitCode: (code) => exitCodes.push(code), + forceExit: (code) => forcedExits.push(code), + holdAfterFailure: () => hold, + retryDelayMs: 0, + retryAttempts: 2, + }) + + const pending = handler("stdin") + while (attempts < 3) await new Promise((resolve) => setTimeout(resolve, 0)) + assert.equal(attempts, 3) + assert.deepEqual(statuses, [SERVER_SHUTDOWN_INCOMPLETE]) + assert.deepEqual(exitCodes, [1]) + assert.deepEqual(forcedExits, []) + handler("SIGTERM") + assert.deepEqual(forcedExits, [1]) + releaseHold() + await pending + }) + + it("escalates a second signal while sharing first-signal cleanup", async () => { + let finish!: () => void + const cleanup = new Promise((resolve) => { finish = resolve }) + const exits: number[] = [], exitCodes: number[] = [] + const handler = createServerShutdownHandler({ shutdown: () => cleanup, logger, + forceExit: (code) => exits.push(code), setExitCode: (code) => exitCodes.push(code), reportStatus: () => undefined }) + const first = handler("SIGINT"), second = handler("SIGTERM") + assert.strictEqual(first, second) + assert.deepEqual(exits, [1]) + finish(); await first + assert.deepEqual(exitCodes, [0]) + }) +}) diff --git a/packages/server/src/shutdown.ts b/packages/server/src/shutdown.ts new file mode 100644 index 00000000..3324aa25 --- /dev/null +++ b/packages/server/src/shutdown.ts @@ -0,0 +1,101 @@ +type ShutdownLogger = Pick +type ShutdownOperation = () => void | Promise + +export type ServerShutdownTrigger = NodeJS.Signals | "stdin" +export const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" +export const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" + +export type ServerShutdownOperations = Record< + "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteProxySessions" | "stopWorkspaces" | + "stopHttpServers" | "stopReleaseMonitor", + ShutdownOperation +> + +export function createServerShutdownHandler(options: { shutdown: () => Promise; logger: ShutdownLogger; + forceExit?: (code: number) => void; setExitCode?: (code: number) => void; + reportStatus?: (status: string) => void; holdAfterFailure?: () => Promise; + retryDelayMs?: number; retryAttempts?: number }) { + const forceExit = options.forceExit ?? process.exit + const setExitCode = options.setExitCode ?? ((code: number) => { process.exitCode = code }) + const reportStatus = options.reportStatus ?? ((status: string) => console.log(status)) + let pending: Promise | undefined + return (signal: ServerShutdownTrigger): Promise => { + if (pending) { + options.logger.error({ signal }, "Additional shutdown signal received; forcing nonzero exit") + forceExit(1) + return pending + } + options.logger.info({ signal }, "Received shutdown signal, stopping workspaces and server") + pending = Promise.resolve().then(options.shutdown).then(() => { + options.logger.info({}, "Shutdown complete") + reportStatus(SERVER_SHUTDOWN_COMPLETE) + setExitCode(0) + }, async (error) => { + options.logger.error({ err: error }, "Server shutdown incomplete; retrying cleanup") + const retryAttempts = Math.max(0, Math.floor(options.retryAttempts ?? 3)) + for (let attempt = 1; attempt <= retryAttempts; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, options.retryDelayMs ?? 250)) + try { + await options.shutdown() + options.logger.info({}, "Shutdown cleanup retry completed") + reportStatus(SERVER_SHUTDOWN_COMPLETE) + setExitCode(0) + return + } catch (retryError) { + options.logger.warn({ err: retryError, attempt, attempts: retryAttempts }, "Shutdown cleanup retry remains incomplete") + } + } + options.logger.error({ attempts: retryAttempts }, "Shutdown cleanup retries exhausted; preserving process-tree containment") + reportStatus(SERVER_SHUTDOWN_INCOMPLETE) + setExitCode(1) + if (options.holdAfterFailure) await options.holdAfterFailure() + }) + return pending + } +} + +export async function orchestrateServerShutdown( + operations: ServerShutdownOperations, + logger: ShutdownLogger, + workspaceAttempts = 2, +): Promise { + const errors: unknown[] = [] + const namedError = (name: keyof ServerShutdownOperations, cause: unknown) => Object.assign( + new Error(`${name} failed: ${cause instanceof Error ? cause.message : String(cause)}`), + { cause }, + ) + const settle = async (pending: Array<[keyof ServerShutdownOperations, ShutdownOperation]>) => { + const results = await Promise.allSettled(pending.map(([, run]) => Promise.resolve().then(run))) + for (let index = 0; index < results.length; index += 1) { + const result = results[index]! + if (result.status !== "rejected") continue + const error = namedError(pending[index]![0], result.reason) + errors.push(error) + logger.error({ err: error }, "Server resource shutdown failed") + } + } + + const workspaceShutdown = (async () => { + const attempts = Math.max(1, Math.floor(workspaceAttempts)) + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const [result] = await Promise.allSettled([Promise.resolve().then(operations.stopWorkspaces)]) + if (result.status === "fulfilled") break + if (attempt < attempts) { + logger.warn({ err: result.reason, attempt, attempts }, "Workspace manager shutdown failed; retrying cleanup") + continue + } + const error = namedError("stopWorkspaces", result.reason) + errors.push(error) + logger.error({ err: error, attempts }, "Workspace manager shutdown failed") + } + })() + await Promise.all([ + settle([ + ["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars], + ["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions], + ]), + workspaceShutdown, + ]) + await settle([["stopHttpServers", operations.stopHttpServers], ["stopReleaseMonitor", operations.stopReleaseMonitor]]) + if (errors.length) throw new AggregateError(errors, "Server shutdown failed") +} diff --git a/packages/server/src/speech/service.test.ts b/packages/server/src/speech/service.test.ts new file mode 100644 index 00000000..66af0534 --- /dev/null +++ b/packages/server/src/speech/service.test.ts @@ -0,0 +1,289 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { SpeechService } from "./service" +import type { SettingsService } from "../settings/service" +import type { Logger } from "../logger" + +function createMockSettings(serverConfig: Record): SettingsService { + return { + getOwner: () => serverConfig, + } as unknown as SettingsService +} + +const mockLogger: Logger = { + child: () => mockLogger, + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, +} as unknown as Logger + +describe("SpeechService direction resolution", () => { + describe("separateProviders = false (default)", () => { + it("uses shared apiKey for both STT and TTS", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + sttModel: "whisper-1", + ttsModel: "tts-1", + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.separateProviders, false) + assert.equal(caps.sttConfigured, true) + assert.equal(caps.ttsConfigured, true) + assert.equal(caps.configured, true) + }) + + it("reports unconfigured when no apiKey is set", () => { + const settings = createMockSettings({ + speech: { sttModel: "whisper-1", ttsModel: "tts-1" }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, false) + assert.equal(caps.ttsConfigured, false) + assert.equal(caps.configured, false) + }) + + it("transcribe throws not-configured when apiKey absent", async () => { + const service = new SpeechService(createMockSettings({ speech: {} }), mockLogger) + await assert.rejects( + () => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }), + /not configured/i, + ) + }) + }) + + describe("separateProviders = true", () => { + it("reports per-direction configured status independently", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + separateProviders: true, + stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1", model: "whisper-large-v3" }, + tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.separateProviders, true) + assert.equal(caps.sttConfigured, true) + assert.equal(caps.ttsConfigured, true) + assert.equal(caps.configured, true) + assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1") + assert.equal(caps.ttsBaseUrl, "https://api.openai.com/v1") + }) + + it("reports partial config when only STT has complete directional pair", () => { + const settings = createMockSettings({ + speech: { + separateProviders: true, + stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, true) + assert.equal(caps.ttsConfigured, false) + assert.equal(caps.configured, false) + }) + + it("does NOT combine directional key with shared URL (incomplete pair)", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + separateProviders: true, + stt: { apiKey: "sk-groq" }, + tts: { apiKey: "sk-deepseek" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, false, "directional key without directional URL should not be configured") + assert.equal(caps.ttsConfigured, false) + }) + + it("does NOT combine shared key with foreign directional URL (incomplete pair)", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + separateProviders: true, + stt: { baseUrl: "https://api.groq.com/openai/v1" }, + tts: { baseUrl: "https://api.deepseek.com/v1" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, false) + assert.equal(caps.ttsConfigured, false) + }) + + it("treats directional baseUrl matching shared as inherited (no directional key)", () => { + const sharedUrl = "https://api.openai.com/v1" + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: sharedUrl, + separateProviders: true, + stt: { baseUrl: sharedUrl }, + tts: { baseUrl: sharedUrl }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, true, "directional baseUrl matching shared with no key should inherit shared key") + assert.equal(caps.ttsConfigured, true) + }) + + it("inherits complete shared pair when neither directional value is set", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + separateProviders: true, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, true) + assert.equal(caps.ttsConfigured, true) + }) + + it("uses complete directional pair when both key and URL are set", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + separateProviders: true, + stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" }, + tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, true) + assert.equal(caps.ttsConfigured, true) + assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1") + }) + + it("directional key with matching shared URL forms a complete pair", () => { + const sharedUrl = "https://api.openai.com/v1" + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: sharedUrl, + separateProviders: true, + stt: { apiKey: "sk-alt-openai", baseUrl: sharedUrl }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, true, "directional key with matching shared URL should be a complete pair") + }) + + it("falls back to shared sttModel/ttsModel when per-direction model is absent", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + sttModel: "shared-stt-model", + ttsModel: "shared-tts-model", + separateProviders: true, + stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1" }, + tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttModel, "shared-stt-model") + assert.equal(caps.ttsModel, "shared-tts-model") + }) + + it("uses per-direction model when set", () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + sttModel: "shared-stt-model", + ttsModel: "shared-tts-model", + separateProviders: true, + stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1", model: "whisper-large-v3" }, + tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttModel, "whisper-large-v3") + assert.equal(caps.ttsModel, "gpt-4o-mini-tts") + }) + + it("omits sttBaseUrl/ttsBaseUrl when separateProviders is false", () => { + const settings = createMockSettings({ + speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttBaseUrl, undefined) + assert.equal(caps.ttsBaseUrl, undefined) + }) + + it("transcribe throws not-configured for incomplete pair (key without URL)", async () => { + const settings = createMockSettings({ + speech: { + apiKey: "sk-shared", + baseUrl: "https://api.openai.com/v1", + separateProviders: true, + stt: { apiKey: "sk-groq" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + await assert.rejects( + () => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }), + /not configured/i, + ) + }) + + it("configured uses && in separate mode (both required for legacy compat)", () => { + const settings = createMockSettings({ + speech: { + separateProviders: true, + stt: { apiKey: "sk-stt", baseUrl: "https://api.stt.com/v1" }, + }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.sttConfigured, true) + assert.equal(caps.ttsConfigured, false) + assert.equal(caps.configured, false, "configured should be false when only one direction is ready") + }) + + it("configured uses || in shared mode", () => { + const settings = createMockSettings({ + speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" }, + }) + const service = new SpeechService(settings, mockLogger) + const caps = service.getCapabilities() + + assert.equal(caps.configured, true) + }) + }) +}) diff --git a/packages/server/src/speech/service.ts b/packages/server/src/speech/service.ts index d3c37646..e1fd9d82 100644 --- a/packages/server/src/speech/service.ts +++ b/packages/server/src/speech/service.ts @@ -15,6 +15,21 @@ const ServerSpeechSettingsSchema = z.object({ ttsModel: z.string().optional(), ttsVoice: z.string().optional(), ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(), + separateProviders: z.boolean().optional(), + stt: z + .object({ + apiKey: z.string().nullish(), + baseUrl: z.string().nullish(), + model: z.string().nullish(), + }) + .optional(), + tts: z + .object({ + apiKey: z.string().nullish(), + baseUrl: z.string().nullish(), + model: z.string().nullish(), + }) + .optional(), }) .optional(), }) @@ -38,7 +53,10 @@ export interface SpeechSynthesisStreamResponse { } export interface SpeechProvider { - getCapabilities(): SpeechCapabilitiesResponse + getCapabilities(): Omit< + SpeechCapabilitiesResponse, + "separateProviders" | "sttConfigured" | "ttsConfigured" | "sttBaseUrl" | "ttsBaseUrl" + > transcribe(input: TranscribeAudioInput): Promise synthesize(input: SynthesizeSpeechInput): Promise synthesizeStream(input: SynthesizeSpeechInput): Promise @@ -66,33 +84,76 @@ export class SpeechService { ) {} getCapabilities(): SpeechCapabilitiesResponse { - return this.createProvider().getCapabilities() + const parsed = this.parseConfig() + const speech = parsed.speech ?? {} + const separate = speech.separateProviders === true + const sttSettings = this.resolveSttSettingsFrom(speech) + const ttsSettings = this.resolveTtsSettingsFrom(speech) + + const sttConfigured = Boolean(sttSettings.apiKey) + const ttsConfigured = Boolean(ttsSettings.apiKey) + + return { + available: true, + configured: separate ? (sttConfigured && ttsConfigured) : (sttConfigured || ttsConfigured), + provider: sttSettings.provider, + supportsStt: true, + supportsTts: true, + supportsStreamingTts: true, + baseUrl: separate ? undefined : (speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined), + sttModel: sttSettings.sttModel, + ttsModel: ttsSettings.ttsModel, + ttsVoice: ttsSettings.ttsVoice, + ttsFormats: ["mp3", "wav", "opus", "aac"], + streamingTtsFormats: ["mp3", "wav", "opus", "aac"], + separateProviders: separate, + sttConfigured, + ttsConfigured, + ...(separate ? { sttBaseUrl: sttSettings.baseUrl, ttsBaseUrl: ttsSettings.baseUrl } : {}), + } } async transcribe(input: TranscribeAudioInput): Promise { - return this.createProvider().transcribe(input) + return this.createSttProvider().transcribe(input) } async synthesize(input: SynthesizeSpeechInput): Promise { - return this.createProvider().synthesize(input) + return this.createTtsProvider().synthesize(input) } async synthesizeStream(input: SynthesizeSpeechInput): Promise { - return this.createProvider().synthesizeStream(input) + return this.createTtsProvider().synthesizeStream(input) } - private createProvider(): SpeechProvider { - const settings = this.resolveSettings() + private parseConfig() { + return ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {}) + } + + private createSttProvider(): SpeechProvider { + const settings = this.resolveSttSettings() return new OpenAICompatibleSpeechProvider({ settings, - logger: this.logger.child({ provider: settings.provider }), + logger: this.logger.child({ provider: settings.provider, direction: "stt" }), }) } - private resolveSettings(): NormalizedSpeechSettings { - const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {}) - const speech = parsed.speech ?? {} + private createTtsProvider(): SpeechProvider { + const settings = this.resolveTtsSettings() + return new OpenAICompatibleSpeechProvider({ + settings, + logger: this.logger.child({ provider: settings.provider, direction: "tts" }), + }) + } + private resolveSttSettings(): NormalizedSpeechSettings { + return this.resolveSttSettingsFrom(this.parseConfig().speech ?? {}) + } + + private resolveTtsSettings(): NormalizedSpeechSettings { + return this.resolveTtsSettingsFrom(this.parseConfig().speech ?? {}) + } + + private resolveShared(speech: NonNullable["speech"]>): NormalizedSpeechSettings { return { provider: speech.provider?.trim() || DEFAULT_PROVIDER, apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY, @@ -103,4 +164,64 @@ export class SpeechService { ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT, } } + + private resolveSttSettingsFrom(speech: NonNullable["speech"]>): NormalizedSpeechSettings { + if (speech.separateProviders !== true) { + return this.resolveShared(speech) + } + const stt = speech.stt ?? {} + const sharedBaseUrl = speech.baseUrl?.trim() || undefined + const rawDirBaseUrl = stt.baseUrl?.trim() || undefined + const dirApiKey = stt.apiKey?.trim() || undefined + const dirBaseUrl = dirApiKey + ? rawDirBaseUrl + : (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined) + const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl) + const hasNoDirectional = !dirApiKey && !dirBaseUrl + return { + provider: speech.provider?.trim() || DEFAULT_PROVIDER, + apiKey: hasCompleteDirectional + ? dirApiKey + : hasNoDirectional + ? speech.apiKey?.trim() || process.env.OPENAI_API_KEY + : undefined, + baseUrl: hasCompleteDirectional || hasNoDirectional + ? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined) + : dirBaseUrl || undefined, + sttModel: stt.model?.trim() || speech.sttModel?.trim() || DEFAULT_STT_MODEL, + ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL, + ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE, + ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT, + } + } + + private resolveTtsSettingsFrom(speech: NonNullable["speech"]>): NormalizedSpeechSettings { + if (speech.separateProviders !== true) { + return this.resolveShared(speech) + } + const tts = speech.tts ?? {} + const sharedBaseUrl = speech.baseUrl?.trim() || undefined + const rawDirBaseUrl = tts.baseUrl?.trim() || undefined + const dirApiKey = tts.apiKey?.trim() || undefined + const dirBaseUrl = dirApiKey + ? rawDirBaseUrl + : (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined) + const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl) + const hasNoDirectional = !dirApiKey && !dirBaseUrl + return { + provider: speech.provider?.trim() || DEFAULT_PROVIDER, + apiKey: hasCompleteDirectional + ? dirApiKey + : hasNoDirectional + ? speech.apiKey?.trim() || process.env.OPENAI_API_KEY + : undefined, + baseUrl: hasCompleteDirectional || hasNoDirectional + ? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined) + : dirBaseUrl || undefined, + sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL, + ttsModel: tts.model?.trim() || speech.ttsModel?.trim() || DEFAULT_TTS_MODEL, + ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE, + ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT, + } + } } diff --git a/packages/server/src/usage/providers/api-key.ts b/packages/server/src/usage/providers/api-key.ts new file mode 100644 index 00000000..756e25f1 --- /dev/null +++ b/packages/server/src/usage/providers/api-key.ts @@ -0,0 +1,182 @@ +import type { UsageProvider } from "../types" +import { + fetchJson, + getCredential, + notConfigured, + resolveWindowLabel, + safeFetch, + toNumber, + toTimestamp, + toUsageWindow, +} from "../shared" + +const kimiAliases = ["kimi-for-coding", "kimi"] as const +const kimi: UsageProvider = { + id: "kimi-for-coding", + name: "Kimi for Coding", + aliases: kimiAliases, + async fetchQuota() { + const key = getCredential(kimiAliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload = await fetchJson("https://api.kimi.com/coding/v1/usages", { + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + }) + const windows: Record> = {} + const usage = payload?.usage + if (usage) { + const limit = toNumber(usage.limit) + const remaining = toNumber(usage.remaining) + windows.weekly = toUsageWindow({ + usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null, + resetAt: toTimestamp(usage.resetTime), + }) + } + for (const item of Array.isArray(payload?.limits) ? payload.limits : []) { + const duration = toNumber(item?.window?.duration) + const unit = item?.window?.timeUnit + const multiplier = unit === "TIME_UNIT_MINUTE" ? 60 : unit === "TIME_UNIT_HOUR" ? 3600 : unit === "TIME_UNIT_DAY" ? 86_400 : null + const seconds = duration !== null && multiplier ? duration * multiplier : null + const limit = toNumber(item?.detail?.limit) + const remaining = toNumber(item?.detail?.remaining) + windows[resolveWindowLabel(seconds)] = toUsageWindow({ + usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null, + windowSeconds: seconds, + resetAt: toTimestamp(item?.detail?.resetTime), + }) + } + return { windows } + }) + }, +} + +const nanoAliases = ["nano-gpt", "nanogpt", "nano_gpt"] as const +const nanoGpt: UsageProvider = { + id: "nano-gpt", + name: "NanoGPT", + aliases: nanoAliases, + async fetchQuota() { + const key = getCredential(nanoAliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload = await fetchJson("https://nano-gpt.com/api/subscription/v1/usage", { + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + }) + const windows: Record> = {} + for (const [label, source] of [["daily", payload?.daily], ["monthly", payload?.monthly]] as const) { + if (!source) continue + const fraction = toNumber(source.percentUsed) + const used = toNumber(source.used) + const limit = toNumber(source.limit ?? source.limits?.[label]) + windows[label] = toUsageWindow({ + usedPercent: fraction !== null ? fraction * 100 : used !== null && limit ? (used / limit) * 100 : null, + windowSeconds: label === "daily" ? 86_400 : null, + resetAt: toTimestamp(source.resetAt ?? payload?.period?.currentPeriodEnd), + }) + } + return { windows } + }) + }, +} + +const openRouterAliases = ["openrouter"] as const +const openRouter: UsageProvider = { + id: "openrouter", + name: "OpenRouter", + aliases: openRouterAliases, + async fetchQuota() { + const key = getCredential(openRouterAliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload = await fetchJson("https://openrouter.ai/api/v1/credits", { + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + }) + const total = toNumber(payload?.data?.total_credits) + const used = toNumber(payload?.data?.total_usage) + const remaining = total !== null && used !== null ? Math.max(0, total - used) : null + return { + windows: { + credits: toUsageWindow({ + usedPercent: total && used !== null ? (used / total) * 100 : null, + valueLabel: remaining !== null && total !== null ? `$${remaining.toFixed(2)} / $${total.toFixed(2)}` : null, + }), + }, + } + }) + }, +} + +function createTokenLimitProvider(input: { id: string; name: string; aliases: readonly string[]; url: string }): UsageProvider { + return { + id: input.id, + name: input.name, + aliases: input.aliases, + async fetchQuota() { + const key = getCredential(input.aliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload = await fetchJson(input.url, { headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" } }) + const windows: Record> = {} + for (const limit of Array.isArray(payload?.data?.limits) ? payload.data.limits : []) { + if (limit?.type !== "TOKENS_LIMIT" && limit?.type !== "TIME_LIMIT") continue + const duration = toNumber(limit.number) + const seconds = limit.type === "TIME_LIMIT" ? 30 * 86_400 : limit.unit === 3 && duration ? duration * 3600 : null + const label = limit.type === "TIME_LIMIT" ? "mcp-tools" : resolveWindowLabel(seconds) + windows[label] = toUsageWindow({ + usedPercent: toNumber(limit.percentage), + windowSeconds: seconds, + resetAt: toTimestamp(limit.nextResetTime), + }) + } + return { windows } + }) + }, + } +} + +const zai = createTokenLimitProvider({ + id: "zai-coding-plan", + name: "z.ai", + aliases: ["zai-coding-plan", "zai", "z.ai"], + url: "https://api.z.ai/api/monitor/usage/quota/limit", +}) + +const zhipu = createTokenLimitProvider({ + id: "zhipuai-coding-plan", + name: "Zhipu AI Coding Plan", + aliases: ["zhipuai-coding-plan", "zhipuai", "zhipu"], + url: "https://open.bigmodel.cn/api/monitor/usage/quota/limit", +}) + +const waferAliases = ["wafer", "wafer-ai", "wafer_ai", "wafer.ai"] as const +const wafer: UsageProvider = { + id: "wafer", + name: "Wafer.ai", + aliases: waferAliases, + async fetchQuota() { + const key = getCredential(waferAliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload = await fetchJson("https://pass.wafer.ai/v1/inference/quota", { + headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" }, + }) + const remaining = toNumber(payload?.remaining_included_requests) + const limit = toNumber(payload?.included_request_limit) + const startAt = toTimestamp(payload?.window_start) + const resetAt = toTimestamp(payload?.window_end) + const seconds = startAt !== null && resetAt !== null ? Math.round((resetAt - startAt) / 1000) : 5 * 3600 + return { + windows: { + [resolveWindowLabel(seconds)]: toUsageWindow({ + usedPercent: toNumber(payload?.current_period_used_percent), + windowSeconds: seconds, + resetAt, + valueLabel: remaining !== null && limit !== null ? `${remaining} / ${limit}` : null, + }), + }, + } + }) + }, +} + +export const apiKeyProviders: UsageProvider[] = [kimi, nanoGpt, openRouter, zai, zhipu, wafer] diff --git a/packages/server/src/usage/providers/google.ts b/packages/server/src/usage/providers/google.ts new file mode 100644 index 00000000..f624b7e7 --- /dev/null +++ b/packages/server/src/usage/providers/google.ts @@ -0,0 +1,179 @@ +import fs from "fs" +import os from "os" +import path from "path" + +import type { UsageProvider } from "../types" +import { asObject, fetchJson, getAuthEntry, getString, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared" + +const GOOGLE_ENDPOINTS = [ + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", +] as const +const DEFAULT_PROJECT_ID = "rising-fact-p41fc" + +interface GoogleSource { + id: "gemini" | "antigravity" + accessToken?: string + refreshToken?: string + projectId?: string + expires?: number | null +} + +function parseRefresh(value: unknown): { token?: string; projectId?: string } { + const raw = getString(value) + if (!raw) return {} + const [token, projectId, managedProjectId] = raw.split("|") + return { token: getString(token) ?? undefined, projectId: getString(projectId) ?? getString(managedProjectId) ?? undefined } +} + +function readJson(filePath: string): any | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) + } catch { + return null + } +} + +function googleSources(): GoogleSource[] { + const sources: GoogleSource[] = [] + const entry = asObject(getAuthEntry(["google", "google.oauth"])) + const oauth = asObject(entry?.oauth) ?? entry + if (oauth) { + const refresh = parseRefresh(oauth.refresh) + const accessToken = getString(oauth.access) ?? getString(oauth.token) ?? undefined + if (accessToken || refresh.token) { + sources.push({ + id: "gemini", + accessToken, + refreshToken: refresh.token, + projectId: refresh.projectId, + expires: toTimestamp(oauth.expires), + }) + } + } + + const home = os.homedir() + for (const candidate of [ + path.join(home, ".config", "opencode", "antigravity-accounts.json"), + path.join(home, ".local", "share", "opencode", "antigravity-accounts.json"), + ]) { + const data = readJson(candidate) + const accounts = Array.isArray(data?.accounts) ? data.accounts : [] + const account = accounts[data?.activeIndex ?? 0] ?? accounts[0] + const refresh = parseRefresh(account?.refreshToken) + const accessToken = getString(account?.accessToken) ?? getString(account?.access_token) ?? undefined + if (accessToken || refresh.token) { + sources.push({ + id: "antigravity", + accessToken, + refreshToken: refresh.token, + projectId: getString(account?.projectId) ?? refresh.projectId, + expires: toTimestamp(account?.expiresAt ?? account?.expires), + }) + break + } + } + return sources +} + +async function refreshAccessToken(source: GoogleSource): Promise { + if (!source.refreshToken) return null + const prefix = source.id === "gemini" ? "GOOGLE" : "ANTIGRAVITY" + const clientId = getString(process.env[`${prefix}_OAUTH_CLIENT_ID`]) + const clientSecret = getString(process.env[`${prefix}_OAUTH_CLIENT_SECRET`]) + if (!clientId || !clientSecret) return null + const payload = await fetchJson("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: source.refreshToken, + grant_type: "refresh_token", + }), + }) + return getString(payload?.access_token) +} + +function transformModel(sourceId: string, modelId: string, data: any) { + const fraction = toNumber(data?.quotaInfo?.remainingFraction ?? data?.remainingFraction) + const resetAt = toTimestamp(data?.quotaInfo?.resetTime ?? data?.resetTime) + const label = sourceId === "gemini" || (resetAt !== null && resetAt - Date.now() > 10 * 3600 * 1000) ? "daily" : "5h" + return { + [`${sourceId}/${modelId}`]: { + windows: { + [label]: toUsageWindow({ + usedPercent: fraction === null ? null : 100 - fraction * 100, + windowSeconds: label === "daily" ? 86_400 : 5 * 3600, + resetAt, + }), + }, + }, + } +} + +const google: UsageProvider = { + id: "google", + name: "Google", + aliases: ["google", "google.oauth", "gemini", "antigravity"], + async fetchQuota() { + const sources = googleSources() + if (sources.length === 0) return notConfigured(this.id, this.name) + const models: Record> }> = {} + try { + for (const source of sources) { + const accessToken = source.accessToken && (!source.expires || source.expires > Date.now()) + ? source.accessToken + : await refreshAccessToken(source) + if (!accessToken) continue + const projectId = source.projectId ?? DEFAULT_PROJECT_ID + if (source.id === "gemini") { + try { + const quota = await fetchJson(`${GOOGLE_ENDPOINTS[2]}/v1internal:retrieveUserQuota`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ project: projectId }), + }) + for (const bucket of Array.isArray(quota?.buckets) ? quota.buckets : []) { + const modelId = getString(bucket?.modelId) + if (modelId) Object.assign(models, transformModel(source.id, modelId, bucket)) + } + } catch { + // The model endpoint below often remains available when quota buckets are not. + } + } + for (const endpoint of GOOGLE_ENDPOINTS) { + try { + const payload = await fetchJson(`${endpoint}/v1internal:fetchAvailableModels`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity/1.11.5 windows/amd64", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }, + body: JSON.stringify({ project: projectId }), + }) + for (const [modelId, data] of Object.entries(payload?.models ?? {})) { + Object.assign(models, transformModel(source.id, modelId, data)) + } + break + } catch { + continue + } + } + } + if (Object.keys(models).length === 0) throw new Error("No model quota data available") + return result(this.id, this.name, { ok: true, configured: true, usage: { windows: {}, models } }) + } catch (error) { + return result(this.id, this.name, { + ok: false, + configured: true, + error: error instanceof Error ? error.message : "Request failed", + }) + } + }, +} + +export const googleProviders: UsageProvider[] = [google] diff --git a/packages/server/src/usage/providers/minimax.ts b/packages/server/src/usage/providers/minimax.ts new file mode 100644 index 00000000..dd24d9f9 --- /dev/null +++ b/packages/server/src/usage/providers/minimax.ts @@ -0,0 +1,116 @@ +import type { UsageProvider } from "../types" +import { fetchJson, getCredential, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared" + +const INACTIVE_WINDOW_STATUS = 3 + +function pickChatModel(models: any[]): any | null { + return ( + models.find((model) => /^minimax-m/i.test(String(model?.model_name ?? "")) && (toNumber(model?.current_interval_total_count) ?? 0) > 0) ?? + models.find((model) => ["general", "chat", "text"].includes(String(model?.model_name ?? "").toLowerCase())) ?? + models.find((model) => toNumber(model?.current_interval_remaining_percent) !== null) ?? + models[0] ?? + null + ) +} + +function isUsablePayload(payload: any): boolean { + if (payload?.base_resp && payload.base_resp.status_code !== 0) return false + return Array.isArray(payload?.model_remains) && payload.model_remains.length > 0 +} + +function usedPercent(model: any, prefix: "interval" | "weekly", tokenPlan: boolean): number | null { + const remainingPercent = toNumber(model?.[`current_${prefix}_remaining_percent`]) + if (remainingPercent !== null) return 100 - remainingPercent + const total = toNumber(model?.[`current_${prefix}_total_count`]) + const rawUsage = toNumber(model?.[`current_${prefix}_usage_count`]) + if (!total || rawUsage === null) return null + const used = tokenPlan ? total - rawUsage : rawUsage + return (Math.max(0, used) / total) * 100 +} + +function windowSeconds(model: any, prefix: "interval" | "weekly"): number | null { + const startAt = toTimestamp(prefix === "interval" ? model?.start_time : model?.weekly_start_time) + const resetAt = toTimestamp(prefix === "interval" ? model?.end_time : model?.weekly_end_time) + if (startAt !== null && resetAt !== null && resetAt > startAt) return Math.floor((resetAt - startAt) / 1000) + const remainsMs = toNumber(prefix === "interval" ? model?.remains_time : model?.weekly_remains_time) + return remainsMs && remainsMs > 0 ? Math.floor(remainsMs / 1000) : null +} + +function createMiniMaxProvider(input: { + id: string + name: string + aliases: readonly string[] + tokenPlanUrl: string + codingPlanUrl: string +}): UsageProvider { + return { + id: input.id, + name: input.name, + aliases: input.aliases, + async fetchQuota() { + const key = getCredential(input.aliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + try { + let tokenPlan = true + let payload: any = null + try { + const tokenPayload = await fetchJson(input.tokenPlanUrl, { headers: { Authorization: `Bearer ${key}` } }) + if (isUsablePayload(tokenPayload)) payload = tokenPayload + } catch { + // Fall back to the legacy Coding Plan endpoint below. + } + if (!payload) { + tokenPlan = false + const codingPayload = await fetchJson(input.codingPlanUrl, { headers: { Authorization: `Bearer ${key}` } }) + if (isUsablePayload(codingPayload)) payload = codingPayload + } + if (!payload) throw new Error("Provider returned no usable quota data") + const model = pickChatModel(Array.isArray(payload?.model_remains) ? payload.model_remains : []) + if (!model) throw new Error("No model quota data available") + const intervalResetAt = toTimestamp(model?.end_time) + const windows: Record> = { + "5h": toUsageWindow({ + usedPercent: usedPercent(model, "interval", tokenPlan), + windowSeconds: windowSeconds(model, "interval"), + resetAt: intervalResetAt, + }), + } + const weeklyStatus = toNumber(model?.current_weekly_status) + const hasWeekly = weeklyStatus !== INACTIVE_WINDOW_STATUS && ( + toNumber(model?.current_weekly_remaining_percent) !== null || (toNumber(model?.current_weekly_total_count) ?? 0) > 0 + ) + if (hasWeekly) { + windows.weekly = toUsageWindow({ + usedPercent: usedPercent(model, "weekly", tokenPlan), + windowSeconds: windowSeconds(model, "weekly"), + resetAt: toTimestamp(model?.weekly_end_time), + }) + } + return result(this.id, this.name, { ok: true, configured: true, usage: { windows } }) + } catch (error) { + return result(this.id, this.name, { + ok: false, + configured: true, + error: error instanceof Error ? error.message : "Request failed", + }) + } + }, + } +} + +export const miniMaxProviders: UsageProvider[] = [ + createMiniMaxProvider({ + id: "minimax-coding-plan", + name: "MiniMax Coding Plan", + aliases: ["minimax-coding-plan", "minimax"], + tokenPlanUrl: "https://api.minimax.io/v1/token_plan/remains", + codingPlanUrl: "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + }), + createMiniMaxProvider({ + id: "minimax-cn-coding-plan", + name: "MiniMax Coding Plan CN", + aliases: ["minimax-cn-coding-plan", "minimaxi"], + tokenPlanUrl: "https://api.minimaxi.com/v1/token_plan/remains", + codingPlanUrl: "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + }), +] diff --git a/packages/server/src/usage/providers/oauth.ts b/packages/server/src/usage/providers/oauth.ts new file mode 100644 index 00000000..8b64473e --- /dev/null +++ b/packages/server/src/usage/providers/oauth.ts @@ -0,0 +1,86 @@ +import type { UsageProvider } from "../types" +import { + fetchJson, + getAuthEntry, + getCredential, + notConfigured, + resolveWindowLabel, + safeFetch, + toNumber, + toTimestamp, + toUsageWindow, +} from "../shared" + +const codexAliases = ["openai", "codex", "chatgpt"] as const +const codex: UsageProvider = { + id: "codex", + name: "Codex", + aliases: codexAliases, + async fetchQuota() { + const entry = getAuthEntry(codexAliases) + const token = entry && (typeof entry.access === "string" ? entry.access : typeof entry.token === "string" ? entry.token : null) + if (!token) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const accountId = typeof entry?.accountId === "string" ? entry.accountId : null + const payload = await fetchJson("https://chatgpt.com/backend-api/wham/usage", { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), + }, + }) + const windows: Record> = {} + for (const source of [payload?.rate_limit?.primary_window, payload?.rate_limit?.secondary_window]) { + if (!source) continue + const seconds = toNumber(source.limit_window_seconds) + windows[resolveWindowLabel(seconds)] = toUsageWindow({ + usedPercent: toNumber(source.used_percent), + windowSeconds: seconds, + resetAt: toTimestamp(source.reset_at), + }) + } + return { windows } + }) + }, +} + +const copilotAliases = ["github-copilot", "copilot"] as const +const copilot: UsageProvider = { + id: "github-copilot", + name: "GitHub Copilot", + aliases: copilotAliases, + async fetchQuota() { + const token = getCredential(copilotAliases, ["access", "token"]) + if (!token) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload = await fetchJson("https://api.github.com/copilot_internal/user", { + headers: { + Authorization: `token ${token}`, + Accept: "application/json", + "Editor-Version": "vscode/1.96.2", + "X-Github-Api-Version": "2025-04-01", + }, + }) + const resetAt = toTimestamp(payload?.quota_reset_date) + const windows: Record> = {} + for (const [key, snapshot] of Object.entries({ + chat: payload?.quota_snapshots?.chat, + completions: payload?.quota_snapshots?.completions, + premium: payload?.quota_snapshots?.premium_interactions, + })) { + if (!snapshot) continue + const source = snapshot as Record + const entitlement = toNumber(source.entitlement) + const remaining = toNumber(source.remaining) + windows[key] = toUsageWindow({ + usedPercent: entitlement && remaining !== null ? 100 - (remaining / entitlement) * 100 : null, + resetAt, + valueLabel: entitlement !== null && remaining !== null ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)}` : null, + }) + } + return { windows } + }) + }, +} + +export const oauthProviders: UsageProvider[] = [codex, copilot] diff --git a/packages/server/src/usage/providers/special.ts b/packages/server/src/usage/providers/special.ts new file mode 100644 index 00000000..33e056d8 --- /dev/null +++ b/packages/server/src/usage/providers/special.ts @@ -0,0 +1,145 @@ +import type { UsageProvider } from "../types" +import { fetchJson, getString, notConfigured, safeFetch, toNumber, toTimestamp, toUsageWindow } from "../shared" + +function decodeJwtExpiration(token: string): number | null { + try { + const payload = token.split(".")[1] + if (!payload) return null + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) + return typeof parsed?.exp === "number" ? parsed.exp * 1000 : null + } catch { + return null + } +} + +async function resolveCursorToken(): Promise { + const accessToken = getString(process.env.CURSOR_ACCESS_TOKEN) ?? getString(process.env.CURSOR_TOKEN) + const refreshToken = getString(process.env.CURSOR_REFRESH_TOKEN) + const expiresAt = accessToken ? decodeJwtExpiration(accessToken) : null + if (accessToken && (!expiresAt || expiresAt > Date.now() + 5 * 60_000)) return accessToken + if (!refreshToken) return accessToken + const payload = await fetchJson("https://api2.cursor.sh/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "refresh_token", client_id: "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB", refresh_token: refreshToken }), + }) + return getString(payload?.access_token) +} + +async function cursorPost(url: string, token: string): Promise { + return fetchJson(url, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" }, + body: "{}", + }) +} + +const cursor: UsageProvider = { + id: "cursor", + name: "Cursor", + aliases: ["cursor"], + async fetchQuota() { + const token = await resolveCursorToken().catch(() => null) + if (!token) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const usage = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", token) + const plan = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetPlanInfo", token).catch(() => null) + const source = usage?.planUsage ?? {} + const limit = toNumber(source.limit) + const remaining = toNumber(source.remaining) + const explicit = toNumber(source.totalPercentUsed) + const resetAt = toTimestamp(usage?.billingCycleEnd ?? plan?.planInfo?.billingCycleEnd) + return { + windows: { + billing_cycle: toUsageWindow({ + usedPercent: explicit ?? (limit && remaining !== null ? ((limit - remaining) / limit) * 100 : null), + resetAt, + valueLabel: toNumber(source.totalSpend) !== null ? `$${(toNumber(source.totalSpend)! / 100).toFixed(2)}` : null, + }), + }, + } + }) + }, +} + +function parseOllamaUsage(html: string) { + const windows: Record> = {} + for (const [label, pattern] of [ + ["session", /Session\s+usage[^0-9]*([0-9.]+)%/i], + ["weekly", /Weekly\s+usage[^0-9]*([0-9.]+)%/i], + ] as const) { + const match = html.match(pattern) + if (match) windows[label] = toUsageWindow({ usedPercent: toNumber(match[1]) }) + } + const premium = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i) + if (premium) { + const used = toNumber(premium[1]) + const total = toNumber(premium[2]) + windows.premium = toUsageWindow({ + usedPercent: total && used !== null ? (used / total) * 100 : null, + valueLabel: used !== null && total !== null ? `${used} / ${total}` : null, + }) + } + return windows +} + +const ollamaCloud: UsageProvider = { + id: "ollama-cloud", + name: "Ollama Cloud", + aliases: ["ollama-cloud", "ollamacloud"], + async fetchQuota() { + const cookie = getString(process.env.OLLAMA_CLOUD_COOKIE) + if (!cookie) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const response = await fetch("https://ollama.com/settings", { + headers: { Cookie: cookie, "User-Agent": "CodeNomad usage provider" }, + redirect: "manual", + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`) + const windows = parseOllamaUsage(await response.text()) + if (Object.keys(windows).length === 0) throw new Error("No usage data available") + return { windows } + }) + }, +} + +function parseOpenCodeGoUsage(html: string, now = Date.now()) { + const normalized = html.replace(/"|"|\\u0022|\\"/g, '"') + const windows: Record> = {} + for (const [label, field] of Object.entries({ "5h": "rollingUsage", weekly: "weeklyUsage", monthly: "monthlyUsage" })) { + const match = normalized.match(new RegExp(`["']?${field}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, "s")) + if (!match) continue + const capture = (name: string) => toNumber(match[1]?.match(new RegExp(`["']?${name}["']?\\s*:\\s*["']?(-?\\d+(?:\\.\\d+)?)`))?.[1]) + const usedPercent = capture("usagePercent") + const resetInSec = capture("resetInSec") + if (usedPercent !== null && resetInSec !== null) { + windows[label] = toUsageWindow({ usedPercent, resetAt: now + Math.max(0, resetInSec) * 1000 }) + } + } + return windows +} + +const openCodeGo: UsageProvider = { + id: "opencode-go", + name: "OpenCode Go", + aliases: ["opencode-go", "opencode"], + async fetchQuota() { + const workspaceId = getString(process.env.OPENCODE_GO_WORKSPACE_ID) + const authCookie = getString(process.env.OPENCODE_GO_AUTH_COOKIE) + if (!workspaceId || !authCookie) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`, { + headers: { Cookie: `auth=${authCookie.replace(/^auth=/, "")}`, "User-Agent": "CodeNomad usage provider" }, + redirect: "manual", + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`) + const windows = parseOpenCodeGoUsage(await response.text()) + if (Object.keys(windows).length === 0) throw new Error("No usage data available") + return { windows } + }) + }, +} + +export const specialProviders: UsageProvider[] = [cursor, ollamaCloud, openCodeGo] diff --git a/packages/server/src/usage/service.test.ts b/packages/server/src/usage/service.test.ts new file mode 100644 index 00000000..2acc1d24 --- /dev/null +++ b/packages/server/src/usage/service.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { getProviderUsage, resolveUsageProvider, selectModelWindows } from "./service" +import type { ProviderResult } from "./types" + +const providerIds = [ + "codex", + "github-copilot", + "google", + "kimi-for-coding", + "nano-gpt", + "openrouter", + "zai-coding-plan", + "zhipuai-coding-plan", + "minimax-coding-plan", + "minimax-cn-coding-plan", + "ollama-cloud", + "wafer", + "opencode-go", + "cursor", +] + +test("registers every supported usage provider", () => { + for (const providerId of providerIds) { + assert.equal(resolveUsageProvider(providerId)?.id, providerId) + } +}) + +test("maps OpenCode provider aliases to their usage provider", () => { + assert.equal(resolveUsageProvider("openai")?.id, "codex") + assert.equal(resolveUsageProvider("copilot")?.id, "github-copilot") + assert.equal(resolveUsageProvider("gemini")?.id, "google") + assert.equal(resolveUsageProvider("opencode")?.id, "opencode-go") +}) + +test("does not use Claude subscription OAuth credentials", () => { + assert.equal(resolveUsageProvider("anthropic"), null) + assert.equal(resolveUsageProvider("claude"), null) +}) + +test("selects model-specific windows by normalized model id", () => { + const result: ProviderResult = { + providerId: "google", + providerName: "Google", + ok: true, + configured: true, + fetchedAt: 1, + usage: { + windows: {}, + models: { + "gemini/gemini-2.5-flash": { + windows: { + daily: { usedPercent: 25, remainingPercent: 75, windowSeconds: 86_400, resetAt: null }, + }, + }, + }, + }, + } + + assert.equal(selectModelWindows(result, "models/gemini-2.5-flash").daily?.usedPercent, 25) +}) + +test("returns a stable unsupported response without making a provider request", async () => { + const response = await getProviderUsage("unknown-provider", { modelId: "model" }) + assert.equal(response.supported, false) + assert.equal(response.configured, false) + assert.equal(response.providerId, null) + assert.deepEqual(response.windows, {}) +}) diff --git a/packages/server/src/usage/service.ts b/packages/server/src/usage/service.ts new file mode 100644 index 00000000..3601f97e --- /dev/null +++ b/packages/server/src/usage/service.ts @@ -0,0 +1,91 @@ +import type { ProviderUsageResponse, ProviderUsageWindow } from "../api-types" +import { apiKeyProviders } from "./providers/api-key" +import { googleProviders } from "./providers/google" +import { miniMaxProviders } from "./providers/minimax" +import { oauthProviders } from "./providers/oauth" +import { specialProviders } from "./providers/special" +import type { ProviderResult, UsageProvider } from "./types" + +const CACHE_TTL_MS = 60_000 +const providers = [...oauthProviders, ...apiKeyProviders, ...miniMaxProviders, ...googleProviders, ...specialProviders] +const registry = new Map() + +for (const provider of providers) { + registry.set(provider.id, provider) + for (const alias of provider.aliases) registry.set(alias.toLowerCase(), provider) +} + +const cache = new Map() +const pending = new Map>() + +export function resolveUsageProvider(providerId: string): UsageProvider | null { + return registry.get(providerId.trim().toLowerCase()) ?? null +} + +function normalizeModelId(value: string): string { + return value.toLowerCase().replace(/^models\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") +} + +export function selectModelWindows(result: ProviderResult, modelId?: string): Record { + const usage = result.usage + if (!usage) return {} + if (!modelId || !usage.models) return usage.windows + const target = normalizeModelId(modelId) + const entries = Object.entries(usage.models) + const exact = entries.find(([name]) => normalizeModelId(name.split("/").pop() ?? name) === target) + if (exact) return exact[1].windows + const partial = entries.find(([name]) => { + const candidate = normalizeModelId(name.split("/").pop() ?? name) + return candidate.includes(target) || target.includes(candidate) + }) + return partial?.[1].windows ?? usage.windows +} + +async function fetchProvider(provider: UsageProvider): Promise { + const cached = cache.get(provider.id) + if (cached && cached.expiresAt > Date.now()) return cached.result + const inFlight = pending.get(provider.id) + if (inFlight) return inFlight + const request = provider.fetchQuota().then((result) => { + cache.set(provider.id, { result, expiresAt: Date.now() + CACHE_TTL_MS }) + return result + }).finally(() => pending.delete(provider.id)) + pending.set(provider.id, request) + return request +} + +export async function getProviderUsage( + requestedProviderId: string, + options: { modelId?: string } = {}, +): Promise { + const provider = resolveUsageProvider(requestedProviderId) + if (!provider) { + return { + requestedProviderId, + providerId: null, + providerName: requestedProviderId, + modelId: options.modelId, + supported: false, + configured: false, + ok: false, + windows: {}, + fetchedAt: Date.now(), + } + } + const result = await fetchProvider(provider) + return { + requestedProviderId, + providerId: provider.id, + providerName: result.providerName, + modelId: options.modelId, + supported: true, + configured: result.configured, + ok: result.ok, + windows: selectModelWindows(result, options.modelId), + fetchedAt: result.fetchedAt, + } +} + +export function clearProviderUsageCache(): void { + cache.clear() +} diff --git a/packages/server/src/usage/shared.test.ts b/packages/server/src/usage/shared.test.ts new file mode 100644 index 00000000..c9fc67d1 --- /dev/null +++ b/packages/server/src/usage/shared.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import test from "node:test" + +import { getCredential, readOpenCodeAuth, toTimestamp, toUsageWindow } from "./shared" + +test("reads the explicit OpenCode auth file without exposing credentials through the API", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-usage-")) + const authFile = path.join(directory, "auth.json") + const previous = process.env.OPENCODE_AUTH_FILE + fs.writeFileSync(authFile, JSON.stringify({ openai: { type: "oauth", access: "secret-token" } })) + process.env.OPENCODE_AUTH_FILE = authFile + + try { + assert.equal((readOpenCodeAuth().openai as Record).access, "secret-token") + assert.equal(getCredential(["openai"], ["access"]), "secret-token") + } finally { + if (previous === undefined) delete process.env.OPENCODE_AUTH_FILE + else process.env.OPENCODE_AUTH_FILE = previous + fs.rmSync(directory, { recursive: true, force: true }) + } +}) + +test("normalizes percentages and timestamps", () => { + assert.deepEqual(toUsageWindow({ usedPercent: 120, resetAt: null }), { + usedPercent: 100, + remainingPercent: 0, + windowSeconds: null, + resetAt: null, + }) + assert.equal(toTimestamp(1_700_000_000), 1_700_000_000_000) +}) diff --git a/packages/server/src/usage/shared.ts b/packages/server/src/usage/shared.ts new file mode 100644 index 00000000..958ca31c --- /dev/null +++ b/packages/server/src/usage/shared.ts @@ -0,0 +1,148 @@ +import fs from "fs" +import os from "os" +import path from "path" + +import type { ProviderUsageWindow } from "../api-types" +import type { AuthEntry, AuthFile, ProviderResult, ProviderUsage } from "./types" + +const REQUEST_TIMEOUT_MS = 15_000 + +function authFileCandidates(): string[] { + const home = os.homedir() + const candidates = [ + process.env.OPENCODE_AUTH_FILE, + process.env.OPENCODE_DATA_DIR ? path.join(process.env.OPENCODE_DATA_DIR, "auth.json") : undefined, + process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, "opencode", "auth.json") : undefined, + path.join(home, ".local", "share", "opencode", "auth.json"), + process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "opencode", "Data", "auth.json") : undefined, + process.env.APPDATA ? path.join(process.env.APPDATA, "opencode", "auth.json") : undefined, + path.join(home, "Library", "Application Support", "opencode", "auth.json"), + ] + return Array.from(new Set(candidates.filter((candidate): candidate is string => Boolean(candidate)))) +} + +export function readOpenCodeAuth(): AuthFile { + for (const candidate of authFileCandidates()) { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(candidate, "utf8")) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as AuthFile + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue + } + } + return {} +} + +export function getAuthEntry(aliases: readonly string[]): AuthEntry | null { + const auth = readOpenCodeAuth() + for (const alias of aliases) { + const value = auth[alias] + if (typeof value === "string" && value.trim()) return { token: value.trim() } + if (value && typeof value === "object" && !Array.isArray(value)) return value as AuthEntry + } + return null +} + +export function getString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null +} + +export function getCredential(aliases: readonly string[], fields: readonly string[]): string | null { + const entry = getAuthEntry(aliases) + if (!entry) return null + for (const field of fields) { + const value = getString(entry[field]) + if (value) return value + } + return null +} + +export function asObject(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null +} + +export function toNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +export function toTimestamp(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value < 1_000_000_000_000 ? value * 1000 : value + if (typeof value === "string" && value.trim()) { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +export function toUsageWindow(input: { + usedPercent: number | null + windowSeconds?: number | null + resetAt?: number | null + valueLabel?: string | null +}): ProviderUsageWindow { + const usedPercent = input.usedPercent === null ? null : Math.max(0, Math.min(100, input.usedPercent)) + return { + usedPercent, + remainingPercent: usedPercent === null ? null : 100 - usedPercent, + windowSeconds: input.windowSeconds ?? null, + resetAt: input.resetAt ?? null, + ...(input.valueLabel ? { valueLabel: input.valueLabel } : {}), + } +} + +export function resolveWindowLabel(windowSeconds: number | null): string { + if (!windowSeconds) return "usage" + if (windowSeconds % 86_400 === 0) { + const days = windowSeconds / 86_400 + return days === 7 ? "weekly" : `${days}d` + } + if (windowSeconds % 3600 === 0) return `${windowSeconds / 3600}h` + return `${windowSeconds}s` +} + +export function result( + providerId: string, + providerName: string, + input: { ok: boolean; configured: boolean; usage?: ProviderUsage; error?: string }, +): ProviderResult { + return { + providerId, + providerName, + ok: input.ok, + configured: input.configured, + usage: input.usage ?? null, + fetchedAt: Date.now(), + ...(input.error ? { error: input.error } : {}), + } +} + +export function notConfigured(providerId: string, providerName: string): ProviderResult { + return result(providerId, providerName, { ok: false, configured: false, error: "Not configured" }) +} + +export async function fetchJson(url: string, init: RequestInit = {}): Promise { + const response = await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) + if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`) + return response.json() +} + +export async function safeFetch( + providerId: string, + providerName: string, + operation: () => Promise, +): Promise { + try { + return result(providerId, providerName, { ok: true, configured: true, usage: await operation() }) + } catch (error) { + return result(providerId, providerName, { + ok: false, + configured: true, + error: error instanceof Error ? error.message : "Request failed", + }) + } +} diff --git a/packages/server/src/usage/types.ts b/packages/server/src/usage/types.ts new file mode 100644 index 00000000..60a59f5a --- /dev/null +++ b/packages/server/src/usage/types.ts @@ -0,0 +1,26 @@ +import type { ProviderUsageWindow } from "../api-types" + +export interface ProviderUsage { + windows: Record + models?: Record }> +} + +export interface ProviderResult { + providerId: string + providerName: string + ok: boolean + configured: boolean + usage: ProviderUsage | null + fetchedAt: number + error?: string +} + +export interface UsageProvider { + id: string + name: string + aliases: readonly string[] + fetchQuota: () => Promise +} + +export type AuthEntry = Record +export type AuthFile = Record diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index a5fb2eda..d11d8a66 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" import { describe, it } from "node:test" -import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn" +import { buildWindowsSpawnSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn" describe("parseWslUncPath", () => { it("parses WSL UNC paths into distro and linux path", () => { @@ -47,6 +50,70 @@ describe("resolveWslWorkingDirectory", () => { }) describe("buildWindowsSpawnSpec", () => { + it("classifies native executables separately from script and shell wrappers", () => { + assert.equal(buildWindowsSpawnSpec("opencode.exe", []).processKind, "windows-direct") + assert.equal(buildWindowsSpawnSpec("opencode.cmd", []).processKind, "windows-wrapper") + assert.equal(buildWindowsSpawnSpec("powershell.exe", []).processKind, "windows-wrapper") + }) + + it("conservatively classifies bare commands as wrappers", () => { + assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper") + }) + + it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", { skip: process.platform !== "win32" }, () => { + const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-")) + const cwd = path.join(root, "workspace") + const bin = path.join(root, "bin with spaces") + mkdirSync(cwd) + mkdirSync(bin) + const shim = path.join(bin, "opencode.cmd") + writeFileSync(shim, "@echo off\r\n") + + try { + const spec = buildWindowsSpawnSpec("opencode", ["serve"], { + cwd, + env: { Path: `"${bin}"`, PathExt: ".CMD;.EXE", ComSpec: "test-cmd.exe" }, + }) + + assert.equal(spec.command, "test-cmd.exe") + assert.equal(spec.processKind, "windows-wrapper") + assert.equal(spec.options.windowsVerbatimArguments, true) + assert.match(spec.args[3] ?? "", new RegExp(escapeRegex(path.win32.resolve(shim)), "i")) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it("honors PATHEXT precedence when both native and shim files exist", { skip: process.platform !== "win32" }, () => { + const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-")) + writeFileSync(path.join(root, "opencode.cmd"), "@echo off\r\n") + writeFileSync(path.join(root, "opencode.exe"), "") + + try { + const spec = buildWindowsSpawnSpec("opencode", [], { + cwd: root, + env: { PATH: "", PATHEXT: ".EXE;.CMD" }, + }) + + assert.equal(spec.command.toLowerCase(), path.win32.resolve(root, "opencode.exe").toLowerCase()) + assert.equal(spec.processKind, "windows-direct") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it("leaves an unresolved bare command unchanged without injecting a shell", () => { + const spec = buildWindowsSpawnSpec("missing-opencode", ["serve"], { + cwd: String.raw`C:\missing-workspace`, + env: { PATH: "", PATHEXT: ".CMD;.EXE", ComSpec: "must-not-run.exe" }, + }) + + assert.equal(spec.command, "missing-opencode") + assert.deepEqual(spec.args, ["serve"]) + assert.equal(spec.processKind, "windows-wrapper") + assert.equal(spec.options.windowsVerbatimArguments, undefined) + }) + it("wraps WSL binaries with wsl.exe and propagates required env vars", () => { const spec = buildWindowsSpawnSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, @@ -200,7 +267,7 @@ describe("buildWindowsSpawnSpec", () => { "--exec", "sh", "-lc", - `printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`, + `codenomad_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d '[:space:]'); codenomad_start=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null); codenomad_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null); test -n "$codenomad_pgid" && test -n "$codenomad_start" && test -n "$codenomad_boot" && printf '%s%s:%s:%s:%s\\n' '__CODENOMAD_WSL_PID__:' "$$" "$codenomad_pgid" "$codenomad_start" "$codenomad_boot" && cd "$1" && shift && exec "$@"`, "codenomad-wsl-launch", "/home/dev/workspace", "/home/dev/.opencode/bin/opencode", @@ -209,10 +276,8 @@ describe("buildWindowsSpawnSpec", () => { assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:") }) - it("builds the WSL kill command for tracked Linux PIDs", () => { - const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM") - - assert.equal(spec.command, "wsl.exe") - assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"]) - }) }) + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts new file mode 100644 index 00000000..ccf8e6ca --- /dev/null +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict" +import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import pino from "pino" + +import { EventBus } from "../../events/bus" +import { WorkspaceManager } from "../manager" +import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity" + +const temporaryDirectories: string[] = [] +const runtimeResult = (pid = 123) => ({ + pid, + port: 4321, + exitPromise: new Promise(() => undefined), + getLastOutput: () => "", +}) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))) +}) + +async function createLinkedWorkspace() { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-identity-")) + temporaryDirectories.push(root) + const target = path.join(root, "target") + const link = path.join(root, "link") + await mkdir(target) + await symlink(target, link, process.platform === "win32" ? "junction" : "dir") + return { root, target, link } +} + +function createManager(rootDir: string) { + const logger = pino({ level: "silent" }) + const manager = new WorkspaceManager({ + rootDir, + settings: { getOwner: () => ({ environmentVariables: {} }) }, + binaryResolver: { resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }) }, + eventBus: new EventBus(logger), + logger, + getServerBaseUrl: () => "http://127.0.0.1:3000", + } as unknown as ConstructorParameters[0]) + ;(manager as any).runtime.launch = async () => runtimeResult() + ;(manager as any).runtime.stop = async () => undefined + ;(manager as any).waitForWorkspaceReadiness = async () => undefined + return manager +} + +async function waitForOwners(manager: WorkspaceManager, count: number) { + while ([...(manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== count) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +async function createSharedLaunch() { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const launchGate = deferred() + let launches = 0 + ;(manager as any).runtime.launch = async () => { + launches += 1 + await launchGate.promise + return runtimeResult() + } + const leader = manager.create(target, undefined, { requestId: "leader" }) + const follower = manager.create(link, undefined, { requestId: "follower" }) + await waitForOwners(manager, 2) + return { manager, launchGate, leader, follower, launches: () => launches } +} + +describe("workspace identity", () => { + it("normalizes Windows paths without affecting POSIX case", () => { + assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\") + assert.equal(normalizeWorkspaceIdentityPath(String.raw`\\Server\Share\Repo`, "win32"), String.raw`\\server\share\repo`) + assert.equal(normalizeWorkspaceIdentityPath("/Projects/CodeNomad/", "linux"), "/Projects/CodeNomad/") + }) + + it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { + const { root, target, link } = await createLinkedWorkspace() + const [targetResult, linkResult, missing] = await Promise.all([ + resolveWorkspaceIdentity(target, root), + resolveWorkspaceIdentity(link, root), + resolveWorkspaceIdentity("missing", root), + ]) + const expectedMissing = path.resolve(root, "missing") + + assert.equal(linkResult.identityKey, targetResult.identityKey) + assert.equal(linkResult.workspacePath, targetResult.workspacePath) + assert.equal(missing.workspacePath, expectedMissing) + assert.equal(missing.identityKey, normalizeWorkspaceIdentityPath(expectedMissing)) + }) + + it("deduplicates active canonical aliases", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const [first, second] = await Promise.all([manager.create(target), manager.create(link)]) + + assert.equal(Number(first.created) + Number(second.created), 1) + assert.equal(first.workspace.id, second.workspace.id) + assert.equal(manager.list().length, 1) + }) + + it("shares one in-flight launch between canonical aliases", async () => { + const shared = await createSharedLaunch() + shared.launchGate.resolve() + const [leader, follower] = await Promise.all([shared.leader, shared.follower]) + + assert.equal(shared.launches(), 1) + assert.equal(leader.workspace.id, follower.workspace.id) + assert.equal(Number(leader.created) + Number(follower.created), 1) + assert.equal(leader.workspace.status, "ready") + }) + + for (const cancelledRole of ["leader", "follower"] as const) { + it(`detaches a cancelled ${cancelledRole} without stopping its shared owner`, async () => { + const shared = await createSharedLaunch() + await shared.manager.cancelCreationRequest(cancelledRole) + shared.launchGate.resolve() + const cancelled = shared[cancelledRole] + const survivor = shared[cancelledRole === "leader" ? "follower" : "leader"] + + await assert.rejects(cancelled, new RegExp(`creation request ${cancelledRole} was cancelled`)) + const result = await survivor + assert.equal(shared.launches(), 1) + assert.equal(result.workspace.requestId, cancelledRole === "leader" ? "follower" : "leader") + assert.equal(shared.manager.list().length, 1) + }) + } + + for (const releasedRole of ["leader", "follower"] as const) { + it(`retains shared ownership when the ${releasedRole} releases and the other owner cancels`, async () => { + const shared = await createSharedLaunch() + shared.launchGate.resolve() + const [leader, follower] = await Promise.all([shared.leader, shared.follower]) + assert.equal(leader.workspace.id, follower.workspace.id) + + assert.equal(shared.manager.releaseCreationRequest(leader.workspace.id, releasedRole), true) + await shared.manager.cancelCreationRequest(releasedRole === "leader" ? "follower" : "leader") + assert.equal(shared.manager.list().length, 1) + assert.equal(shared.manager.get(leader.workspace.id)?.requestId, undefined) + }) + } + + it("releases a failed canonical reservation for retry", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const launchGate = deferred() + let launches = 0 + ;(manager as any).runtime.launch = async () => { + launches += 1 + await launchGate.promise + throw new Error("launch failed") + } + const failures = [ + manager.create(target, undefined, { requestId: "first" }), + manager.create(link, undefined, { requestId: "second" }), + ] + await waitForOwners(manager, 2) + launchGate.resolve() + assert.deepEqual((await Promise.allSettled(failures)).map((result) => result.status), ["rejected", "rejected"]) + assert.equal(launches, 1) + + ;(manager as any).runtime.launch = async () => runtimeResult(456) + assert.equal((await manager.create(target)).created, true) + }) + + it("allows forced canonical duplicates without replacing the reusable workspace", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const normal = await manager.create(target) + const forced = await manager.create(link, undefined, { forceNew: true }) + assert.notEqual(normal.workspace.id, forced.workspace.id) + + await manager.delete(forced.workspace.id) + const reused = await manager.create(link) + assert.equal(reused.created, false) + assert.equal(reused.workspace.id, normal.workspace.id) + }) +}) diff --git a/packages/server/src/workspaces/binary-path.test.ts b/packages/server/src/workspaces/binary-path.test.ts new file mode 100644 index 00000000..9f748034 --- /dev/null +++ b/packages/server/src/workspaces/binary-path.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { binaryPathsEqual } from "./manager" + +test("matches regular Windows binary paths without case sensitivity", () => { + assert.equal(binaryPathsEqual(String.raw`C:\Tools\OpenCode.cmd`, String.raw`c:\tools\opencode.CMD`, "win32"), true) +}) + +test("matches WSL distro names without changing Linux path casing", () => { + assert.equal( + binaryPathsEqual( + String.raw`\\wsl.localhost\Ubuntu\home\dev\OpenCode`, + String.raw`\\wsl$\ubuntu\home\dev\OpenCode`, + "win32", + ), + true, + ) + assert.equal( + binaryPathsEqual( + String.raw`\\wsl.localhost\Ubuntu\home\dev\OpenCode`, + String.raw`\\wsl$\ubuntu\home\dev\opencode`, + "win32", + ), + false, + ) +}) diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts new file mode 100644 index 00000000..59607c92 --- /dev/null +++ b/packages/server/src/workspaces/instance-client.ts @@ -0,0 +1,55 @@ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { WorkspaceManager } from "./manager" + +const INSTANCE_HOST = "127.0.0.1" +const LOOPBACK_TIMEOUT_MS = 10_000 + +interface InstanceClientOptions { + timeoutMs?: number +} + +/** + * Creates an OpenCode SDK client for direct loopback communication with a + * running workspace instance. + * + * Routes and body shapes come from the auto-generated SDK contract + * (`@opencode-ai/sdk`), eliminating handwritten URL construction that can + * drift between SDK versions. Other server modules that need to call the + * OpenCode instance directly should use this factory rather than building + * `http://127.0.0.1:{port}/...` URLs by hand. + * + * All requests carry a 10-second timeout — loopback calls should be + * near-instant; a hang indicates a stuck instance. + * + * The client is cheap to create (object only, no connection); create one per + * call or cache per instance as needed. Returns `null` when the instance has + * no open port yet. + */ +export function createInstanceClient( + workspaceManager: WorkspaceManager, + instanceId: string, + options: InstanceClientOptions = {}, +): OpencodeClient | null { + const port = workspaceManager.getInstancePort(instanceId) + if (!port) return null + + const headers: Record = {} + const authorization = workspaceManager.getInstanceAuthorizationHeader(instanceId) + if (authorization) { + headers.authorization = authorization + } + + const workspace = workspaceManager.get(instanceId) + const timeoutMs = options.timeoutMs ?? LOOPBACK_TIMEOUT_MS + + return createOpencodeClient({ + baseUrl: `http://${INSTANCE_HOST}:${port}/`, + headers, + fetch: (url, init) => + fetch(url, { + ...(init as RequestInit), + signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(timeoutMs), + }), + ...(workspace?.path ? { directory: workspace.path } : {}), + }) +} diff --git a/packages/server/src/workspaces/launch-cleanup.test.ts b/packages/server/src/workspaces/launch-cleanup.test.ts new file mode 100644 index 00000000..2a2f34f8 --- /dev/null +++ b/packages/server/src/workspaces/launch-cleanup.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import { spawnSync, type SpawnSyncReturns } from "node:child_process" +import { describe, it } from "node:test" +import { LAUNCH_CLEANUP_TOKEN_ENV, probeLaunchCleanupToken, signalLaunchCleanupToken } from "./process-identity" + +type Spawn = typeof import("node:child_process").spawnSync +const result = (stdout: string): SpawnSyncReturns => ({ pid: 1, output: [null, stdout, ""], stdout, stderr: "", status: 0, signal: null }) + +describe("launch cleanup token adapter", () => { + it("passes the exact token to the bounded Linux environ probe", () => { + const token = "a".repeat(64), calls: any[] = [] + const run = ((command: string, args: string[], options: object) => { calls.push(command, args, options); return result("CODENOMAD_PROCESS|5000|1|4242|150|boot-a|150\n") }) as unknown as Spawn + const probe = probeLaunchCleanupToken(run, token, 25) + assert.equal(probe.ok && probe.processes.get(5000)?.startOrder, "150") + assert.equal(calls[0], "sh") + assert.deepEqual([calls[2].timeout, calls[1].includes(LAUNCH_CLEANUP_TOKEN_ENV), calls[1].includes(token)], [25, true, true]) + assert.match(calls[1][1], /\/proc\/\$1\/environ/) + assert.doesNotMatch(calls[1][1], /\bseq\b/) + }) + + it("executes a successful empty Linux token probe", { skip: process.platform !== "linux" }, () => { + const probe = probeLaunchCleanupToken(spawnSync, "f".repeat(64), 1_000) + assert.deepEqual(probe, { ok: true, processes: new Map() }) + }) + + it("signals every exact-token target and rejects malformed records", () => { + const rows = "CODENOMAD_TARGET|4242|1|4242|100|boot-a|100\nCODENOMAD_TARGET|5000|1|4242|150|boot-a|150\nCODENOMAD_RESULT|1\n" + const run = ((() => result(rows)) as unknown) as Spawn + const cleanup = signalLaunchCleanupToken(run, "b".repeat(64), "SIGKILL", 25) + assert.deepEqual([cleanup.ok, cleanup.targets.map(({ pid }) => pid)], [true, [4242, 5000]]) + const malformed = ((() => result("5000|1|4242|150|boot-a|150|truncated\n")) as unknown) as Spawn + assert.equal(probeLaunchCleanupToken(malformed, "c".repeat(64), 25).ok, false) + }) +}) diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts new file mode 100644 index 00000000..7b9a66e4 --- /dev/null +++ b/packages/server/src/workspaces/manager.test.ts @@ -0,0 +1,341 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import pino from "pino" + +import { EventBus } from "../events/bus" +import { + WorkspaceWindowsTreeCleanupIncompleteError, + type ProcessExitInfo, + type WorkspaceRuntime, +} from "./runtime" +import { + WorkspaceCleanupTimeoutError, + WorkspaceLaunchCancelledError, + WorkspaceLaunchTimeoutError, + WorkspaceManager, + WorkspaceShutdownError, +} from "./manager" + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +class ControlledRuntime { + readonly launchResult = deferred>>() + readonly launchCalled = deferred() + readonly active = new Set() + stopCalls = 0 + failStops = 0 + onExit?: (info: ProcessExitInfo) => void + + launch: WorkspaceRuntime["launch"] = (options) => { + this.active.add(options.workspaceId) + this.onExit = options.onExit + this.launchCalled.resolve(options.workspaceId) + options.signal?.addEventListener("abort", () => this.launchResult.reject(options.signal?.reason), { once: true }) + return this.launchResult.promise + } + + stop: WorkspaceRuntime["stop"] = async (workspaceId) => { + this.stopCalls += 1 + if (this.failStops-- > 0) throw new Error("controlled stop failure") + this.active.delete(workspaceId) + } + + resolveLaunch(): void { + this.launchResult.resolve({ + pid: 1234, + port: 4321, + exitPromise: new Promise(() => undefined), + getLastOutput: () => "", + }) + } +} + +function createHarness(options: { + shutdownTimeoutMs?: number + launchTimeoutMs?: number + setTimeout?: (callback: () => void, delayMs: number) => ReturnType + clearTimeout?: (timer: ReturnType) => void +} = {}) { + const eventBus = new EventBus() + const runtime = new ControlledRuntime() + const readiness = deferred() + const started: string[] = [] + const stopped: string[] = [] + eventBus.on("workspace.started", (event) => started.push(event.workspace.id)) + eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId)) + const manager = new WorkspaceManager({ + rootDir: process.cwd(), + settings: { getOwner: () => ({}) } as never, + binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } as never, + eventBus, + logger: pino({ level: "silent" }), + getServerBaseUrl: () => "http://127.0.0.1:4000", + runtime, + ...options, + }) + ;(manager as any).waitForWorkspaceReadiness = ({ signal }: { signal?: AbortSignal }) => Promise.race([ + readiness.promise, + new Promise((_resolve, reject) => { + const cancel = () => reject(signal?.reason) + signal?.addEventListener("abort", cancel, { once: true }) + if (signal?.aborted) cancel() + }), + ]) + return { manager, runtime, readiness, started, stopped } +} + +async function createReady(harness: ReturnType) { + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + await creation + return workspaceId +} + +describe("workspace manager lifecycle", () => { + for (const boundary of ["launch", "readiness", "shutdown"] as const) { + it(`cancels and cleans a workspace during ${boundary}`, async () => { + const harness = createHarness() + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + let cleanup: Promise + if (boundary === "readiness") { + harness.runtime.resolveLaunch() + await new Promise((resolve) => setImmediate(resolve)) + cleanup = harness.manager.delete(workspaceId) + } else { + cleanup = boundary === "shutdown" ? harness.manager.shutdown() : harness.manager.delete(workspaceId) + harness.runtime.resolveLaunch() + } + + await assert.rejects(creation, WorkspaceLaunchCancelledError) + await cleanup + assert.deepEqual([harness.runtime.active.size, harness.started, harness.manager.list(), harness.stopped], + [0, [], [], boundary === "readiness" ? [workspaceId] : []]) + }) + } + + it("shares failed cleanup and allows a later delete retry", async () => { + const harness = createHarness() + const workspaceId = await createReady(harness) + harness.runtime.failStops = 2 + + const first = harness.manager.delete(workspaceId) + const concurrent = harness.manager.delete(workspaceId) + assert.strictEqual(first, concurrent) + const failures = await Promise.allSettled([first, concurrent]) + assert.deepEqual(failures.map((result) => result.status), ["rejected", "rejected"]) + assert.equal(harness.runtime.active.has(workspaceId), true) + + await harness.manager.delete(workspaceId) + assert.equal(harness.runtime.active.has(workspaceId), false) + assert.equal(harness.manager.get(workspaceId), undefined) + }) + + it("retries cancellation deletion for an already-cancelled request", async () => { + const harness = createHarness() + const creation = harness.manager.create(process.cwd(), undefined, { requestId: "retry-cancel" }) + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + await creation + harness.runtime.failStops = 2 + + await assert.rejects(harness.manager.cancelCreationRequest("retry-cancel"), /controlled stop failure/) + assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) + assert.equal(harness.runtime.active.has(workspaceId), true) + + await harness.manager.cancelCreationRequest("retry-cancel") + assert.equal(harness.manager.get(workspaceId), undefined) + assert.equal(harness.runtime.active.has(workspaceId), false) + assert.deepEqual(harness.stopped, [workspaceId]) + }) + + it("gives release and cancellation one terminal winner", async () => { + const cancelled = createHarness() + const cancelledCreation = cancelled.manager.create(process.cwd(), undefined, { requestId: "cancel-wins" }) + const cancelledId = await cancelled.runtime.launchCalled.promise + cancelled.runtime.resolveLaunch() + cancelled.readiness.resolve(undefined) + await cancelledCreation + const stopStarted = deferred() + const finishStop = deferred() + const originalStop = cancelled.runtime.stop + cancelled.runtime.stop = async (workspaceId) => { + stopStarted.resolve() + await finishStop.promise + await originalStop(workspaceId) + } + + const cancellation = cancelled.manager.cancelCreationRequest("cancel-wins") + await stopStarted.promise + assert.equal(cancelled.manager.releaseCreationRequest(cancelledId, "cancel-wins"), false) + assert.equal(cancelled.manager.get(cancelledId)?.id, cancelledId) + finishStop.resolve() + await cancellation + assert.equal(cancelled.manager.get(cancelledId), undefined) + + const released = createHarness() + const releasedCreation = released.manager.create(process.cwd(), undefined, { requestId: "release-wins" }) + const releasedId = await released.runtime.launchCalled.promise + released.runtime.resolveLaunch() + released.readiness.resolve(undefined) + await releasedCreation + + assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true) + await released.manager.cancelCreationRequest("release-wins") + assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true) + assert.equal(released.manager.get(releasedId)?.id, releasedId) + assert.equal(released.runtime.active.has(releasedId), true) + }) + + it("retains unresolved pre-creation cancellation until its delayed create", async () => { + const harness = createHarness() + const requestIds = Array.from({ length: 1_025 }, (_, index) => `pending-cancel-${index}`) + await Promise.all(requestIds.map((requestId) => harness.manager.cancelCreationRequest(requestId))) + + assert.equal((harness.manager as any).cancelledCreationRequests.size, requestIds.length) + await assert.rejects( + harness.manager.create(process.cwd(), undefined, { requestId: requestIds[0] }), + /was cancelled/, + ) + assert.equal((harness.manager as any).cancelledCreationRequests.has(requestIds[0]), false) + assert.equal((harness.manager as any).cancelledCreationRequests.size, requestIds.length - 1) + }) + + it("returns scoped correlation while an ordinary shared launch remains retained", async () => { + const harness = createHarness() + const ordinary = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + const scoped = harness.manager.create(process.cwd(), undefined, { requestId: "restore-shared" }) + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + + const [ordinaryResult, scopedResult] = await Promise.all([ordinary, scoped]) + assert.equal(ordinaryResult.created, true) + assert.equal(ordinaryResult.workspace.requestId, undefined) + assert.equal(scopedResult.created, false) + assert.equal(scopedResult.workspace.id, workspaceId) + assert.equal(scopedResult.workspace.requestId, "restore-shared") + + assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-shared"), true) + assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) + assert.equal(harness.runtime.active.has(workspaceId), true) + + const reused = await harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" }) + assert.equal(reused.workspace.requestId, "restore-reused") + await harness.manager.cancelCreationRequest("restore-reused") + assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) + assert.equal(harness.runtime.active.has(workspaceId), true) + await assert.rejects( + harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" }), + /was cancelled/, + ) + assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-reused"), false) + }) + + for (const boundary of ["runtime launch", "health readiness"] as const) { + it(`applies one shared end-to-end deadline during ${boundary} and cleans up`, async () => { + const deadlines: Array<() => void> = [] + const harness = createHarness({ + launchTimeoutMs: 25, + setTimeout: ((callback: () => void) => { + const timer = { active: true } + deadlines.push(() => { if (timer.active) callback() }) + return timer as unknown as ReturnType + }) as typeof setTimeout, + clearTimeout: ((timer: { active: boolean }) => { timer.active = false }) as unknown as typeof clearTimeout, + }) + const first = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-one" }) + const workspaceId = await harness.runtime.launchCalled.promise + const shared = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-two" }) + while ([...(harness.manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== 2) { + await new Promise((resolve) => setImmediate(resolve)) + } + if (boundary === "health readiness") { + harness.runtime.resolveLaunch() + await new Promise((resolve) => setImmediate(resolve)) + } + + for (const fire of deadlines) fire() + const outcomes = await Promise.allSettled([first, shared]) + assert.deepEqual(outcomes.map((outcome) => outcome.status), ["rejected", "rejected"]) + assert.ok(outcomes.every((outcome) => outcome.status === "rejected" && outcome.reason instanceof WorkspaceLaunchTimeoutError)) + assert.strictEqual((outcomes[0] as PromiseRejectedResult).reason, (outcomes[1] as PromiseRejectedResult).reason) + assert.equal(harness.runtime.active.has(workspaceId), false) + assert.equal(harness.runtime.stopCalls >= 1, true) + assert.deepEqual(harness.manager.list(), []) + }) + } + + it("bounds shutdown instead of waiting forever", async () => { + let fireDeadline!: () => void + let cleared = 0 + const harness = createHarness({ + shutdownTimeoutMs: 25, + setTimeout: ((callback: () => void) => { + fireDeadline = callback + return {} as ReturnType + }) as typeof setTimeout, + clearTimeout: () => { cleared += 1 }, + } as never) + const workspaceId = await createReady(harness) + cleared = 0 + harness.runtime.stop = () => new Promise(() => undefined) + + const shutdown = harness.manager.shutdown() + fireDeadline() + await assert.rejects(shutdown, WorkspaceCleanupTimeoutError) + assert.equal(harness.manager.get(workspaceId)?.status, "ready") + assert.equal(cleared, 1) + }) + + it("publishes stopped exactly once for normal exit, readiness failure, and manager cleanup", async () => { + const normal = createHarness() + const normalId = await createReady(normal) + normal.runtime.onExit?.({ workspaceId: normalId, code: 0, signal: null, requested: false }) + await normal.manager.delete(normalId) + assert.deepEqual(normal.stopped, [normalId]) + + const failed = createHarness() + const failedCreation = failed.manager.create(process.cwd()) + const failedId = await failed.runtime.launchCalled.promise + failed.runtime.resolveLaunch() + failed.readiness.reject(new Error("not ready")) + await assert.rejects(failedCreation, /not ready/) + assert.deepEqual(failed.stopped, [failedId]) + + const cleaned = createHarness() + const cleanedId = await createReady(cleaned) + await cleaned.manager.shutdown() + assert.deepEqual(cleaned.stopped, [cleanedId]) + }) + + for (const [name, failure] of [ + ["cleanup failures", new Error("stop failed")], + ["incomplete Windows tree cleanup", new WorkspaceWindowsTreeCleanupIncompleteError("workspace", 4242, ["taskkill failed"])], + ] as const) { + it(`aggregates ${name} during shutdown`, async () => { + const harness = createHarness() + const workspaceId = await createReady(harness) + harness.runtime.stop = async () => { throw failure } + + await assert.rejects(harness.manager.shutdown(), (error: unknown) => { + assert.ok(error instanceof WorkspaceShutdownError) + assert.strictEqual(error.errors[0], failure) + return true + }) + assert.equal(harness.manager.get(workspaceId)?.status, "ready") + assert.equal(harness.runtime.active.has(workspaceId), true) + }) + } +}) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index c497db2d..547bccd8 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,6 +1,8 @@ import path from "path" import { spawnSync } from "child_process" +import { randomUUID } from "node:crypto" import { connect } from "net" +import { setTimeout as delay } from "node:timers/promises" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" import type { BinaryResolver } from "../settings/binaries" @@ -22,8 +24,33 @@ import { OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth" +import { resolveWorkspaceIdentity } from "./workspace-identity" +import { parseWslUncPath } from "./spawn" const STARTUP_STABILITY_DELAY_MS = 1500 +const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 +const ORDINARY_CREATION_OWNER = "" +const WORKSPACE_STATE = Symbol("workspaceState") +type ManagerTimeout = ReturnType + +interface WorkspaceRuntimeController { + launch: WorkspaceRuntime["launch"] + stop: WorkspaceRuntime["stop"] +} + +export function binaryPathsEqual(left: string, right: string, platform = process.platform): boolean { + const leftWsl = parseWslUncPath(left) + const rightWsl = parseWslUncPath(right) + if (leftWsl || rightWsl) { + return Boolean( + leftWsl + && rightWsl + && leftWsl.distro.toLowerCase() === rightWsl.distro.toLowerCase() + && leftWsl.linuxPath === rightWsl.linuxPath, + ) + } + return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right +} interface WorkspaceManagerOptions { rootDir: string @@ -34,35 +61,132 @@ interface WorkspaceManagerOptions { getServerBaseUrl: () => string /** Optional CA bundle path to trust CodeNomad HTTPS certs. */ nodeExtraCaCertsPath?: string + runtime?: Pick + shutdownTimeoutMs?: number + launchSettlementTimeoutMs?: number + launchTimeoutMs?: number + setTimeout?: (callback: () => void, delayMs: number) => ManagerTimeout + clearTimeout?: (timer: ManagerTimeout) => void } -interface WorkspaceRecord extends WorkspaceDescriptor {} +interface WorkspaceRecord extends WorkspaceDescriptor { + identityKey: string + ownership: WorkspaceCreationOwnership + [WORKSPACE_STATE]: WorkspaceState +} +interface WorkspaceState { + abortController: AbortController + creation?: Promise + settlement?: Promise + deletePromise?: Promise + published: boolean + stoppedPublished: boolean +} +export class WorkspaceLaunchCancelledError extends Error { + constructor(workspaceId: string) { + super(`Workspace ${workspaceId} launch was cancelled`) + this.name = "WorkspaceLaunchCancelledError" + } +} +export class WorkspaceLaunchTimeoutError extends Error { + readonly code = "WORKSPACE_LAUNCH_TIMEOUT" + readonly retryable = true + constructor(workspaceId: string | undefined, timeoutMs: number) { + super(`${workspaceId ? `Workspace ${workspaceId}` : "Workspace"} did not finish launching within ${timeoutMs}ms`) + this.name = "WorkspaceLaunchTimeoutError" + } +} +export class WorkspaceCleanupTimeoutError extends Error { + readonly code = "WORKSPACE_CLEANUP_TIMEOUT" + readonly retryable = true + constructor(operation: string, timeoutMs: number) { + super(`Workspace ${operation} did not finish within ${timeoutMs}ms; cleanup can be retried`) + this.name = "WorkspaceCleanupTimeoutError" + } +} +export class WorkspaceShutdownError extends AggregateError { + readonly code = "WORKSPACE_SHUTDOWN_FAILED" + readonly retryable = true + constructor(errors: unknown[]) { + super(errors, `Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`) + this.name = "WorkspaceShutdownError" + } +} +export interface WorkspaceCreateResult { + workspace: WorkspaceDescriptor + created: boolean +} +export interface WorkspaceCreateOptions { + binaryPath?: string + requestId?: string + forceNew?: boolean +} +type CreationRequestState = "active" | "cancelled" | "released" +type WorkspaceCreationOwnership = Map +interface WorkspaceReadiness { + workspaceId: string + port: number + exitPromise: Promise + getLastOutput: () => string + signal?: AbortSignal +} export class WorkspaceManager { private readonly workspaces = new Map() - private readonly runtime: WorkspaceRuntime + private readonly pendingWorkspaceCreations = new Map() + private readonly cancelledCreationRequests = new Set() + private shuttingDown = false + private readonly runtime: Pick private readonly codeNomadPluginUrl: string private readonly opencodeAuth = new Map() constructor(private readonly options: WorkspaceManagerOptions) { - this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) + this.runtime = options.runtime ?? new WorkspaceRuntime(this.options.eventBus, this.options.logger) this.codeNomadPluginUrl = getCodeNomadPluginUrl() } - list(): WorkspaceDescriptor[] { return Array.from(this.workspaces.values()) + .filter((record) => record[WORKSPACE_STATE].published) } get(id: string): WorkspaceDescriptor | undefined { - return this.workspaces.get(id) + const record = this.workspaces.get(id) + return record?.[WORKSPACE_STATE].published ? record : undefined } getInstancePort(id: string): number | undefined { - return this.workspaces.get(id)?.port + const record = this.workspaces.get(id) + return record?.[WORKSPACE_STATE].published ? record.port : undefined } getInstanceAuthorizationHeader(id: string): string | undefined { - return this.opencodeAuth.get(id)?.authorization + return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.opencodeAuth.get(id)?.authorization : undefined + } + + findReadyInstanceIdByBinary(binaryPath: string): string | undefined { + const resolvedPath = this.resolveBinaryPath(binaryPath) + return this.list().find((workspace) => { + return workspace.status === "ready" && binaryPathsEqual(workspace.binaryId, resolvedPath) + })?.id + } + + private findReadyWorkspaceByIdentity( + identityKey: string, + includeRestoreOwned: boolean, + ): WorkspaceDescriptor | undefined { + for (const record of this.workspaces.values()) { + const state = record[WORKSPACE_STATE] + if ( + state.published + && !state.abortController.signal.aborted + && (includeRestoreOwned || !record.requestId) + && record.status === "ready" + && record.identityKey === identityKey + ) { + return record + } + } + return undefined } listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { @@ -114,12 +238,78 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } - async create(folder: string, name?: string): Promise { - - const id = `${Date.now().toString(36)}` - const binary = this.options.binaryResolver.resolveDefault() - const resolvedBinaryPath = this.resolveBinaryPath(binary.path) - const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) + async create( + folder: string, + name?: string, + options: WorkspaceCreateOptions = {}, + ): Promise { + const launchTimeoutMs = Math.max(1, this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS) + const launchDeadlineAt = Date.now() + launchTimeoutMs + try { + const { workspacePath, identityKey } = await this.withLaunchDeadline( + resolveWorkspaceIdentity(folder, this.options.rootDir), + undefined, + launchDeadlineAt, + launchTimeoutMs, + ) + if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { + throw new Error(`Workspace creation request ${options.requestId} was cancelled`) + } + if (this.shuttingDown) { + throw new Error("Workspace manager is shutting down") + } + if (options.forceNew) { + const ownership = this.createOwnership(options.requestId) + const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) + const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + return this.finishCreation(result, options.requestId, ownership) + } + const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) + if (existing) { + this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") + const record = this.workspaces.get(existing.id) + if (options.requestId && record) { + if (!record.ownership.has(options.requestId)) record.ownership.set(options.requestId, "active") + this.syncOwnership(record) + return this.finishCreation({ workspace: existing, created: false }, options.requestId, record.ownership) + } + return { workspace: existing, created: false } + } + const pending = this.pendingWorkspaceCreations.get(identityKey) + if (pending) { + const state = pending[WORKSPACE_STATE] + const owner = options.requestId ?? ORDINARY_CREATION_OWNER + if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") + this.syncOwnership(pending) + const result = await state.creation! + return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) + } + const ownership = this.createOwnership(options.requestId) + const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) + const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + this.pendingWorkspaceCreations.set(identityKey, record) + try { + return this.finishCreation(await creation, options.requestId, ownership) + } finally { + if (this.pendingWorkspaceCreations.get(identityKey) === record) { + this.pendingWorkspaceCreations.delete(identityKey) + } + } + } finally { + if (options.requestId) this.cancelledCreationRequests.delete(options.requestId) + } + } + private reserveWorkspace( + workspacePath: string, + identityKey: string, + name: string | undefined, + options: WorkspaceCreateOptions, + ownership: WorkspaceCreationOwnership, + launchDeadlineAt: number, + ): WorkspaceRecord { + const id = randomUUID() + const binary = this.options.binaryResolver.resolve(options.binaryPath) + const resolvedBinaryPath = this.resolveBinaryPath(binary.path, Math.max(1, launchDeadlineAt - Date.now())) clearWorkspaceSearchCache(workspacePath) this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") @@ -127,8 +317,9 @@ export class WorkspaceManager { const proxyPath = `/workspaces/${id}/instance` - const descriptor: WorkspaceRecord = { + const record = { id, + requestId: options.requestId, path: workspacePath, name, status: "starting", @@ -138,137 +329,328 @@ export class WorkspaceManager { binaryVersion: binary.version, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - } - - this.workspaces.set(id, descriptor) - - - this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) - - const serverConfig = this.options.settings.getOwner("config", "server") - const envVars = (serverConfig as any)?.environmentVariables - const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} - const opencodeConfigContent = buildOpencodeConfigContent( - resolveExistingOpencodeConfigContent(userEnvironment), - this.codeNomadPluginUrl, - ) - const serverBaseUrl = this.options.getServerBaseUrl() - const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "") - - const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({ - userEnvironment, - processEnv: process.env, + } as WorkspaceRecord + Object.defineProperties(record, { + identityKey: { value: identityKey }, + ownership: { value: ownership }, + [WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } }, }) - const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword }) - if (!authorization) { - throw new Error("Failed to build OpenCode auth header") + + this.workspaces.set(id, record) + if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { + record[WORKSPACE_STATE].abortController.abort(new WorkspaceLaunchCancelledError(id)) } - this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization }) - - const environment = { - ...userEnvironment, - OPENCODE_CONFIG_CONTENT: opencodeConfigContent, - OPENCODE_EXPERIMENTAL_WORKSPACES: "true", - CODENOMAD_INSTANCE_ID: id, - CODENOMAD_BASE_URL: serverBaseUrl, - ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), - [OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedServerBaseUrl}${proxyPath}`, - [OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername, - [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, - } - - const logLevel = (serverConfig as any)?.logLevel - + return record + } + private startCreation(record: WorkspaceRecord, options: WorkspaceCreateOptions, + launchDeadlineAt: number, launchTimeoutMs: number): Promise { + const creation = this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) + record[WORKSPACE_STATE].creation = creation + record[WORKSPACE_STATE].settlement = creation.then(() => undefined, () => undefined) + return creation + } + private async createWithDeadline(record: WorkspaceRecord, options: WorkspaceCreateOptions, + launchDeadlineAt: number, launchTimeoutMs: number): Promise { + const timeoutMs = Math.max(1, launchDeadlineAt - Date.now()) + const state = record[WORKSPACE_STATE] + let timeout: ManagerTimeout | null = (this.options.setTimeout ?? setTimeout)(() => { + timeout = null + if (!state.abortController.signal.aborted) { + state.abortController.abort(new WorkspaceLaunchTimeoutError(record.id, launchTimeoutMs)) + } + }, timeoutMs) try { + return await this.createResolvedWorkspace(record, options) + } finally { + if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) + } + } + + private async withLaunchDeadline(operation: Promise, workspaceId: string | undefined, + deadlineAt: number, launchTimeoutMs: number): Promise { + const timeoutMs = Math.max(1, deadlineAt - Date.now()) + let timeout: ManagerTimeout | null = null + const deadline = new Promise((_resolve, reject) => { + timeout = (this.options.setTimeout ?? setTimeout)(() => { + timeout = null + reject(new WorkspaceLaunchTimeoutError(workspaceId, launchTimeoutMs)) + }, timeoutMs) + }) + try { + return await Promise.race([operation, deadline]) + } finally { + if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) + } + } + private async createResolvedWorkspace( + record: WorkspaceRecord, + options: WorkspaceCreateOptions, + ): Promise { + const state = record[WORKSPACE_STATE] + const { id, path: workspacePath, binaryId: resolvedBinaryPath, proxyPath } = record + try { + this.throwIfCancelled(record) + + const serverConfig = this.options.settings.getOwner("config", "server") + const envVars = (serverConfig as any)?.environmentVariables + const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} + const opencodeConfigContent = buildOpencodeConfigContent( + resolveExistingOpencodeConfigContent(userEnvironment), + this.codeNomadPluginUrl, + ) + const serverBaseUrl = this.options.getServerBaseUrl() + const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "") + + const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({ + userEnvironment, + processEnv: process.env, + }) + const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword }) + if (!authorization) { + throw new Error("Failed to build OpenCode auth header") + } + this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization }) + + const environment = { + ...userEnvironment, + OPENCODE_CONFIG_CONTENT: opencodeConfigContent, + OPENCODE_EXPERIMENTAL_WORKSPACES: "true", + CODENOMAD_INSTANCE_ID: id, + CODENOMAD_BASE_URL: serverBaseUrl, + ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), + [OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedServerBaseUrl}${proxyPath}`, + [OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername, + [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, + } + + const logLevel = (serverConfig as any)?.logLevel const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: resolvedBinaryPath, environment, logLevel, + signal: state.abortController.signal, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) + record.pid = pid + record.port = port - const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) + this.throwIfCancelled(record) + state.published = true + this.options.eventBus.publish({ type: "workspace.created", workspace: record }) + this.throwIfCancelled(record) + const runtimeVersion = await this.waitForWorkspaceReadiness({ + workspaceId: id, + port, + exitPromise, + getLastOutput, + signal: state.abortController.signal, + }) + this.throwIfCancelled(record) if (runtimeVersion) { - descriptor.binaryVersion = runtimeVersion + record.binaryVersion = runtimeVersion } - descriptor.pid = pid - descriptor.port = port - descriptor.status = "ready" - descriptor.updatedAt = new Date().toISOString() - this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor }) + record.status = "ready" + record.updatedAt = new Date().toISOString() + this.options.eventBus.publish({ type: "workspace.started", workspace: record }) this.options.logger.info({ workspaceId: id, port }, "Workspace ready") - return descriptor + return { workspace: record, created: true } } catch (error) { - descriptor.status = "error" - descriptor.error = error instanceof Error ? error.message : String(error) - descriptor.updatedAt = new Date().toISOString() - this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) - this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start") - throw error + const launchFailure = state.abortController.signal.aborted ? state.abortController.signal.reason : error + let stopFailure: unknown + await this.runtime.stop(id).catch((stopError) => { + stopFailure = stopError + }) + if (!stopFailure) { + this.removeRecord(id, record, state.published) + throw launchFailure + } + if (!state.published) { + throw stopFailure + } + record.status = "error" + record.error = stopFailure instanceof Error + ? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}` + : launchFailure instanceof Error ? launchFailure.message : String(launchFailure) + record.updatedAt = new Date().toISOString() + if (this.workspaces.get(id) === record && state.published) { + this.options.eventBus.publish({ type: "workspace.error", workspace: record }) + } + this.options.logger.error({ workspaceId: id, err: launchFailure }, "Workspace failed to start") + throw launchFailure } } - async delete(id: string): Promise { - const workspace = this.workspaces.get(id) - if (!workspace) return undefined - - this.options.logger.info({ workspaceId: id }, "Stopping workspace") - const wasRunning = Boolean(workspace.pid) - if (wasRunning) { - await this.runtime.stop(id).catch((error) => { - this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly") + delete(id: string): Promise { + const record = this.workspaces.get(id) + if (!record) return Promise.resolve(undefined) + const state = record[WORKSPACE_STATE] + if (!state.abortController.signal.aborted) { + state.abortController.abort(new WorkspaceLaunchCancelledError(id)) + } + const pending = this.pendingWorkspaceCreations.get(record.identityKey) + if (pending === record) { + this.pendingWorkspaceCreations.delete(record.identityKey) + } + if (!state.deletePromise) { + let deletePromise!: Promise + deletePromise = this.cleanupDeletedWorkspace(id, record).catch((error) => { + if (state.deletePromise === deletePromise) state.deletePromise = undefined + throw error }) + state.deletePromise = deletePromise } + return state.deletePromise + } - this.workspaces.delete(id) - this.opencodeAuth.delete(id) - clearWorkspaceSearchCache(workspace.path) - if (!wasRunning) { - this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id }) + releaseCreationRequest(id: string, requestId: string): boolean { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return false + const ownership = record.ownership + const state = ownership.get(requestId) + if (state === "released") return true + if (state === "cancelled") return false + if (state !== "active") return false + ownership.set(requestId, "released") + this.syncOwnership(record) + return true + } + + async cancelCreationRequest(requestId: string): Promise { + let matched = false + for (const [workspaceId, record] of this.workspaces) { + const ownership = record.ownership + const state = ownership.get(requestId) + if (state === "released") { matched = true; continue } + if (state === "cancelled") { + matched = true + if (this.hasActiveRequest(ownership) || this.isRetained(ownership)) continue + await this.delete(workspaceId) + return + } + if (state !== "active") continue + matched = true + ownership.set(requestId, "cancelled") + this.syncOwnership(record) + if (this.hasActiveRequest(ownership) || this.isRetained(ownership)) return + await this.delete(workspaceId) + return } - return workspace + if (!matched) this.cancelledCreationRequests.add(requestId) + } + + private createOwnership(requestId?: string): WorkspaceCreationOwnership { + return new Map([[requestId ?? ORDINARY_CREATION_OWNER, "active"]]) + } + + private finishCreation( + result: WorkspaceCreateResult, + requestId: string | undefined, + ownership: WorkspaceCreationOwnership, + ): WorkspaceCreateResult { + if (requestId && ownership.get(requestId) === "cancelled") { + throw new Error(`Workspace creation request ${requestId} was cancelled`) + } + const retained = this.isRetained(ownership) + return { + workspace: requestId ? { ...result.workspace, requestId } : result.workspace, + created: result.created && !(requestId && retained), + } + } + + private syncOwnership(record: WorkspaceRecord | undefined): void { + if (!record) return + const ownership = record.ownership + record.requestId = this.isRetained(ownership) + ? undefined + : Array.from(ownership).find(([, state]) => state === "active")?.[0] + } + + private isRetained(ownership: WorkspaceCreationOwnership): boolean { + return ownership.has(ORDINARY_CREATION_OWNER) || Array.from(ownership.values()).includes("released") + } + + private hasActiveRequest(ownership: WorkspaceCreationOwnership): boolean { + return Array.from(ownership).some(([requestId, state]) => Boolean(requestId) && state === "active") } async shutdown() { + this.shuttingDown = true this.options.logger.info("Shutting down all workspaces") - - const stopTasks: Array> = [] - - for (const [id, workspace] of this.workspaces) { - if (!workspace.pid) { - this.options.logger.debug({ workspaceId: id }, "Workspace already stopped") - continue - } - - this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown") - stopTasks.push( - this.runtime.stop(id).catch((error) => { - this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown") - }), - ) - } - - if (stopTasks.length > 0) { - await Promise.allSettled(stopTasks) - } - - this.workspaces.clear() - this.opencodeAuth.clear() - this.options.logger.info("All workspaces cleared") + const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id)) + const results = stopTasks.length + ? await this.withTimeout(Promise.allSettled(stopTasks), this.options.shutdownTimeoutMs ?? 10000, "shutdown") + : [] + const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) + if (this.workspaces.size === 0) { + this.pendingWorkspaceCreations.clear() + this.cancelledCreationRequests.clear() + } else if (!stopFailures.length) stopFailures.push( + new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`), + ) + if (stopFailures.length) throw new WorkspaceShutdownError(stopFailures) } - private requireWorkspace(id: string): WorkspaceRecord { - const workspace = this.workspaces.get(id) - if (!workspace) { - throw new Error("Workspace not found") + private async withTimeout(operation: Promise, timeoutMs: number, label: string): Promise { + let timeout: ManagerTimeout | null = null + const deadline = new Promise((_resolve, reject) => { + timeout = (this.options.setTimeout ?? setTimeout)(() => { + timeout = null + reject(new WorkspaceCleanupTimeoutError(label, timeoutMs)) + }, timeoutMs) + }) + + try { + return await Promise.race([operation, deadline]) + } finally { + if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) } - return workspace } - private resolveBinaryPath(identifier: string): string { + private requireWorkspace(id: string): WorkspaceDescriptor { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) throw new Error("Workspace not found") + return record + } + + private throwIfCancelled(record: WorkspaceRecord): void { + record[WORKSPACE_STATE].abortController.signal.throwIfAborted() + } + + private async cleanupDeletedWorkspace(id: string, record: WorkspaceRecord): Promise { + // Stop once immediately, then again after launch settlement to cover a child + // that became available while cancellation was propagating. + const immediateStop = this.runtime.stop(id).catch((error) => { + this.options.logger.warn({ workspaceId: id, err: error }, "Initial workspace process cleanup failed; retrying after launch settles") + }) + await this.withTimeout(record[WORKSPACE_STATE].settlement!, this.options.launchSettlementTimeoutMs ?? 5000, `${id} launch cancellation`) + await immediateStop + await this.runtime.stop(id) + + this.removeRecord(id, record, true) + return record + } + + private removeRecord(id: string, record: WorkspaceRecord, publishStopped: boolean): void { + if (this.workspaces.get(id) !== record) return + this.workspaces.delete(id) + this.opencodeAuth.delete(id) + clearWorkspaceSearchCache(record.path) + if (publishStopped) this.publishStopped(record, "deleted") + } + + private publishStopped(record: WorkspaceRecord, reason: "deleted" | "stopped" = "stopped"): void { + const state = record[WORKSPACE_STATE] + if (!state.published || state.stoppedPublished) return + state.stoppedPublished = true + record.status = "stopped" + record.error = undefined + this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.id, reason }) + } + + resolveBinaryPath(identifier: string, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS): string { if (!identifier) { return identifier } @@ -281,7 +663,7 @@ export class WorkspaceManager { const locator = process.platform === "win32" ? "where" : "which" try { - const result = spawnSync(locator, [identifier], { encoding: "utf8" }) + const result = spawnSync(locator, [identifier], { encoding: "utf8", timeout: Math.max(1, timeoutMs) }) if (result.status === 0 && result.stdout) { const candidates = result.stdout .split(/\r?\n/) @@ -321,58 +703,27 @@ export class WorkspaceManager { return candidates[0] ?? "" } - private async waitForWorkspaceReadiness(params: { - workspaceId: string - port: number - exitPromise: Promise - getLastOutput: () => string - }): Promise { + private async waitForWorkspaceReadiness(params: WorkspaceReadiness): Promise { await Promise.race([ - this.waitForPortAvailability(params.port), - params.exitPromise.then((info) => { - throw this.buildStartupError( - params.workspaceId, - "exited before becoming ready", - info, - params.getLastOutput(), - ) - }), + this.waitForPortAvailability(params.port, 5000, params.signal), + this.exitDuringStartup(params, "exited before becoming ready"), ]) const version = await this.waitForInstanceHealth(params) await Promise.race([ - this.delay(STARTUP_STABILITY_DELAY_MS), - params.exitPromise.then((info) => { - throw this.buildStartupError( - params.workspaceId, - "exited shortly after start", - info, - params.getLastOutput(), - ) - }), + delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }), + this.exitDuringStartup(params, "exited shortly after start"), ]) return version } - private async waitForInstanceHealth(params: { - workspaceId: string - port: number - exitPromise: Promise - getLastOutput: () => string - }): Promise { + private async waitForInstanceHealth(params: WorkspaceReadiness): Promise { const probeResult = await Promise.race([ - this.probeInstance(params.workspaceId, params.port), - params.exitPromise.then((info) => { - throw this.buildStartupError( - params.workspaceId, - "exited during health checks", - info, - params.getLastOutput(), - ) - }), + this.probeInstance(params.workspaceId, params.port, params.signal), + this.exitDuringStartup(params, "exited during health checks"), ]) if (probeResult.ok) { @@ -387,9 +738,16 @@ export class WorkspaceManager { throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) } + private exitDuringStartup(params: WorkspaceReadiness, phase: string): Promise { + return params.exitPromise.then((info) => { + throw this.buildStartupError(params.workspaceId, phase, info, params.getLastOutput()) + }) + } + private async probeInstance( workspaceId: string, port: number, + signal?: AbortSignal, ): Promise<{ ok: boolean; reason?: string; version?: string }> { const url = `http://127.0.0.1:${port}/global/health` @@ -400,7 +758,7 @@ export class WorkspaceManager { headers["Authorization"] = authHeader } - const response = await fetch(url, { headers }) + const response = await fetch(url, { headers, signal }) if (!response.ok) { const reason = `/global/health returned HTTP ${response.status}` this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") @@ -437,7 +795,7 @@ export class WorkspaceManager { return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`) } - private waitForPortAvailability(port: number, timeoutMs = 5000): Promise { + private waitForPortAvailability(port: number, timeoutMs = 5000, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const deadline = Date.now() + timeoutMs let settled = false @@ -452,17 +810,18 @@ export class WorkspaceManager { } const tryConnect = () => { - if (settled) { - return - } - const socket = connect({ port, host: "127.0.0.1" }, () => { + if (settled) return + const socket = connect({ port, host: "127.0.0.1", signal }, () => { cleanup() socket.end() resolve() }) socket.once("error", () => { socket.destroy() - if (settled) { + if (settled) return + if (signal?.aborted) { + cleanup() + reject(signal.reason) return } if (Date.now() >= deadline) { @@ -477,17 +836,11 @@ export class WorkspaceManager { }) } + if (signal?.aborted) return reject(signal.reason) tryConnect() }) } - private delay(durationMs: number): Promise { - if (durationMs <= 0) { - return Promise.resolve() - } - return new Promise((resolve) => setTimeout(resolve, durationMs)) - } - private describeExit(info: ProcessExitInfo): string { if (info.signal) { return `signal ${info.signal}` @@ -499,8 +852,9 @@ export class WorkspaceManager { } private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { - const workspace = this.workspaces.get(workspaceId) - if (!workspace) return + const record = this.workspaces.get(workspaceId) + if (!record) return + const workspace = record this.opencodeAuth.delete(workspaceId) @@ -510,10 +864,8 @@ export class WorkspaceManager { workspace.port = undefined workspace.updatedAt = new Date().toISOString() - if (info.requested || info.code === 0) { - workspace.status = "stopped" - workspace.error = undefined - this.options.eventBus.publish({ type: "workspace.stopped", workspaceId }) + if (record[WORKSPACE_STATE].abortController.signal.aborted || info.requested || info.code === 0) { + this.publishStopped(record) } else { workspace.status = "error" workspace.error = `Process exited with code ${info.code}` diff --git a/packages/server/src/workspaces/process-identity.darwin.test.ts b/packages/server/src/workspaces/process-identity.darwin.test.ts new file mode 100644 index 00000000..f657474f --- /dev/null +++ b/packages/server/src/workspaces/process-identity.darwin.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict" +import { spawn, spawnSync } from "node:child_process" +import { once } from "node:events" +import { setTimeout as delay } from "node:timers/promises" +import { it } from "node:test" + +import { + LAUNCH_CLEANUP_TOKEN_ENV, + probePosixProcesses, + signalOwnedPosixProcessGroup, + signalPosixProcesses, +} from "./process-identity" + +const darwinOnly = { skip: process.platform !== "darwin", timeout: 10_000 } + +async function spawnDetachedGroup(cleanupToken?: string) { + const leader = spawn(process.execPath, ["-e", ` + const { spawn } = require("node:child_process") + spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + process.stdout.write("ready\\n") + setInterval(() => {}, 1000) + `], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + env: { ...process.env, ...(cleanupToken ? { [LAUNCH_CLEANUP_TOKEN_ENV]: cleanupToken } : {}) }, + }) + assert.ok(leader.pid) + await once(leader.stdout!, "data") + return leader as typeof leader & { pid: number } +} + +async function assertGroupGone(groupId: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const remaining = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId }) + if (remaining.ok && remaining.processes.size === 0) return + await delay(50) + } + assert.fail("owned Darwin process group remained alive after signaling") +} + +it("uses real Darwin ps identities to stop an owned detached process group", darwinOnly, async () => { + const leader = await spawnDetachedGroup() + + try { + const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", { + pids: [leader.pid], + groupId: leader.pid, + }) + assert.equal(snapshot.ok, true) + assert.equal(snapshot.ok && snapshot.processes.get(leader.pid)?.groupId, leader.pid) + assert.equal(snapshot.ok && snapshot.processes.size >= 2, true) + + const signaled = signalOwnedPosixProcessGroup(spawnSync, leader.pid, "SIGTERM", 1_000) + assert.equal(signaled.ok && signaled.matched, true) + assert.equal(signaled.ok && signaled.signalSent, true) + await assertGroupGone(leader.pid) + } finally { + try { + process.kill(-leader.pid, "SIGKILL") + } catch { + // The successful path has already removed the process group. + } + } +}) + +it("uses a retained real Darwin identity anchor after the group leader exits", darwinOnly, async () => { + const cleanupToken = "darwin-integration-cleanup-token" + const leader = await spawnDetachedGroup(cleanupToken) + + try { + const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId: leader.pid }) + assert.equal(snapshot.ok, true) + const leaderIdentity = snapshot.ok ? snapshot.processes.get(leader.pid) : undefined + assert.ok(leaderIdentity) + assert.equal(snapshot.ok && snapshot.processes.size >= 2, true) + + leader.kill("SIGTERM") + if (leader.exitCode === null) await once(leader, "exit") + const signaled = signalPosixProcesses(spawnSync, { + leader: leaderIdentity, + groupId: leader.pid, + members: [], + signal: "SIGTERM", + allowLeaderlessGroup: true, + cleanupToken, + }, 1_000, "darwin") + assert.equal(signaled.ok && signaled.matched, true) + assert.equal(signaled.ok && signaled.signalSent, true) + await assertGroupGone(leader.pid) + } finally { + try { + process.kill(-leader.pid, "SIGKILL") + } catch { + // The successful path has already removed the process group. + } + } +}) diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts new file mode 100644 index 00000000..0efe5add --- /dev/null +++ b/packages/server/src/workspaces/process-identity.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict" +import { spawn as spawnChild, spawnSync, type SpawnSyncReturns } from "node:child_process" +import { once } from "node:events" +import { readFileSync } from "node:fs" +import { describe, it } from "node:test" + +import { + probePosixProcesses, probeWindowsProcesses, probeWslProcesses, sameProcess, + signalOwnedPosixProcessGroup, signalPosixProcesses, signalWindowsProcesses, + startedNoLaterThan, type ProcessIdentity, +} from "./process-identity" + +type Spawn = typeof import("node:child_process").spawnSync +type Call = { command: string; args: readonly string[]; script: string } +const output = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => + ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) +const spawn = (stdout: string, call?: Call, status = 0, stderr = "") => ((command: string, args: readonly string[]) => { + if (call) Object.assign(call, { command, args, script: command === "powershell.exe" ? args.at(-1) ?? "" : args[args.indexOf("-c") + 1] ?? "" }) + return output(stdout, status, stderr) +}) as unknown as Spawn +const b64 = (value: string) => Buffer.from(value).toString("base64") +const identity = (startTime = "123456"): ProcessIdentity => + ({ pid: 42, parentPid: 1, groupId: 42, startTime, bootId: "boot-a", startOrder: startTime }) + +describe("process identity probes", () => { + it("parses immutable Linux identities", () => { + const call = {} as Call + const probe = probePosixProcesses(spawn("42|1|42|123456|boot-a|123456\n", call), 25, "linux") + assert.deepEqual([call.command, call.args.includes("codenomad-posix-identity"), call.script.trimEnd().endsWith("exit 0")], ["sh", true, true]) + assert.deepEqual(probe.ok && probe.processes.get(42), identity()) + }) + + it("queries the requested Linux launch group without per-process subprocesses", () => { + const call = {} as Call + const probe = probePosixProcesses(spawn("42|1|42|123456|boot-a|123456\n", call), 25, "linux", { pids: [42], groupId: 42 }) + assert.deepEqual(call.args.slice(-1), ["42"]) + assert.match(call.script, /expected_group=\$stat_group/) + assert.doesNotMatch(call.script, /\b(?:cat|cut|sed|basename|dirname)\b/) + assert.deepEqual(probe.ok && probe.processes.get(42), identity()) + }) + + it("captures real Linux start ticks and launch-group members within the deadline", { skip: process.platform !== "linux" }, async () => { + const child = spawnChild("sh", ["-c", "sleep 5"], { stdio: "ignore" }) + await once(child, "spawn") + try { + const stat = readFileSync(`/proc/${process.pid}/stat`, "utf8") + const expectedStart = stat.slice(stat.lastIndexOf(") ") + 2).split(" ")[19] + const probe = probePosixProcesses(spawnSync, 1_000, "linux", { pids: [process.pid], groupId: process.pid }) + assert.equal(probe.ok && probe.processes.get(process.pid)?.startTime, expectedStart) + assert.equal(probe.ok && probe.processes.has(child.pid!), true) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit") + child.kill() + await exited + } + } + }) + + it("uses one delimiter-safe process-table query on portable POSIX", () => { + const call = {} as Call + const command = "/opt/opencode 'pipe|value'\t\"quoted\" café" + const start = "Fri Jul 10 12:34:56 2026" + const probe = probePosixProcesses(spawn(`42 1 42 ${start} ${command}\n`, call), 25, "darwin") + assert.deepEqual([call.command, call.args], ["ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="]]) + assert.equal(probe.ok && probe.processes.get(42)?.startTime, `${start}\t${command}`) + }) + + it("ignores malformed unrelated portable rows but fails for a malformed requested identity", () => { + const start = "Fri Jul 10 12:34:56 2026" + const unrelated = `77 1 77 malformed identity\n42 1 42 ${start} opencode\n` + const filtered = probePosixProcesses(spawn(unrelated), 25, "darwin", { pids: [42], groupId: 42 }) + assert.equal(filtered.ok && filtered.processes.get(42)?.startTime, `${start}\topencode`) + assert.equal(probePosixProcesses(spawn(unrelated), 25, "darwin", { pids: [77] }).ok, false) + }) + + it("preserves delimiter-heavy identities through POSIX escalation and rescan", () => { + const command = "/opt/opencode pipe|value\nnext\t'quoted'" + const row = `CODENOMAD_TARGET_B64|42|1|42|${b64("Fri Jul 10 12:34:56 2026")}|${b64(command)}\nCODENOMAD_RESULT|1||1\n` + const expected = `Fri Jul 10 12:34:56 2026\t${command}` + const guardedCall = {} as Call + const guarded = signalPosixProcesses(spawn(row, guardedCall), { leader: identity(expected), groupId: 42, members: [identity(expected)], signal: "SIGKILL" }, 25, "darwin") + const call = {} as Call + const owned = signalOwnedPosixProcessGroup(spawn(row, call), 42, "SIGTERM", 25) + assert.deepEqual([guarded.ok, guarded.ok && guarded.signaled[0]?.startTime], [true, expected]) + assert.deepEqual([owned.ok && owned.matched, owned.ok && owned.signaled[0]?.startTime], [true, expected]) + assert.ok(call.script.indexOf('kill "-$requested_signal"') < call.script.lastIndexOf("for current_pid")) + assert.match(call.script, /group_pids\(\).*pid=,pgid=/) + assert.doesNotMatch(call.script, /ps -eo pid=/) + assert.match(guardedCall.script, /test "\$current_group" = "\$expected_group"/) + }) + + it("marks a retained portable group request for leaderless guarded cleanup", () => { + const call = {} as Call + const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|1||1\n", call), { + leader: identity("gone"), groupId: 42, members: [identity("member")], signal: "SIGTERM", + allowLeaderlessGroup: true, cleanupToken: "secret-token", + }, 25, "darwin") + assert.equal(guarded.ok, true) + assert.equal(call.args[7], "1") + assert.equal(call.args[8], "secret-token") + assert.match(call.script, /anchor=0/) + assert.match(call.script, /has_cleanup_token/) + }) + + it("queries WSL identities in the selected distro", () => { + const call = {} as Call + const probe = probeWslProcesses(spawn("99|1|99|123456|boot-a|123456\n101|99|99|123460|boot-a|123460\n", call), "Ubuntu Test", 25) + assert.deepEqual([call.command, call.args.slice(0, 4), call.args.includes("codenomad-wsl-identity"), call.script.trimEnd().endsWith("exit 0")], + ["wsl.exe", ["--distribution", "Ubuntu Test", "--exec", "sh"], true, true]) + assert.equal(probe.ok && probe.processes.get(101)?.startTime, "123460") + }) + + it("uses Windows CIM CreationDate as the immutable identity", () => { + const call = {} as Call + const probe = probeWindowsProcesses(spawn("4242|100|0|20260710123456.123456+000||20260710123456\n", call), 25) + assert.match(call.script, /Get-CimInstance Win32_Process/) + assert.match(call.script, /ProcessId -gt 0/) + assert.equal(probe.ok && probe.processes.get(4242)?.startTime, "20260710123456.123456+000") + }) + + it("rejects PID reuse and invalid start ordering", () => { + const original = identity("9") + for (const [candidate, expected] of [[{ ...original }, true], [{ ...original, startTime: "10" }, false], [{ ...original, pid: 43 }, false]] as const) + assert.equal(sameProcess(original, candidate), expected) + assert.equal(startedNoLaterThan(original, "10"), true) + assert.equal(startedNoLaterThan({ ...original, startOrder: "11" }, "10"), false) + assert.equal(startedNoLaterThan({ ...original, startOrder: "Fri Jul 10" }, "10"), false) + }) + + it("returns a POSIX mismatch without a second signal command", () => { + const call = {} as Call + const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|0||0\n", call), { leader: identity(), groupId: 42, members: [identity()], signal: "SIGTERM" }, 25, "linux") + assert.deepEqual(guarded, { ok: true, matched: false, signalSent: false, signaled: [] }) + assert.deepEqual([call.command, call.args[2], call.args.includes("123456")], ["sh", "codenomad-guarded-signal", true]) + assert.ok(call.script.indexOf('kill "-$requested_signal"') < call.script.indexOf("uptime=$(cut")) + }) + + it("selects and terminates Windows identities in one guarded CIM invocation", () => { + const call = {} as Call + const guarded = signalWindowsProcesses(spawn("CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_RESULT|1||1\n", call), { leader: identity("created"), groupId: 42, members: [identity("created")], signal: "SIGKILL" }, 25) + assert.equal(guarded.ok && guarded.matched, true) + assert.equal(call.command, "powershell.exe") + assert.match(call.script, /CreationDate.*Invoke-CimMethod -InputObject/s) + assert.equal(call.script.match(/foreach \(\$process in \$selected\)/g)?.length, 2) + assert.ok(call.script.indexOf("CODENOMAD_TARGET|") < call.script.indexOf("Invoke-CimMethod")) + assert.doesNotMatch(call.script, /taskkill/i) + }) + + it("retains observed Windows identities after partial termination failure", () => { + const rows = "CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_TARGET|4243|4242|0|descendant||100" + const guarded = signalWindowsProcesses(spawn(rows, undefined, 1, "termination failed"), { leader: identity("created"), groupId: 42, members: [identity("created")], signal: "SIGTERM" }, 25) + assert.equal(guarded.ok, false) + assert.deepEqual(!guarded.ok && guarded.observed?.map(({ pid }) => pid), [4242, 4243]) + }) + + it("fails conservatively for command, malformed, and empty probe output", () => { + assert.deepEqual(probeWindowsProcesses(spawn("", undefined, 1, "CIM unavailable"), 25), { ok: false, error: "CIM unavailable" }) + assert.deepEqual(probePosixProcesses(spawn("", undefined, 20, "proc unavailable"), 25, "linux"), { ok: false, error: "proc unavailable" }) + for (const probe of [probePosixProcesses(spawn("42 malformed process row\n"), 25, "darwin"), probeWslProcesses(spawn("not an identity"), "Ubuntu", 25)]) assert.equal(probe.ok, false) + }) +}) diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts new file mode 100644 index 00000000..2d9b2f07 --- /dev/null +++ b/packages/server/src/workspaces/process-identity.ts @@ -0,0 +1,561 @@ +import type { SpawnSyncReturns, spawnSync } from "node:child_process" + +export interface ProcessIdentity { + pid: number + parentPid: number + groupId: number + startTime: string + bootId?: string + startOrder?: string +} + +export type ProcessSnapshot = + | { ok: true; processes: Map } + | { ok: false; error: string } + +export interface GuardedSignalRequest { + leader?: ProcessIdentity + groupId?: number + members: ProcessIdentity[] + signal: NodeJS.Signals + allowLeaderlessGroup?: boolean + cleanupToken?: string +} + +export interface PosixProcessFilter { + pids?: readonly number[] + groupId?: number +} + +export type GuardedSignalResult = + | { ok: true; matched: boolean; signalSent: boolean; signaled: ProcessIdentity[]; cutoff?: string } + | { ok: false; error: string; observed?: ProcessIdentity[] } + +export type TokenSignalResult = { ok: boolean; signalSent: boolean; targets: ProcessIdentity[]; error?: string } + +export const LAUNCH_CLEANUP_TOKEN_ENV = "CODENOMAD_LAUNCH_CLEANUP_TOKEN" + +type SpawnCommand = typeof spawnSync +const SHELL_DOLLAR = "$" + +const LINUX_IDENTITY_FUNCTIONS = String.raw` +IFS= read -r boot 2>/dev/null < /proc/sys/kernel/random/boot_id || exit 20 +read_stat() { + line= + while IFS= read -r chunk || test -n "$chunk"; do line=$line$chunk; done 2>/dev/null < "/proc/$1/stat" + test -n "$line" || return 1 + stat_pid=$1; rest=${SHELL_DOLLAR}{line##*) }; set -- $rest + test "$#" -ge 20 || return 1 + stat_ppid=$2; stat_group=$3; shift 19; stat_start=$1 +} +emit_linux() { + test -n "$1" && printf '%s|' "$1" + printf '%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" +} +` + +const LINUX_SNAPSHOT_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} +for stat in /proc/[0-9]*/stat; do + directory=${SHELL_DOLLAR}{stat%/stat}; pid=${SHELL_DOLLAR}{directory##*/}; read_stat "$pid" && emit_linux "" +done +exit 0 +` + +const LINUX_LAUNCH_GROUP_SNAPSHOT_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} +leader_pid=$1; read_stat "$leader_pid" || exit 22; expected_group=$stat_group; emit_linux "" +for stat in /proc/[0-9]*/stat; do + directory=${SHELL_DOLLAR}{stat%/stat}; pid=${SHELL_DOLLAR}{directory##*/}; test "$pid" = "$leader_pid" && continue + read_stat "$pid" && test "$stat_group" = "$expected_group" && emit_linux "" +done +exit 0 +` + +const LINUX_GUARDED_SIGNAL_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} +leader_pid=$1; leader_start=$2; leader_boot=$3; expected_group=$4; requested_signal=$5 +shift 5; matched=0; cutoff=; signal_sent=0 +if read_stat "$leader_pid" && test "$boot" = "$leader_boot" && test "$stat_start" = "$leader_start" && test "$stat_group" = "$expected_group"; then + matched=1 + for stat in /proc/[0-9]*/stat; do + directory=${SHELL_DOLLAR}{stat%/stat}; candidate=${SHELL_DOLLAR}{directory##*/}; read_stat "$candidate" && test "$stat_group" = "$expected_group" && emit_linux CODENOMAD_TARGET + done + if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then + signal_sent=1 + hz=$(getconf CLK_TCK 2>/dev/null) || exit 21 + uptime=$(cut -d' ' -f1 /proc/uptime 2>/dev/null) || exit 21 + cutoff=$(awk -v uptime="$uptime" -v hz="$hz" 'BEGIN { printf "%.0f", uptime * hz }') + fi +else + while test "$#" -ge 3; do + expected_pid=$1; expected_start=$2; expected_boot=$3; shift 3 + if read_stat "$expected_pid" && test "$boot" = "$expected_boot" && test "$stat_start" = "$expected_start"; then + emit_linux CODENOMAD_TARGET + if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi + fi + done +fi +printf 'CODENOMAD_RESULT|%s|%s|%s\n' "$matched" "$cutoff" "$signal_sent" +` + +const POSIX_IDENTITY_FUNCTIONS = String.raw` +LC_ALL=C; export LC_ALL; set -f +encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } +read_identity() { + current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 + current_verify=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 + test "$current_meta" = "$current_verify" || return 1 + set -- $current_meta; test "$#" -ge 7 || return 1 + current_ppid=$1; current_group=$2; shift 2; current_start="$1 $2 $3 $4 $5" + shift 5; current_command="$*"; test -n "$current_command" || return 1 + current_identity=$(printf '%s\t%s' "$current_start" "$current_command") +} +emit_target() { + printf 'CODENOMAD_TARGET_B64|%s|%s|%s|' "$current_pid" "$current_ppid" "$current_group" + encode "$current_start"; printf '|'; encode "$current_command"; printf '\n' +} +group_pids() { ps -axo pid=,pgid= 2>/dev/null | awk -v group="$1" '$2 == group { print $1 }'; } +has_cleanup_token() { + test -n "$cleanup_token" || return 1 + ps eww -p "$1" -o command= 2>/dev/null | tr ' ' '\n' | grep -Fqx -- "${LAUNCH_CLEANUP_TOKEN_ENV}=$cleanup_token" +} +` + +const LINUX_TOKEN_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} +key=$1; expected=$2; requested_signal=$3 +matches_token() { test -r "/proc/$1/environ" && tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | grep -Fqx -- "$key=$expected"; } +signal_sent=0; passes=1; test -n "$requested_signal" && passes=3 +pass=0 +while test "$pass" -lt "$passes"; do + pass=$((pass + 1)) + for environ in /proc/[0-9]*/environ; do + directory=${SHELL_DOLLAR}{environ%/environ}; pid=${SHELL_DOLLAR}{directory##*/} + if matches_token "$pid" && read_stat "$pid"; then + test -n "$requested_signal" && prefix=CODENOMAD_TARGET || prefix=CODENOMAD_PROCESS + emit_linux "$prefix" + if test -n "$requested_signal" && matches_token "$pid" && read_stat "$pid" && kill "-$requested_signal" "$pid" 2>/dev/null; then signal_sent=1; fi + fi + done +done +if test -n "$requested_signal"; then printf 'CODENOMAD_RESULT|%s\n' "$signal_sent"; fi +exit 0 +` + +const POSIX_GUARDED_SIGNAL_SCRIPT = String.raw`${POSIX_IDENTITY_FUNCTIONS} +leader_pid=$1; leader_start=$2; expected_group=$3; requested_signal=$4; allow_leaderless=$5; cleanup_token=$6; shift 6 +matched=0; signal_sent=0 +if read_identity "$leader_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$leader_start"; then + matched=1 + for current_pid in $(group_pids "$expected_group"); do + read_identity "$current_pid" && test "$current_group" = "$expected_group" && emit_target + done + if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1; fi +elif test "$allow_leaderless" = 1 && ! read_identity "$expected_group"; then + anchor=0 + while test "$#" -ge 2; do + expected_pid=$1; expected_start=$2; shift 2; current_pid=$expected_pid + if read_identity "$expected_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$expected_start"; then anchor=1; fi + done + if test "$anchor" = 0; then + for current_pid in $(group_pids "$expected_group"); do + if has_cleanup_token "$current_pid" && read_identity "$current_pid" && test "$current_group" = "$expected_group"; then anchor=1; break; fi + done + fi + if test "$anchor" = 1; then + matched=1 + for current_pid in $(group_pids "$expected_group"); do + read_identity "$current_pid" && test "$current_group" = "$expected_group" && emit_target + done + if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1; fi + fi +else + while test "$#" -ge 2; do + expected_pid=$1; expected_start=$2; shift 2; current_pid=$expected_pid + if read_identity "$expected_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$expected_start"; then + emit_target; if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi + fi + done +fi +printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" +` + +const POSIX_OWNED_GROUP_SIGNAL_SCRIPT = String.raw`${POSIX_IDENTITY_FUNCTIONS} +root_pid=$1; requested_signal=$2; matched=0; signal_sent=0 +if read_identity "$root_pid" && test "$current_group" = "$root_pid"; then + matched=1 + for current_pid in $(group_pids "$root_pid"); do + read_identity "$current_pid" && test "$current_group" = "$root_pid" && emit_target + done + if kill "-$requested_signal" -- "-$root_pid" 2>/dev/null; then signal_sent=1; fi + for current_pid in $(group_pids "$root_pid"); do + read_identity "$current_pid" && test "$current_group" = "$root_pid" && emit_target + done +fi +printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" +` + +const commandError = (result: SpawnSyncReturns): string => + result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` + +function parseDelimitedSnapshot(output: string, requireBootId = false): Map | null { + const processes = new Map() + for (const line of output.split(/\r?\n/)) { + if (!line) continue + const fields = line.split("|") + if (fields.length !== 6) return null + const [pidText, parentPidText, groupIdText, startTime = "", bootId = "", startOrder = ""] = fields + const pid = Number.parseInt(pidText ?? "", 10) + const parentPid = Number.parseInt(parentPidText ?? "", 10) + const groupId = Number.parseInt(groupIdText ?? "", 10) + if (!/^\d+$/.test(pidText ?? "") || !/^\d+$/.test(parentPidText ?? "") || !/^\d+$/.test(groupIdText ?? "") || + !Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !startTime || (requireBootId && !bootId)) return null + processes.set(pid, { pid, parentPid, groupId: Number.isInteger(groupId) && groupId > 0 ? groupId : pid, startTime, + ...(bootId ? { bootId } : {}), ...(startOrder ? { startOrder } : {}) }) + } + return processes +} + +function decodeBase64Field(value: string): string | null { + if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return null + } + try { + const bytes = Buffer.from(value, "base64") + if (bytes.toString("base64") !== value) return null + return new TextDecoder("utf-8", { fatal: true }).decode(bytes) + } catch { + return null + } +} + +function parseBase64Snapshot(output: string, prefix = "CODENOMAD_B64|"): Map | null { + const processes = new Map() + for (const line of output.split(/\r?\n/)) { + if (!line) continue + if (!line.startsWith(prefix)) return null + const fields = line.slice(prefix.length).split("|") + if (fields.length !== 5) return null + const [pidText = "", parentPidText = "", groupIdText = "", startEncoded = "", commandEncoded = ""] = fields + if (!/^\d+$/.test(pidText) || !/^\d+$/.test(parentPidText) || !/^\d+$/.test(groupIdText)) return null + const pid = Number.parseInt(pidText, 10) + const parentPid = Number.parseInt(parentPidText, 10) + const groupId = Number.parseInt(groupIdText, 10) + const start = decodeBase64Field(startEncoded) + const command = decodeBase64Field(commandEncoded) + if (pid <= 0 || parentPid < 0 || groupId <= 0 || start === null || command === null) return null + processes.set(pid, { pid, parentPid, groupId, startTime: `${start}\t${command}` }) + } + return processes +} + +function parsePortablePosixSnapshot(output: string, filter?: PosixProcessFilter): Map | null { + const processes = new Map() + const requestedPids = filter?.pids ? new Set(filter.pids) : undefined + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) continue + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/) + if (!match) { + const numeric = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+/) + if (!filter || !numeric || requestedPids?.has(Number(numeric[1])) || Number(numeric[3]) === filter.groupId) return null + continue + } + const [, pidText = "", parentPidText = "", groupIdText = "", start = "", command = ""] = match + const pid = Number.parseInt(pidText, 10) + const parentPid = Number.parseInt(parentPidText, 10) + const groupId = Number.parseInt(groupIdText, 10) + if (pid <= 0 || parentPid < 0 || groupId <= 0) return null + if (filter && !requestedPids?.has(pid) && groupId !== filter.groupId) continue + processes.set(pid, { pid, parentPid, groupId, startTime: `${start}\t${command}` }) + } + return processes +} + +function querySnapshot( + run: () => SpawnSyncReturns, + parse: (output: string) => Map | null, + options: { allowEmpty?: boolean; malformedError?: string; redact?: (error: string) => string } = {}, +): ProcessSnapshot { + const sanitize = options.redact ?? ((error: string) => error) + try { + const result = run() + if (result.status !== 0) return { ok: false, error: sanitize(commandError(result)) } + const processes = parse(String(result.stdout ?? "")) + if (processes && (options.allowEmpty || processes.size > 0)) return { ok: true, processes } + return { ok: false, error: options.malformedError ?? "process identity query returned no parseable processes" } + } catch (error) { + return { ok: false, error: sanitize(error instanceof Error ? error.message : String(error)) } + } +} + +function parsePrefixedSnapshot(output: string, prefix: string): Map | null { + const records: string[] = [] + for (const line of output.split(/\r?\n/)) { + if (!line) continue + if (!line.startsWith(prefix)) return null + records.push(line.slice(prefix.length)) + } + return parseDelimitedSnapshot(records.join("\n"), true) +} + +function parseGuardedResult(result: SpawnSyncReturns): GuardedSignalResult { + const signaled = new Map() + const failure = (error: string): GuardedSignalResult => ({ ok: false, error, + ...(signaled.size > 0 ? { observed: Array.from(signaled.values()) } : {}) }) + let matched: boolean | undefined + let signalSent = false + let cutoff: string | undefined + for (const line of String(result.stdout ?? "").split(/\r?\n/)) { + if (line.startsWith("CODENOMAD_TARGET|") || line.startsWith("CODENOMAD_TARGET_B64|")) { + const parsed = line.startsWith("CODENOMAD_TARGET_B64|") + ? parseBase64Snapshot(line, "CODENOMAD_TARGET_B64|") + : parseDelimitedSnapshot(line.slice("CODENOMAD_TARGET|".length)) + if (!parsed) return failure("guarded signal command returned a malformed target record") + for (const identity of parsed.values()) signaled.set(identity.pid, identity) + continue + } + if (line.startsWith("CODENOMAD_RESULT|")) { + const fields = line.split("|") + if (fields.length !== 4 || !/^[01]$/.test(fields[1] ?? "") || !/^[01]$/.test(fields[3] ?? "")) { + return failure("guarded signal command returned a malformed result record") + } + const [, matchedText, cutoffText, signalSentText] = fields + matched = matchedText === "1" + cutoff = cutoffText || undefined + signalSent = signalSentText === "1" + continue + } + if (line) return failure("guarded signal command returned unexpected output") + } + if (result.status !== 0) return failure(commandError(result)) + return matched === undefined + ? failure("guarded signal command returned no structured result") + : { ok: true, matched, signalSent, signaled: Array.from(signaled.values()), ...(cutoff ? { cutoff } : {}) } +} + +function runGuardedCommand(run: () => SpawnSyncReturns): GuardedSignalResult { + try { + return parseGuardedResult(run()) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} + +const signalName = (signal: NodeJS.Signals): "TERM" | "KILL" => signal === "SIGKILL" ? "KILL" : "TERM" + +function runLinuxScript(spawnCommand: SpawnCommand, script: string, args: string[], timeoutMs: number, + label: string, distro?: string): SpawnSyncReturns { + return distro + ? spawnCommand("wsl.exe", ["--distribution", distro, "--exec", "sh", "-c", script, label, ...args], { encoding: "utf8", timeout: timeoutMs }) + : spawnCommand("sh", ["-c", script, label, ...args], { encoding: "utf8", timeout: timeoutMs }) +} + +const redactToken = (value: string, token: string): string => value.split(token).join("[REDACTED]") + +function shellGuardArgs(request: GuardedSignalRequest, linux: boolean): string[] { + const leader = request.leader + const args = linux + ? [String(leader?.pid ?? 0), leader?.startTime ?? "", leader?.bootId ?? "", String(request.groupId ?? 0), signalName(request.signal)] + : [String(leader?.pid ?? 0), leader?.startTime ?? "", String(request.groupId ?? 0), signalName(request.signal), + request.allowLeaderlessGroup ? "1" : "0", request.cleanupToken ?? ""] + for (const member of request.members) { + args.push(String(member.pid), member.startTime) + if (linux) args.push(member.bootId ?? "") + } + return args +} + +const quotePowerShell = (value: string): string => `'${value.replace(/'/g, "''")}'` + +function buildWindowsGuardedScript(request: GuardedSignalRequest): string { + const leaderPid = request.leader?.pid ?? 0 + const leaderStart = quotePowerShell(request.leader?.startTime ?? "") + const expected = request.members.map( + (identity) => `@{ Pid = ${identity.pid}; Start = ${quotePowerShell(identity.startTime)} }`, + ).join(", ") + return [ + "$ErrorActionPreference = 'Stop'", + `$leaderPid = ${leaderPid}`, + `$leaderStart = ${leaderStart}`, + `$expected = @(${expected})`, + "function Get-CodeNomadStart($process) { return ([datetime]$process.CreationDate).ToUniversalTime().Ticks.ToString() }", + "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", + "$byPid = @{}; $all | ForEach-Object { $byPid[[int]$_.ProcessId] = $_ }", + "$leader = $byPid[$leaderPid]", + "$matched = $null -ne $leader -and (Get-CodeNomadStart $leader) -eq $leaderStart", + "$selected = @()", + "if ($matched) {", + " $ids = @($leaderPid); $changed = $true", + " while ($changed) { $changed = $false; foreach ($process in $all) { if ($ids -contains [int]$process.ParentProcessId -and $ids -notcontains [int]$process.ProcessId) { $ids += [int]$process.ProcessId; $changed = $true } } }", + " $selected = @($all | Where-Object { $ids -contains [int]$_.ProcessId } | Sort-Object ProcessId -Descending)", + "} else {", + " foreach ($item in $expected) { $process = $byPid[[int]$item.Pid]; if ($null -ne $process -and (Get-CodeNomadStart $process) -eq [string]$item.Start) { $selected += $process } }", + "}", + "foreach ($process in $selected) {", + " $start = Get-CodeNomadStart $process", + " '{0}|{1}|0|{2}||{2}' -f [int]$process.ProcessId, [int]$process.ParentProcessId, $start | ForEach-Object { 'CODENOMAD_TARGET|' + $_ }", + "}", + "foreach ($process in $selected) {", + " Invoke-CimMethod -InputObject $process -MethodName Terminate -Arguments @{ Reason = 1 } -ErrorAction Stop | Out-Null", + "}", + "'CODENOMAD_RESULT|' + ($(if ($matched) { '1' } else { '0' })) + '||' + ($(if ($selected.Count -gt 0) { '1' } else { '0' }))", + ].join("; ") +} + +export function sameProcess(left: ProcessIdentity | undefined, right: ProcessIdentity | undefined): boolean { + return Boolean(left && right && left.pid === right.pid && left.startTime === right.startTime && + (!left.bootId || !right.bootId || left.bootId === right.bootId)) +} + +export function startedNoLaterThan(identity: ProcessIdentity, cutoff: string): boolean { + const startOrder = identity.startOrder ?? identity.startTime + if (!/^\d+$/.test(startOrder) || !/^\d+$/.test(cutoff)) return false + try { + return BigInt(startOrder) <= BigInt(cutoff) + } catch { + return false + } +} + +export function descendantsOf(processes: Map, rootPid: number): ProcessIdentity[] { + const descendants: ProcessIdentity[] = [] + const pending = [rootPid] + const seen = new Set(pending) + while (pending.length > 0) { + const parentPid = pending.shift()! + for (const process of processes.values()) { + if (process.parentPid !== parentPid || seen.has(process.pid)) continue + seen.add(process.pid) + pending.push(process.pid) + descendants.push(process) + } + } + return descendants +} + +export function probePosixProcesses(spawnCommand: SpawnCommand, timeoutMs: number, + platform: NodeJS.Platform = process.platform, filter?: PosixProcessFilter): ProcessSnapshot { + if (platform === "linux") { + const pids = filter?.pids?.filter((pid) => Number.isInteger(pid) && pid > 0).map(String) ?? [] + const launchGroupProbe = pids.length === 1 && filter?.groupId === Number(pids[0]) + return querySnapshot( + () => runLinuxScript(spawnCommand, launchGroupProbe ? LINUX_LAUNCH_GROUP_SNAPSHOT_SCRIPT : LINUX_SNAPSHOT_SCRIPT, + launchGroupProbe ? pids : [], timeoutMs, "codenomad-posix-identity"), + (output) => parseDelimitedSnapshot(output, true), + { allowEmpty: Boolean(filter) }, + ) + } + // POSIX has no portable pidfd/start ticks; collect one coherent table instead of probing every PID. + return querySnapshot( + () => spawnCommand("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="], { + encoding: "utf8", timeout: timeoutMs, env: { ...process.env, LC_ALL: "C", LANG: "C" }, + }), + (output) => parsePortablePosixSnapshot(output, filter), + { allowEmpty: Boolean(filter) }, + ) +} + +export function probeWindowsProcesses(spawnCommand: SpawnCommand, timeoutMs: number): ProcessSnapshot { + const script = [ + "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", + "$all | Where-Object { [int]$_.ProcessId -gt 0 } | ForEach-Object { $start = ([datetime]$_.CreationDate).ToUniversalTime().Ticks.ToString(); '{0}|{1}|0|{2}||{2}' -f [int]$_.ProcessId, [int]$_.ParentProcessId, $start }", + ].join("; ") + return querySnapshot( + () => spawnCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: timeoutMs }), + parseDelimitedSnapshot, + ) +} + +export function probeWslProcesses(spawnCommand: SpawnCommand, distro: string, timeoutMs: number): ProcessSnapshot { + return querySnapshot( + () => runLinuxScript(spawnCommand, LINUX_SNAPSHOT_SCRIPT, [], timeoutMs, "codenomad-wsl-identity", distro), + (output) => parseDelimitedSnapshot(output, true), + ) +} + +export function signalPosixProcesses(spawnCommand: SpawnCommand, request: GuardedSignalRequest, + timeoutMs: number, platform: NodeJS.Platform): GuardedSignalResult { + const linux = platform === "linux" + return runGuardedCommand(() => spawnCommand( + "sh", + ["-c", linux ? LINUX_GUARDED_SIGNAL_SCRIPT : POSIX_GUARDED_SIGNAL_SCRIPT, "codenomad-guarded-signal", ...shellGuardArgs(request, linux)], + { encoding: "utf8", timeout: timeoutMs }, + )) +} + +export function signalOwnedPosixProcessGroup(spawnCommand: SpawnCommand, rootPid: number, + signal: NodeJS.Signals, timeoutMs: number): GuardedSignalResult { + return runGuardedCommand(() => spawnCommand( + "sh", + ["-c", POSIX_OWNED_GROUP_SIGNAL_SCRIPT, "codenomad-owned-group-cleanup", String(rootPid), signalName(signal)], + { encoding: "utf8", timeout: timeoutMs }, + )) +} + +export function signalWslProcesses(spawnCommand: SpawnCommand, distro: string, + request: GuardedSignalRequest, timeoutMs: number): GuardedSignalResult { + return runGuardedCommand(() => runLinuxScript( + spawnCommand, + LINUX_GUARDED_SIGNAL_SCRIPT, + shellGuardArgs(request, true), + timeoutMs, + "codenomad-wsl-guarded-signal", + distro, + )) +} + +export function signalWindowsProcesses(spawnCommand: SpawnCommand, request: GuardedSignalRequest, + timeoutMs: number): GuardedSignalResult { + return runGuardedCommand(() => spawnCommand( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", buildWindowsGuardedScript(request)], + { encoding: "utf8", timeout: timeoutMs }, + )) +} + +export function probeLaunchCleanupToken(spawnCommand: SpawnCommand, token: string, + timeoutMs: number, distro?: string): ProcessSnapshot { + return querySnapshot( + () => runLinuxScript( + spawnCommand, + LINUX_TOKEN_SCRIPT, + [LAUNCH_CLEANUP_TOKEN_ENV, token, ""], + timeoutMs, + "codenomad-token-cleanup", + distro, + ), + (output) => parsePrefixedSnapshot(output, "CODENOMAD_PROCESS|"), + { + allowEmpty: true, + malformedError: "launch cleanup probe returned malformed or unexpected output", + redact: (error) => redactToken(error, token), + }, + ) +} + +export function signalLaunchCleanupToken(spawnCommand: SpawnCommand, token: string, + signal: NodeJS.Signals, timeoutMs: number, distro?: string): TokenSignalResult { + const failed = (error: string): TokenSignalResult => ({ ok: false, signalSent: false, targets: [], error }) + try { + const result = runLinuxScript( + spawnCommand, + LINUX_TOKEN_SCRIPT, + [LAUNCH_CLEANUP_TOKEN_ENV, token, signalName(signal)], + timeoutMs, + "codenomad-token-cleanup", + distro, + ) + if (result.status !== 0) return failed(redactToken(commandError(result), token)) + const lines = String(result.stdout ?? "").split(/\r?\n/).filter(Boolean) + const resultLines = lines.filter((line) => line.startsWith("CODENOMAD_RESULT|")) + if (resultLines.length !== 1 || !/^CODENOMAD_RESULT\|[01]$/.test(resultLines[0] ?? "")) { + return failed("launch cleanup signal returned no valid structured result") + } + const targets = parsePrefixedSnapshot( + lines.filter((line) => !line.startsWith("CODENOMAD_RESULT|")).join("\n"), + "CODENOMAD_TARGET|", + ) + return targets + ? { ok: true, signalSent: resultLines[0]!.endsWith("1"), targets: Array.from(targets.values()) } + : failed("launch cleanup signal returned malformed or unexpected output") + } catch (error) { + return failed(redactToken(error instanceof Error ? error.message : String(error), token)) + } +} diff --git a/packages/server/src/workspaces/runtime.test.ts b/packages/server/src/workspaces/runtime.test.ts new file mode 100644 index 00000000..54fb92a9 --- /dev/null +++ b/packages/server/src/workspaces/runtime.test.ts @@ -0,0 +1,265 @@ +import assert from "node:assert/strict" +import type { ChildProcess, SpawnSyncReturns } from "node:child_process" +import { EventEmitter } from "node:events" +import { PassThrough } from "node:stream" +import { describe, it } from "node:test" +import pino from "pino" + +import { EventBus } from "../events/bus" +import { WorkspaceRuntime, WorkspaceRuntimeIdentityCaptureError, WorkspaceStopTimeoutError, + WorkspaceWindowsTreeCleanupIncompleteError, type WorkspaceRuntimeOptions } from "./runtime" +type Timer = ReturnType +type Command = typeof import("node:child_process").spawnSync +type Call = { command: string; args: readonly string[] } +class ManualTimers { + private id = 0 + private pending = new Map void; delay: number }>() + set = (callback: () => void, delay: number) => { const id = ++this.id; this.pending.set(id, { callback, delay }); return id as unknown as Timer } + clear = (timer: Timer) => this.pending.delete(timer as unknown as number) + run(): void { + const next = [...this.pending].sort((a, b) => a[1].delay - b[1].delay || a[0] - b[0])[0] + assert.ok(next, "expected a pending timer") + this.pending.delete(next[0]) + next[1].callback() + } +} +class FakeChild extends EventEmitter { + stdout = new PassThrough() + stderr = new PassThrough() + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + signals: NodeJS.Signals[] = [] + constructor(readonly pid: number | undefined = 4242) { super() } + kill(signal: NodeJS.Signals = "SIGTERM") { this.signals.push(signal); return true } + exit(code: number | null = 0, signal: NodeJS.Signals | null = null) { this.exitCode = code; this.signalCode = signal; this.emit("exit", code, signal) } +} +const result = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => + ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) +const posix = (rows: Array<[number, number, number, string]>, boot = "boot-a") => + rows.map(([pid, ppid, pgid, start]) => `${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`).join("\n") +const portable = (rows: Array<[number, number, number, string, string]>) => + rows.map(([pid, ppid, pgid, start, command]) => `${pid} ${ppid} ${pgid} ${start} ${command}`).join("\n") +const windows = (rows: Array<[number, number, string]>) => + rows.map(([pid, ppid, start], i) => `${pid}|${ppid}|0|${start}||${100 + i}`).join("\n") +const guarded = (matched: boolean, rows: Array<[number, number, number, string]>, boot = "boot-a") => [ + ...rows.map(([pid, ppid, pgid, start]) => `CODENOMAD_TARGET|${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`), + `CODENOMAD_RESULT|${matched ? "1" : "0"}|200|${rows.length ? "1" : "0"}`, +].join("\n") +const token = (rows: Array<[number, number, number, string]>, signal: boolean, boot = "boot-a") => [ + ...rows.map(([pid, ppid, pgid, start]) => `${signal ? "CODENOMAD_TARGET" : "CODENOMAD_PROCESS"}|${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`), + ...(signal ? [`CODENOMAD_RESULT|${rows.length ? "1" : "0"}`] : []), +].join("\n") +const isToken = (args: readonly string[]) => args.includes("codenomad-token-cleanup") +const isSignal = (args: readonly string[]) => isToken(args) && (args.includes("TERM") || args.includes("KILL")) +const isGuarded = (args: readonly string[]) => !isToken(args) && args.some((arg) => arg.includes("guarded-signal") || arg.includes("CODENOMAD_RESULT")) +async function harness(options: WorkspaceRuntimeOptions & { binary?: string; output?: string; report?: boolean } = {}) { + const child = new FakeChild() + const timers = new ManualTimers() + const calls: Call[] = [] + const platform = options.platform ?? "linux" + const command = options.spawnSync ?? ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + const alive = child.exitCode === null && child.signalCode === null + if (isToken(args)) return result(token(alive ? [[4242, 1, 4242, "100"]] : [], isSignal(args))) + if (isGuarded(args)) return result(platform === "win32" + ? "CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1" + : guarded(true, [[4242, 1, 4242, "100"]])) + return result(platform === "win32" + ? windows(alive ? [[4242, 1, "win-start"]] : []) + : posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) + }) as Command + const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { + platform, gracefulStopTimeoutMs: 10, forcedStopTimeoutMs: 10, ...options, + spawnSync: command, setTimeout: timers.set, clearTimeout: timers.clear, + spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, + }) + const abort = new AbortController() + const folder = platform === "win32" && process.platform !== "win32" ? `/${process.cwd()}` : process.cwd() + const launch = runtime.launch({ workspaceId: "w", folder, binaryPath: options.binary ?? "opencode", signal: abort.signal }) + if (options.report !== false) { + queueMicrotask(() => child.stdout.write(options.output ?? "opencode server listening on http://127.0.0.1:4321\n")) + await launch + } + return { runtime, child, timers, calls, launch, abort } +} +describe("workspace runtime lifecycle contracts", () => { + it("captures the Linux launch group with one bounded shell command", async () => { + let launchCall: Call | undefined + await harness({ spawnSync: ((command: string, args: readonly string[]) => { + launchCall ??= { command, args: [...args] } + return result(posix([[4242, 1, 4242, "100"]])) + }) as unknown as Command }) + assert.deepEqual(launchCall?.args.slice(-1), ["4242"]) + }) + + it("cancels before spawn and while waiting for a port without losing retryable cleanup", async () => { + let spawned = false + const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { + spawn: (() => { spawned = true; return new FakeChild() as unknown as ChildProcess }) as typeof import("node:child_process").spawn, + }) + const pre = new AbortController(); pre.abort(new Error("pre-cancelled")) + await assert.rejects(runtime.launch({ workspaceId: "pre", folder: process.cwd(), binaryPath: "opencode", signal: pre.signal }), /pre-cancelled/) + assert.equal(spawned, false) + + let alive = true + const h = await harness({ report: false, spawnSync: ((_command: string, args: readonly string[]) => { + if (isToken(args)) return result(token(alive ? [[4242, 1, 4242, "100"]] : [], isSignal(args))) + if (isGuarded(args)) return result(guarded(true, [[4242, 1, 4242, "100"]])) + return result(posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) + }) as unknown as Command }) + h.abort.abort(new Error("port-cancelled")) + await assert.rejects(h.launch, /port-cancelled/) + const first = h.runtime.stop("w"); h.timers.run(); h.timers.run() + await assert.rejects(first, WorkspaceStopTimeoutError) + alive = false + const retry = h.runtime.stop("w"); h.child.exit(); await retry + + const direct = await harness({ report: false }) + const stopped = direct.runtime.stop("w") + await assert.rejects(direct.launch, /runtime launch was cancelled/) + direct.child.exit() + await stopped + }) + it("rejects launches whose immutable identity cannot be captured and safely cleans up", async () => { + for (const scenario of [{ platform: "linux" as const, binary: "opencode" }, { platform: "win32" as const, binary: "opencode.exe" }]) { + const child = new FakeChild(scenario.platform === "linux" ? undefined : 4242) + const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { + platform: scenario.platform, + spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, + spawnSync: (() => result("", 1, "identity unavailable")) as unknown as Command, + }) + await assert.rejects(runtime.launch({ workspaceId: scenario.platform, folder: process.cwd(), binaryPath: scenario.binary }), WorkspaceRuntimeIdentityCaptureError) + assert.deepEqual(child.signals, ["SIGTERM"]) + assert.doesNotThrow(() => child.emit("error", new Error("late spawn error"))) + } + }) + it("signals identity-matched POSIX, Windows, and WSL processes", async () => { + const scenarios = [ + { name: "POSIX", platform: "linux" as const, binary: "opencode", marker: "codenomad-guarded-signal" }, + { name: "Windows", platform: "win32" as const, binary: "opencode.exe", marker: "CODENOMAD_RESULT" }, + { name: "WSL", platform: "win32" as const, binary: "\\\\wsl$\\Ubuntu\\usr\\bin\\opencode", marker: "codenomad-wsl-guarded-signal", + output: "__CODENOMAD_WSL_PID__:99:99:50:wsl-boot\nopencode server listening on http://127.0.0.1:4321\n" }, + ] + for (const scenario of scenarios) { + let alive = true + const calls: Call[] = [] + const h = await harness({ platform: scenario.platform, binary: scenario.binary, output: scenario.output, spawnSync: ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + const wsl = scenario.name === "WSL" + if (wsl && command === "powershell.exe") return result(windows([[4242, 1, "host-start"]])) + if (isToken(args)) { const rows: Array<[number, number, number, string]> = alive ? [[wsl ? 99 : 4242, 1, wsl ? 99 : 4242, wsl ? "50" : "100"]] : []; if (isSignal(args)) alive = false; return result(token(rows, isSignal(args), wsl ? "wsl-boot" : "boot-a")) } + if (isGuarded(args)) { alive = false; return result(wsl ? guarded(true, [[99, 1, 99, "50"]], "wsl-boot") : scenario.platform === "win32" ? "CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1" : guarded(true, [[4242, 1, 4242, "100"]])) } + return result(wsl + ? posix(alive ? [[99, 1, 99, "50"]] : [[1, 0, 1, "10"]], "wsl-boot") + : scenario.platform === "win32" + ? windows(alive ? [[4242, 1, "win-start"]] : [[1, 0, "system-start"]]) + : posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) + }) as unknown as Command }) + const stop = h.runtime.stop("w") + h.child.exit() + await stop + assert.ok(calls.some(({ args }) => args.some((arg) => arg.includes(scenario.marker))), `${scenario.name} signal`) + assert.equal(calls.some(({ command }) => command === "taskkill.exe"), false) + } + }) + it("does not signal a reused PID or process group", async () => { + let launched = false + const calls: string[][] = [] + const h = await harness({ spawnSync: ((_command: string, args: readonly string[]) => { + calls.push([...args]) + if (isToken(args)) return result(token([], isSignal(args))) + if (isGuarded(args)) return result(guarded(true, [[4242, 1, 4242, "100"]])) + if (!launched) { launched = true; return result(posix([[4242, 1, 4242, "100"]])) } + return result(posix([[4242, 1, 4242, "300"], [6000, 4242, 4242, "150"]])) + }) as unknown as Command }) + await h.runtime.stop("w") + assert.ok(calls.every((args) => !args.includes("6000") && !args.includes("300"))) + }) + it("retains and cleans a portable process group after its leader exits", async () => { + const start = "Fri Jul 10 12:34:56 2026" + let alive = true + let leaderExited = false + const guardedCalls: readonly string[][] = [] + const h = await harness({ platform: "darwin", spawnSync: ((_command: string, args: readonly string[]) => { + if (isGuarded(args)) { + (guardedCalls as string[][]).push([...args]) + alive = false + return result(`CODENOMAD_TARGET_B64|5000|1|4242|${Buffer.from(start).toString("base64")}|${Buffer.from("opencode-child").toString("base64")}\nCODENOMAD_RESULT|1||1`) + } + const rows: Array<[number, number, number, string, string]> = !alive + ? [] + : leaderExited + ? [[5000, 4242, 4242, start, "opencode-child"]] + : [[4242, 1, 4242, start, "opencode"]] + return result(portable(rows)) + }) as unknown as Command }) + + leaderExited = true + h.child.exit(1) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(guardedCalls.length, 1) + assert.equal(guardedCalls[0]?.[7], "1") + assert.equal((h.runtime as unknown as { processes: Map }).processes.size, 0) + }) + it("refuses a leaderless portable group when no retained identity anchor remains", async () => { + const start = "Fri Jul 10 12:34:56 2026" + let leaderExited = false + const guardedCalls: readonly string[][] = [] + const h = await harness({ platform: "darwin", spawnSync: ((_command: string, args: readonly string[]) => { + if (isGuarded(args)) { + (guardedCalls as string[][]).push([...args]) + return result("CODENOMAD_RESULT|0||0") + } + return result(portable(leaderExited + ? [[6000, 1, 4242, "Fri Jul 10 99:99:99 2026", "unverified-process"]] + : [[4242, 1, 4242, start, "opencode"]])) + }) as unknown as Command }) + + leaderExited = true + h.child.exit(1) + const cleanup = h.runtime.stop("w") + h.timers.run(); h.timers.run() + await assert.rejects(cleanup, (error: unknown) => + error instanceof WorkspaceStopTimeoutError && /no longer has a verified identity anchor/.test(error.message)) + assert.equal(guardedCalls.length, 2) + assert.ok(guardedCalls.every((args) => args[7] === "1" && !args.includes("6000"))) + assert.equal((h.runtime as unknown as { processes: Map }).processes.size, 1) + }) + it("bounds direct Windows cleanup without falling back to taskkill", async () => { + const calls: Call[] = [] + const h = await harness({ platform: "win32", binary: "opencode.exe", spawnSync: ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + return isGuarded(args) ? result("CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1") : result(windows([[4242, 1, "win-start"]])) + }) as unknown as Command }) + const stop = h.runtime.stop("w"); h.timers.run(); h.timers.run() + await assert.rejects(stop, WorkspaceStopTimeoutError) + assert.equal(calls.some(({ command }) => command === "taskkill.exe"), false) + assert.equal(calls.filter(({ args }) => isGuarded(args)).length, 2) + }) + it("escalates wrapper cleanup, reports incomplete exited trees, and permits retry", async () => { + let available = false + const calls: Call[] = [] + const h = await harness({ platform: "win32", binary: "opencode.cmd", spawnSync: ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + return available ? result() : result("", 1, "taskkill unavailable") + }) as unknown as Command }) + const first = h.runtime.stop("w"); h.timers.run(); h.timers.run() + await assert.rejects(first, (error: unknown) => error instanceof WorkspaceStopTimeoutError && /\/T \/F failed/.test(error.message)) + assert.deepEqual(calls.map(({ args }) => args), [["/PID", "4242", "/T"], ["/PID", "4242", "/T", "/F"]]) + available = true + const retry = h.runtime.stop("w"); h.child.exit(); await retry + + const exited = await harness({ platform: "win32", binary: "opencode.cmd", spawnSync: (() => result("", 1, "taskkill unavailable")) as unknown as Command }) + const incomplete = exited.runtime.stop("w"); exited.child.exit(1) + await assert.rejects(incomplete, WorkspaceWindowsTreeCleanupIncompleteError) + await assert.rejects(exited.runtime.stop("w"), WorkspaceWindowsTreeCleanupIncompleteError) + }) + it("shares one bounded stop operation across concurrent callers", async () => { + const h = await harness() + const first = h.runtime.stop("w"); const second = h.runtime.stop("w") + assert.strictEqual(first, second) + h.timers.run(); h.timers.run() + const outcomes = await Promise.allSettled([first, second]) + assert.deepEqual(outcomes.map(({ status }) => status), ["rejected", "rejected"]) + }) +}) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index efc77f9a..41adab8c 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -1,10 +1,29 @@ import { ChildProcess, spawn, spawnSync } from "child_process" +import { randomBytes } from "crypto" import { existsSync, statSync } from "fs" import path from "path" import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" import { Logger } from "../logger" -import { buildSpawnSpec, buildWslSignalSpec } from "./spawn" +import { buildSpawnSpec, type SpawnProcessKind } from "./spawn" +import { + descendantsOf, + LAUNCH_CLEANUP_TOKEN_ENV, + probeLaunchCleanupToken, + probePosixProcesses, + probeWindowsProcesses, + probeWslProcesses, + sameProcess, + signalPosixProcesses, + signalOwnedPosixProcessGroup, + signalLaunchCleanupToken, + signalWindowsProcesses, + signalWslProcesses, + startedNoLaterThan, + type GuardedSignalResult, + type ProcessIdentity, + type ProcessSnapshot, +} from "./process-identity" const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:" @@ -28,6 +47,7 @@ interface LaunchOptions { environment?: Record logLevel?: string onExit?: (info: ProcessExitInfo) => void + signal?: AbortSignal } export interface ProcessExitInfo { @@ -37,32 +57,131 @@ export interface ProcessExitInfo { requested: boolean } +interface TrackedProcesses { + leader?: ProcessIdentity + groupId?: number + dispatchCutoff?: string + groupOwnershipRetained?: boolean + groupGoneConfirmed?: boolean + groupOwnershipUncertain?: boolean + members: Map +} + interface ManagedProcess { child: ChildProcess + cleanupToken: string + processKind: SpawnProcessKind + windowsTreeCleanupConfirmed?: boolean + windowsTreeCleanupFailures?: string[] + identityCaptureFailed?: boolean requestedStop: boolean - wsl?: { + stopPromise?: Promise + cancelLaunch?: () => void + finalizeExit?: (code: number | null, signal: NodeJS.Signals | null) => void + targets?: TrackedProcesses + wsl?: TrackedProcesses & { distro: string linuxPid: number | null + linuxPgid: number | null + leaderStartTime: string | null + bootId: string | null + } +} + +type RuntimeTimeout = ReturnType + +export interface WorkspaceRuntimeOptions { + gracefulStopTimeoutMs?: number + forcedStopTimeoutMs?: number + stopCommandTimeoutMs?: number + platform?: NodeJS.Platform + spawn?: typeof spawn + spawnSync?: typeof spawnSync + setTimeout?: (callback: () => void, delayMs: number) => RuntimeTimeout + clearTimeout?: (timer: RuntimeTimeout) => void +} + +export class WorkspaceStopTimeoutError extends Error { + readonly code = "WORKSPACE_STOP_TIMEOUT" + readonly retryable = true + + constructor(workspaceId: string, pid: number | undefined, timeoutMs: number, liveness: string, failures: string[]) { + const failureDetails = failures.length > 0 ? ` Stop failures: ${failures.join("; ")}.` : "" + super( + `Workspace ${workspaceId} process ${pid ?? "with unknown PID"} did not stop within ${timeoutMs}ms; ${liveness}.` + + `${failureDetails} The stop can be retried.`, + ) + this.name = "WorkspaceStopTimeoutError" + } +} + +export class WorkspaceWindowsTreeCleanupIncompleteError extends Error { + readonly code = "WORKSPACE_WINDOWS_TREE_CLEANUP_INCOMPLETE" + readonly retryable = true + + constructor(workspaceId: string, pid: number | undefined, failures: string[]) { + const failureDetails = failures.length > 0 ? ` Stop failures: ${failures.join("; ")}.` : "" + super( + `Workspace ${workspaceId} Windows wrapper ${pid ?? "with unknown PID"} exited before taskkill confirmed process-tree cleanup.` + + `${failureDetails} The workspace record was retained because cleanup is incomplete.`, + ) + this.name = "WorkspaceWindowsTreeCleanupIncompleteError" + } +} + +export class WorkspaceRuntimeIdentityCaptureError extends Error { + readonly code = "WORKSPACE_RUNTIME_IDENTITY_CAPTURE_FAILED" + + constructor(workspaceId: string, detail: string) { + super(`Workspace ${workspaceId} process identity capture failed: ${detail}`) + this.name = "WorkspaceRuntimeIdentityCaptureError" } } export class WorkspaceRuntime { private processes = new Map() + private readonly platform: NodeJS.Platform + private readonly spawnProcess: typeof spawn + private readonly spawnCommand: typeof spawnSync + private readonly scheduleTimeout: (callback: () => void, delayMs: number) => RuntimeTimeout + private readonly cancelTimeout: (timer: RuntimeTimeout) => void + private readonly gracefulStopTimeoutMs: number + private readonly forcedStopTimeoutMs: number + private readonly stopCommandTimeoutMs: number - constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {} + constructor( + private readonly eventBus: EventBus, + private readonly logger: Logger, + options: WorkspaceRuntimeOptions = {}, + ) { + this.platform = options.platform ?? process.platform + this.spawnProcess = options.spawn ?? spawn + this.spawnCommand = options.spawnSync ?? spawnSync + this.scheduleTimeout = options.setTimeout ?? setTimeout + this.cancelTimeout = options.clearTimeout ?? clearTimeout + this.gracefulStopTimeoutMs = Math.max(0, options.gracefulStopTimeoutMs ?? 2000) + this.forcedStopTimeoutMs = Math.max(0, options.forcedStopTimeoutMs ?? 2000) + this.stopCommandTimeoutMs = Math.max(1, options.stopCommandTimeoutMs ?? 1000) + } - async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { + async launch(options: LaunchOptions): Promise<{ + pid: number + port: number + exitPromise: Promise + getLastOutput: () => string + }> { + options.signal?.throwIfAborted() this.validateFolder(options.folder) const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] - const env = { ...process.env, ...(options.environment ?? {}) } + const cleanupToken = randomBytes(32).toString("hex") + const env = { ...process.env, ...(options.environment ?? {}), [LAUNCH_CLEANUP_TOKEN_ENV]: cleanupToken } let exitResolve: ((info: ProcessExitInfo) => void) | null = null const exitPromise = new Promise((resolveExit) => { exitResolve = resolveExit }) - // Store recent output for debugging - keep last 50 lines from each stream const MAX_OUTPUT_LINES = 50 const recentStdout: string[] = [] @@ -81,12 +200,13 @@ export class WorkspaceRuntime { } return new Promise((resolve, reject) => { - const propagatedEnvKeys = Object.keys(options.environment ?? {}) + const propagatedEnvKeys = [...Object.keys(options.environment ?? {}), LAUNCH_CLEANUP_TOKEN_ENV] const spec = buildSpawnSpec(options.binaryPath, args, { cwd: options.folder, env, propagateEnvKeys: propagatedEnvKeys, wslPidMarker: WSL_PID_MARKER, + platform: this.platform, }) const commandLine = [spec.command, ...spec.args].join(" ") this.logger.info( @@ -115,25 +235,80 @@ export class WorkspaceRuntime { }, "OpenCode spawn environment", ) - const detached = process.platform !== "win32" - const child = spawn(spec.command, spec.args, { + const detached = this.platform !== "win32" + const child = this.spawnProcess(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["ignore", "pipe", "pipe"], detached, ...spec.options, }) + const handleEarlyError = (error: Error) => { + this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime failed before launch handlers were ready") + } + child.on("error", handleEarlyError) const managed: ManagedProcess = { child, + cleanupToken, + processKind: spec.processKind, requestedStop: false, - ...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}), + targets: { members: new Map() }, + ...(spec.wsl + ? { + wsl: { + distro: spec.wsl.distro, + linuxPid: null, + linuxPgid: null, + leaderStartTime: null, + bootId: null, + members: new Map(), + }, + } + : {}), } this.processes.set(options.workspaceId, managed) + if (spec.processKind === "posix" || spec.processKind === "wsl" || spec.processKind === "windows-direct") { + const launchSnapshot = child.pid + ? this.platform === "win32" + ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) + : probePosixProcesses( + this.spawnCommand, + this.stopCommandTimeoutMs, + this.platform, + { pids: [child.pid], groupId: child.pid }, + ) + : { ok: false as const, error: "spawned child did not expose a PID" } + const launchLeader = launchSnapshot.ok && child.pid ? launchSnapshot.processes.get(child.pid) : undefined + if (!launchLeader) { + const detail = launchSnapshot.ok + ? `spawned PID ${child.pid ?? "unknown"} was absent from the identity snapshot` + : launchSnapshot.error + this.beginFailedLaunchCleanup(options.workspaceId, managed) + reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, detail)) + return + } + managed.targets!.leader = launchLeader + managed.targets!.groupId = launchLeader.groupId + managed.targets!.groupOwnershipRetained = this.platform !== "linux" && this.platform !== "win32" && + launchLeader.groupId === launchLeader.pid + for (const identity of launchSnapshot.ok ? launchSnapshot.processes.values() : [launchLeader]) { + if (identity.groupId === launchLeader.groupId) managed.targets!.members.set(identity.pid, identity) + } + } let stdoutBuffer = "" let stderrBuffer = "" let portFound = false + let pendingPort: number | null = null + let launchSettled = false + const cancelLaunch = () => { + if (launchSettled) return + launchSettled = true + stopWarningTimer() + reject(options.signal?.reason ?? new Error(`Workspace ${options.workspaceId} runtime launch was cancelled`)) + } + managed.cancelLaunch = cancelLaunch let warningTimer: NodeJS.Timeout | null = null @@ -152,16 +327,24 @@ export class WorkspaceRuntime { startWarningTimer() + options.signal?.addEventListener("abort", cancelLaunch, { once: true }) + if (options.signal?.aborted) cancelLaunch() + const cleanupStreams = () => { stopWarningTimer() child.stdout?.removeAllListeners() child.stderr?.removeAllListeners() } + let finalized = false const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { + if (finalized) return + finalized = true + const cleanupRequired = !managed.requestedStop this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited") - this.processes.delete(options.workspaceId) cleanupStreams() + options.signal?.removeEventListener("abort", cancelLaunch) + managed.cancelLaunch = undefined child.removeListener("error", handleError) child.removeListener("exit", handleExit) const exitInfo: ProcessExitInfo = { @@ -177,27 +360,71 @@ export class WorkspaceRuntime { if (!portFound) { const recentOutput = getLastOutput().trim() const reason = recentOutput || stderrBuffer || `Process exited with code ${code}` - reject(new Error(reason)) + if (!launchSettled) { + launchSettled = true + reject(new Error(reason)) + } } else { options.onExit?.(exitInfo) } + if (cleanupRequired && this.processes.get(options.workspaceId) === managed) { + void this.stop(options.workspaceId).catch((error) => { + this.logger.warn({ workspaceId: options.workspaceId, err: error }, "Unexpected workspace exit cleanup remains pending") + }) + } } + managed.finalizeExit = handleExit const handleError = (error: Error) => { + const cleanupRequired = !managed.requestedStop cleanupStreams() + options.signal?.removeEventListener("abort", cancelLaunch) + managed.cancelLaunch = undefined child.removeListener("exit", handleExit) - this.processes.delete(options.workspaceId) this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") if (exitResolve) { exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop }) exitResolve = null } - reject(error) + if (!launchSettled) { + launchSettled = true + reject(error) + } + if (cleanupRequired && this.processes.get(options.workspaceId) === managed) { + void this.stop(options.workspaceId).catch((stopError) => { + this.logger.warn({ workspaceId: options.workspaceId, err: stopError }, "Workspace error cleanup remains pending") + }) + } } + child.removeListener("error", handleEarlyError) child.on("error", handleError) child.on("exit", handleExit) + const resolveLaunchIfIdentified = () => { + if (launchSettled || pendingPort === null) return + if (managed.wsl && (!managed.wsl.linuxPid || !managed.wsl.linuxPgid || !managed.wsl.leaderStartTime || !managed.wsl.bootId)) { + return + } + portFound = true + launchSettled = true + stopWarningTimer() + options.signal?.removeEventListener("abort", cancelLaunch) + managed.cancelLaunch = undefined + child.removeListener("error", handleError) + this.logger.info({ workspaceId: options.workspaceId, port: pendingPort }, "Workspace runtime allocated port") + resolve({ pid: child.pid!, port: pendingPort, exitPromise, getLastOutput }) + } + + const failWslIdentityCapture = (detail: string) => { + if (launchSettled) return + launchSettled = true + managed.requestedStop = true + cleanupStreams() + this.beginFailedLaunchCleanup(options.workspaceId, managed) + reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, detail)) + } + child.stdout?.on("data", (data: Buffer) => { const text = data.toString() stdoutBuffer += text @@ -209,10 +436,34 @@ export class WorkspaceRuntime { if (!trimmed) continue if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) { - const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10) - if (Number.isFinite(linuxPid) && linuxPid > 0) { + const [linuxPidText, linuxPgidText, linuxStartTime = "", bootId = ""] = trimmed.slice(WSL_PID_MARKER.length).split(":", 4) + const linuxPid = Number.parseInt(linuxPidText ?? "", 10) + const linuxPgid = Number.parseInt(linuxPgidText ?? "", 10) + if (Number.isInteger(linuxPid) && linuxPid > 0 && Number.isInteger(linuxPgid) && linuxPgid > 0 && /^\d+$/.test(linuxStartTime) && bootId) { managed.wsl.linuxPid = linuxPid - this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID") + managed.wsl.linuxPgid = linuxPgid + managed.wsl.leaderStartTime = linuxStartTime + managed.wsl.bootId = bootId + managed.wsl.members.set(linuxPid, { + pid: linuxPid, + parentPid: 0, + groupId: linuxPgid, + startTime: linuxStartTime, + bootId, + startOrder: linuxStartTime, + }) + this.logger.debug( + { + workspaceId: options.workspaceId, + linuxPid, + linuxPgid: managed.wsl.linuxPgid, + linuxStartTime: managed.wsl.leaderStartTime, + }, + "Captured WSL OpenCode process identity", + ) + resolveLaunchIfIdentified() + } else { + failWslIdentityCapture("WSL launcher returned an incomplete Linux PID identity") } continue } @@ -226,13 +477,13 @@ export class WorkspaceRuntime { if (!portFound) { const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) - if (portMatch) { - portFound = true - stopWarningTimer() - child.removeListener("error", handleError) - const port = parseInt(portMatch[1], 10) - this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port") - resolve({ pid: child.pid!, port, exitPromise, getLastOutput }) + if (portMatch && !launchSettled) { + pendingPort = parseInt(portMatch[1], 10) + if (managed.wsl && (!managed.wsl.leaderStartTime || !managed.wsl.bootId)) { + failWslIdentityCapture("WSL process reported a port before its Linux identity") + } else { + resolveLaunchIfIdentified() + } } } } @@ -259,173 +510,323 @@ export class WorkspaceRuntime { }) } - async stop(workspaceId: string): Promise { + private beginFailedLaunchCleanup(workspaceId: string, managed: ManagedProcess): void { + managed.identityCaptureFailed = true + void this.stop(workspaceId).catch((error) => { + this.logger.warn({ workspaceId, err: error }, "Unpublished workspace cleanup remains pending") + }) + if (managed.child.exitCode === null && managed.child.signalCode === null) { + try { + managed.child.kill("SIGTERM") + } catch (error) { + this.logger.debug({ workspaceId, err: error }, "Failed initial live-child cleanup signal") + } + } + } + + stop(workspaceId: string): Promise { const managed = this.processes.get(workspaceId) - if (!managed) return + if (!managed) return Promise.resolve() + if (managed.stopPromise) { + return managed.stopPromise + } + + const stopPromise = this.stopManagedProcess(workspaceId, managed) + managed.stopPromise = stopPromise + void stopPromise.finally(() => { + if (managed.stopPromise === stopPromise) managed.stopPromise = undefined + }).catch(() => undefined) + return stopPromise + } + + private stopManagedProcess(workspaceId: string, managed: ManagedProcess): Promise { managed.requestedStop = true - const child = managed.child + managed.cancelLaunch?.() + managed.cancelLaunch = undefined this.logger.info({ workspaceId }, "Stopping OpenCode process") + if (managed.processKind === "windows-wrapper") return this.stopOwnedWindowsProcess(workspaceId, managed) + const { child } = managed const pid = child.pid - if (!pid) { - this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop") - return - } - - const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null - - const tryKillPosixGroup = (signal: NodeJS.Signals) => { - try { - // Negative PID targets the process group (POSIX). - process.kill(-pid, signal) - return true - } catch (error) { - const err = error as NodeJS.ErrnoException - if (err?.code === "ESRCH") { - return true - } - this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group") - return false + const failures: string[] = [] + const wrapperExited = () => child.exitCode !== null || child.signalCode !== null + const hasWslIdentity = () => Boolean( + managed.wsl?.linuxPid && managed.wsl.linuxPgid && managed.wsl.leaderStartTime && managed.wsl.bootId, + ) + const trackedTarget = () => managed.wsl && hasWslIdentity() ? managed.wsl : managed.targets! + const trackedLeader = (): ProcessIdentity | undefined => { + if (!managed.wsl || !hasWslIdentity()) return managed.targets?.leader + return { + pid: managed.wsl.linuxPid!, + parentPid: 0, + groupId: managed.wsl.linuxPgid!, + startTime: managed.wsl.leaderStartTime!, + bootId: managed.wsl.bootId!, + startOrder: managed.wsl.leaderStartTime!, } } - const tryKillSinglePid = (signal: NodeJS.Signals) => { - try { - process.kill(pid, signal) - return true - } catch (error) { - const err = error as NodeJS.ErrnoException - if (err?.code === "ESRCH") { - return true - } - this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID") - return false - } - } - - const tryTaskkill = (force: boolean) => { - const args = ["/PID", String(pid), "/T"] - if (force) { - args.push("/F") + const refreshTargets = () => { + const target = trackedTarget() + const leader = trackedLeader() + const groupId = managed.wsl && hasWslIdentity() ? managed.wsl.linuxPgid! : target.groupId + const portableGroupId = this.platform !== "linux" && this.platform !== "win32" && + target.groupOwnershipRetained && !target.groupGoneConfirmed ? groupId : undefined + const snapshot = managed.wsl && hasWslIdentity() + ? probeWslProcesses(this.spawnCommand, managed.wsl.distro, this.stopCommandTimeoutMs) + : this.platform === "win32" + ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) + : probePosixProcesses(this.spawnCommand, this.stopCommandTimeoutMs, this.platform, this.platform === "linux" + ? undefined + : { pids: [leader?.pid, ...target.members.keys()].filter((value): value is number => Boolean(value)), groupId: portableGroupId }) + if (!snapshot.ok) { + const platformName = managed.wsl && hasWslIdentity() ? "WSL" : this.platform === "win32" ? "Windows" : "POSIX" + failures.push(`${platformName} identity discovery failed: ${snapshot.error}`) + return { snapshot, aliveMembers: [] as ProcessIdentity[] } } - try { - const result = spawnSync("taskkill", args, { encoding: "utf8" }) - const exitCode = result.status - if (exitCode === 0) { - return true - } - // If the PID is already gone, treat it as success. - const stderr = (result.stderr ?? "").toString().toLowerCase() - const stdout = (result.stdout ?? "").toString().toLowerCase() - const combined = `${stdout}\n${stderr}` - if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) { - return true - } - this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed") - return false - } catch (error) { - this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute") - return false + const leaderMatches = sameProcess(leader, leader ? snapshot.processes.get(leader.pid) : undefined) + const groupLeader = groupId ? snapshot.processes.get(groupId) : undefined + const groupWasReused = Boolean(groupLeader && !sameProcess(leader, groupLeader)) + const retainedAnchorMatches = Boolean(portableGroupId && Array.from(target.members.values()).some((identity) => + sameProcess(identity, snapshot.processes.get(identity.pid)), + )) + if (portableGroupId && groupWasReused) { + target.groupGoneConfirmed = true + target.groupOwnershipUncertain = false } - } - - const trySignalWslProcess = (signal: NodeJS.Signals) => { - if (process.platform !== "win32" || !managed.wsl?.linuxPid) { - return false - } - - try { - const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal) - const result = spawnSync(spec.command, spec.args, { encoding: "utf8" }) - const exitCode = result.status - if (exitCode === 0) { - return true - } - - const stderr = (result.stderr ?? "").toString().toLowerCase() - const stdout = (result.stdout ?? "").toString().toLowerCase() - const combined = `${stdout}\n${stderr}` - if (combined.includes("no such process") || combined.includes("not found")) { - return true - } - - this.logger.debug( - { workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout }, - "WSL kill failed", + for (const process of snapshot.processes.values()) { + const sameBoot = !leader?.bootId || process.bootId === leader.bootId + const withinDispatch = (this.platform === "linux" || Boolean(managed.wsl)) && Boolean( + target.dispatchCutoff && sameBoot && startedNoLaterThan(process, target.dispatchCutoff), ) - return false - } catch (error) { - this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute") - return false + const withinRetainedPortableGroup = Boolean(portableGroupId && !groupWasReused && retainedAnchorMatches) + if (groupId && process.groupId === groupId && (leaderMatches || withinRetainedPortableGroup || (!groupWasReused && withinDispatch))) { + target.members.set(process.pid, process) + } + } + if (portableGroupId && !groupWasReused) { + const groupPresent = Array.from(snapshot.processes.values()).some((process) => process.groupId === portableGroupId) + if (!groupPresent) { + target.groupGoneConfirmed = true + target.groupOwnershipUncertain = false + } else if (!leaderMatches && !retainedAnchorMatches) { + target.groupOwnershipUncertain = true + } + } + if (this.platform === "win32" && !managed.wsl && leaderMatches && leader) { + for (const descendant of descendantsOf(snapshot.processes, leader.pid)) target.members.set(descendant.pid, descendant) + } + const aliveMembers = Array.from(target.members.values()).filter((identity) => + sameProcess(identity, snapshot.processes.get(identity.pid)), + ) + return { snapshot, aliveMembers } + } + + const usesTokenCleanup = () => this.platform === "linux" || Boolean(managed.wsl) + const refreshTokenTargets = (): ProcessSnapshot | undefined => { + if (!usesTokenCleanup()) return + const snapshot = probeLaunchCleanupToken( + this.spawnCommand, managed.cleanupToken, this.stopCommandTimeoutMs, managed.wsl?.distro, + ) + if (!snapshot.ok) failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token discovery failed: ${snapshot.error}`) + else for (const identity of snapshot.processes.values()) trackedTarget().members.set(identity.pid, identity) + return snapshot + } + const recordSignalResult = (result: GuardedSignalResult, target: TrackedProcesses, name: string, signal: NodeJS.Signals) => { + const identities = result.ok ? result.signaled : (result.observed ?? []) + for (const identity of identities) target.members.set(identity.pid, identity) + if (!result.ok) failures.push(`${name} guarded ${signal} failed: ${result.error}`) + else { + if (result.matched && !result.signalSent) failures.push(`${name} guarded ${signal} matched but sent no signal`) + if (result.cutoff) target.dispatchCutoff = result.cutoff } } const sendStopSignal = (signal: NodeJS.Signals) => { - if (process.platform === "win32") { - // WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe. - if (!trySignalWslProcess(signal)) { - // Fallback to the Windows process tree rooted at pid. Use /F only for escalation. - tryTaskkill(signal === "SIGKILL") - } - return + if (!pid) failures.push(`${signal} was not sent because the process PID is unavailable`) + if (pid && wrapperExited() && this.platform !== "linux" && this.platform !== "win32") refreshTargets() + let signaledOwnedGroup = false + if (pid && managed.identityCaptureFailed && this.platform !== "linux" && this.platform !== "win32" && !wrapperExited()) { + const result = signalOwnedPosixProcessGroup(this.spawnCommand, pid, signal, this.stopCommandTimeoutMs) + recordSignalResult(result, managed.targets!, "owned POSIX group", signal) + const leader = result.ok && result.matched ? result.signaled.find((identity) => identity.pid === pid) : undefined + if (leader) Object.assign(managed.targets!, { leader, groupId: pid }) + refreshTargets() + signaledOwnedGroup = true } - - // Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server. - const groupOk = tryKillPosixGroup(signal) - if (!groupOk) { - // Fallback to direct PID kill. - tryKillSinglePid(signal) + if (pid && !signaledOwnedGroup) { + const target = trackedTarget() + const groupId = managed.wsl && hasWslIdentity() ? managed.wsl.linuxPgid! : target.groupId + const request = { + leader: trackedLeader(), groupId, members: [...target.members.values()], signal, + allowLeaderlessGroup: this.platform !== "linux" && this.platform !== "win32" && + Boolean(target.groupOwnershipRetained && !target.groupGoneConfirmed && wrapperExited()), + cleanupToken: this.platform !== "linux" && this.platform !== "win32" ? managed.cleanupToken : undefined, + } + const result = managed.wsl && hasWslIdentity() + ? signalWslProcesses(this.spawnCommand, managed.wsl.distro, request, this.stopCommandTimeoutMs) + : this.platform === "win32" + ? signalWindowsProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs) + : signalPosixProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs, this.platform) + recordSignalResult(result, target, managed.wsl && hasWslIdentity() ? "WSL" : this.platform === "win32" ? "Windows" : "POSIX", signal) + refreshTargets() + } + if (usesTokenCleanup()) { + const result = signalLaunchCleanupToken( + this.spawnCommand, managed.cleanupToken, signal, this.stopCommandTimeoutMs, managed.wsl?.distro, + ) + const name = managed.wsl ? "WSL" : "Linux" + if (!result.ok) failures.push(`${name} launch-token ${signal} failed: ${result.error}`) + else { + if (result.targets.length > 0 && !result.signalSent) failures.push(`${name} launch-token ${signal} matched but sent no signal`) + for (const identity of result.targets) trackedTarget().members.set(identity.pid, identity) + } + refreshTokenTargets() } } - await new Promise((resolve, reject) => { - let escalationTimer: NodeJS.Timeout | null = null - - const cleanup = () => { - child.removeListener("exit", onExit) - child.removeListener("error", onError) - if (escalationTimer) { - clearTimeout(escalationTimer) - escalationTimer = null - } + const probeLiveness = () => { + const refreshed = pid ? refreshTargets() : undefined + const tokenSnapshot = refreshTokenTargets() + if (tokenSnapshot && !tokenSnapshot.ok) { + return { state: "unknown", detail: `${managed.wsl ? "WSL Linux" : "Linux"} launch-token cleanup could not be confirmed` } as const } - - const onExit = () => { - cleanup() - resolve() + if (refreshed && !refreshed.snapshot.ok && (managed.targets?.leader || !tokenSnapshot?.ok)) { + const name = managed.wsl ? "WSL Linux" : this.platform === "win32" ? "Windows" : "POSIX" + return { state: "unknown", detail: `${name} target identity could not be confirmed` } as const } - const onError = (error: Error) => { - cleanup() - reject(error) + if (managed.identityCaptureFailed && this.platform === "win32" && !managed.wsl) { + return { state: "unknown", detail: "Windows cleanup cannot prove exact launch ownership without a Job Object" } as const } - - if (isAlreadyExited()) { - this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited") - cleanup() - resolve() - return + if (managed.identityCaptureFailed && managed.wsl && !managed.targets?.leader && !wrapperExited()) { + return { state: "unknown", detail: "the unidentified Windows WSL wrapper is still alive" } as const } - - child.once("exit", onExit) - child.once("error", onError) - - this.logger.debug( - { workspaceId, pid, detached: process.platform !== "win32" }, - "Sending SIGTERM to workspace process (tree/group)", - ) - sendStopSignal("SIGTERM") - - escalationTimer = setTimeout(() => { - escalationTimer = null - if (isAlreadyExited()) { - this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation") - return - } + if (trackedTarget().groupOwnershipUncertain) { + return { state: "unknown", detail: "the retained POSIX process group no longer has a verified identity anchor" } as const + } + if (trackedTarget().members.size === 0) { + if (tokenSnapshot?.ok && tokenSnapshot.processes.size === 0) return { state: "gone", detail: "no process carries the unpublished launch token" } as const + return { state: "unknown", detail: pid ? "the original process identity was not captured" : "the target PID is unavailable" } as const + } + if ((refreshed?.aliveMembers.length ?? 0) === 0 && (!tokenSnapshot?.ok || tokenSnapshot.processes.size === 0)) { + return { state: "gone", detail: "all tracked original process identities are gone" } as const + } + const name = managed.wsl && hasWslIdentity() ? "WSL Linux process group" : this.platform === "win32" ? "Windows process tree" : "POSIX process group" + return { state: "alive", detail: `the tracked original ${name} is still alive` } as const + } + const stopped = () => probeLiveness().state === "gone" ? true : undefined + const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs + return this.runBoundedStop(workspaceId, managed, { + start: () => { + this.logger.debug({ workspaceId, pid, detached: this.platform !== "win32" }, "Sending SIGTERM to workspace process (tree/group)") + sendStopSignal("SIGTERM") + return stopped() + }, + exit: stopped, + error: (error) => { failures.push(`child process error while stopping: ${error.message}`) }, + escalate: () => { + const liveness = probeLiveness() + if (liveness.state === "gone") return true this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating") sendStopSignal("SIGKILL") - }, 2000) + }, + deadline: () => { + const liveness = probeLiveness() + if (liveness.state === "gone") return true + const prefix = wrapperExited() ? "the wrapper exited but " : "" + return new WorkspaceStopTimeoutError(workspaceId, pid, totalTimeoutMs, `${prefix}${liveness.detail}`, failures) + }, + }) + } + + private stopOwnedWindowsProcess(workspaceId: string, managed: ManagedProcess): Promise { + const { child } = managed + const pid = child.pid + const failures = (managed.windowsTreeCleanupFailures ??= []) + const outcome = () => managed.windowsTreeCleanupConfirmed + ? true + : new WorkspaceWindowsTreeCleanupIncompleteError(workspaceId, pid, failures) + const stopChild = (force: boolean) => { + if (child.exitCode !== null || child.signalCode !== null) return + if (!pid) { + failures.push(`${force ? "forced" : "graceful"} stop was not sent because the process PID is unavailable`) + return + } + const args = ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])] + try { + const result = this.spawnCommand("taskkill.exe", args, { encoding: "utf8", timeout: this.stopCommandTimeoutMs }) + if (result.status === 0) managed.windowsTreeCleanupConfirmed = true + else { + const detail = result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` + failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${detail}`) + } + } catch (error) { + failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs + return this.runBoundedStop(workspaceId, managed, { + start: () => { + if (child.exitCode !== null || child.signalCode !== null) return outcome() + this.logger.debug({ workspaceId, pid }, "Stopping owned Windows workspace wrapper tree") + stopChild(false) + }, + exit: outcome, + error: (error) => { + failures.push(`child process error while stopping: ${error.message}`) + return error + }, + escalate: () => { + this.logger.warn({ workspaceId, pid }, "Owned Windows process did not stop after the graceful attempt, escalating") + stopChild(true) + }, + deadline: () => new WorkspaceStopTimeoutError( + workspaceId, pid, totalTimeoutMs, + child.exitCode !== null || child.signalCode !== null + ? "taskkill did not confirm tree cleanup before the owned Windows wrapper exited" + : "the owned Windows wrapper did not emit exit or error after tree termination", + failures, + ), + }) + } + + private runBoundedStop( + workspaceId: string, + managed: ManagedProcess, + actions: { + start: () => true | Error | void + exit: () => true | Error | void + error: (error: Error) => true | Error | void + escalate: () => true | Error | void + deadline: () => true | Error + }, + ): Promise { + const { child } = managed + return new Promise((resolve, reject) => { + let settled = false + const timers: RuntimeTimeout[] = [] + const finish = (outcome: true | Error | void) => { + if (settled || !outcome) return + settled = true + child.removeListener("exit", onExit) + child.removeListener("error", onError) + for (const timer of timers) this.cancelTimeout(timer) + if (outcome instanceof Error) reject(outcome) + else { + if (this.processes.get(workspaceId) === managed) this.processes.delete(workspaceId) + managed.finalizeExit?.(child.exitCode, child.signalCode) + resolve() + } + } + const onExit = () => finish(actions.exit()) + const onError = (error: Error) => finish(actions.error(error)) + child.once("exit", onExit) + child.on("error", onError) + timers.push(this.scheduleTimeout(() => finish(actions.escalate()), this.gracefulStopTimeoutMs)) + timers.push(this.scheduleTimeout(() => finish(actions.deadline()), this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs)) + finish(actions.start()) }) } diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index f40dcdb0..52ec18d7 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -1,4 +1,5 @@ import { spawnSync } from "child_process" +import { statSync } from "fs" import path from "path" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) @@ -13,10 +14,28 @@ const CODENOMAD_PLUGIN_FILE_SPEC_REGEX = new RegExp( `(${escapeRegex(CODENOMAD_PLUGIN_PACKAGE_NAME)}@file:)([A-Za-z]:[^"\\r\\n]+?\\.tgz)`, ) const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", WSL_PLUGIN_PATH_ENV]) +const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe"]) +const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" +const WINDOWS_SHELL_NAMES = new Set([ + "bash", + "bash.exe", + "cmd", + "cmd.exe", + "command.com", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", + "sh", + "sh.exe", +]) + +export type SpawnProcessKind = "posix" | "windows-direct" | "windows-wrapper" | "wsl" export interface SpawnSpec { command: string args: string[] + processKind: SpawnProcessKind options: { windowsVerbatimArguments?: boolean } @@ -33,6 +52,7 @@ interface BuildSpawnSpecOptions { env?: NodeJS.ProcessEnv propagateEnvKeys?: string[] wslPidMarker?: string + platform?: NodeJS.Platform } interface WslPath { @@ -77,17 +97,21 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option return buildWslSpawnSpec(wslPath, args, options) } - const extension = path.extname(binaryPath).toLowerCase() + const resolvedBinaryPath = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath + const extension = path.win32.extname(resolvedBinaryPath).toLowerCase() if (WINDOWS_CMD_EXTENSIONS.has(extension)) { - const comspec = process.env.ComSpec || "cmd.exe" + const comspec = getWindowsEnvironmentValue(options.env, "COMSPEC") ?? + getWindowsEnvironmentValue(process.env, "COMSPEC") ?? + "cmd.exe" // cmd.exe requires the full command as a single string. // Using the ""