mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-22 14:22:22 +00:00
Release v0.19.0 - Persistent workspaces, customizable panels, provider controls, nested sessions and mobile reliability (#656)
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
## Highlights - **Resume exactly where you left off**: Desktop builds now restore window bounds, zoom, workspace tabs, active sessions, drafts, attachments, scroll positions, panel layout, Yolo state, and expanded session trees across restarts. - **Yolo now works beyond the browser**: Permission auto-accept is server-owned, synchronized across clients, persisted through session metadata, and continues working while the UI is closed. - **Provider usage and model control**: The Status panel can show quota windows and reset times for supported providers, while per-provider visibility controls keep large model catalogs manageable. - **A more adaptable workspace**: Settings were reorganized, chat blocks can be shown, collapsed, or hidden, and right-panel tabs and Status sections can be reordered or disabled. - **Large session trees stay responsive**: Nested sub-sessions can expand recursively, while sidebar virtualization keeps deep or large histories fast. - **Stronger native desktop workflows**: Open project folders, terminals, files, and editors from CodeNomad, update Windows builds through WinGet, and update OpenCode directly from Settings. ## Desktop continuity and native integration - Electron and Tauri now coordinate a single primary desktop snapshot while keeping additional app processes independent. Restoration handles partial startup, cancelled workspaces, stale owners, shutdown races, and cross-platform child-process cleanup. - Native File menus and the command palette expose safe project-folder, terminal, file, and editor actions with canonical path validation. - Tauri gained native About and Help menus, restored the macOS quit shortcut, and can deliver notifications to authenticated remote windows. - Workspace actions are clearer, real paths are resolved when opening folders, local-instance creation is explicitly labelled, and invalid OpenCode configuration is surfaced instead of failing silently. ## Sessions and messaging - Recursive parent/sub-session trees support deep expansion, search, selection, and persisted expanded state. Only visible sidebar rows are mounted, including when the drawer is closed. - Mobile clients now detect real SSE reconnects and reload authoritative state, preventing sessions from remaining stuck or showing duplicated messages after backgrounding. Redundant large-session reloads were also removed. - Conversation follow behavior now respects deliberate scroll-up escape and deterministic bottom pinning. Session loading failures are visible, the project session ceiling was raised, worktrees refresh from OpenCode ready events, and session list scoping was corrected. - Prompt submission focuses the conversation, failed prompts remain in input history, and environment-variable removals persist correctly. - Tool deletion now removes associated reasoning and generated text instead of leaving orphaned output, with clearer destructive-selection overlays. - Markdown gained bracket-style math delimiters and per-code-block line wrapping. Raw HTML is sanitized without swallowing later assistant output, and long file paths keep their relevant segments visible. ## Providers, speech, and settings - Provider quota usage supports a broad set of subscription and API providers, refreshes safely in the background, and keeps the last successful result when a refresh fails. - Provider settings now include model search plus Show all / Hide all controls. Hidden models disappear only from pickers without changing OpenCode defaults or execution. - Speech input and output can use separate OpenAI-compatible providers, API keys, base URLs, and models, with dedicated STT and TTS tests. Early microphone release no longer leaves pending acquisition active. - Settings are split into focused General, Chat, Notifications, Speech, Remote Access, OpenCode, Providers, SideCars, Config files, Advanced, and Info sections. - Thinking, tool output, diagnostics, tool inputs, and token usage each support expanded, collapsed, or hidden presentation modes. Provider authentication and loading are scoped more safely to the active instance. - The right panel now uses a typed registry and bundled manifest lifecycle. Users can show, hide, drag, and reorder native tabs and Status sections, with preferences restored across launches. ## Reliability and platform fixes - Large workspace search counts only relevant fuzzy matches toward its cap, prevents directory-link cycles, and gives the file picker enough space for long paths. - Permission handling ignores stale updates, preserves family-scoped Yolo behavior, and serializes metadata writes so Yolo and worktree data cannot overwrite each other. - Linux workspace startup avoids process-identity timeouts, Windows auto-port selection retries reserved ports, and macOS/Windows/POSIX process cleanup uses stricter ownership checks. - Authentication submits the username shown to the user, provider controls remain usable on narrow screens, and mobile selection actions no longer overlap. - Desktop restoration and workspace startup were hardened against crashes, timeouts, cancelled launches, stale renderer authority, and incomplete shutdown. - Connection indicators better explain mobile network drops, remote Tauri notifications work again, and background-process prompting now follows the shared instance-client path. ## Packaging and distribution - Linux releases now provide a tested Tauri `.deb` installer for Ubuntu 24.04 and a portable Electron `.tar.gz` fallback. Obsolete AppImage, RPM, Flatpak, and zip variants are no longer published. - Windows Tauri users can launch the official silent WinGet upgrade from the update notification, with the GitHub release page retained as fallback. - Release builds synchronize package versions and validate bundled resources, executables, desktop entries, and shared-library closure across the supported artifacts. ## Thanks for contributions Thanks to [@shantur](https://github.com/shantur), [@pascalandr](https://github.com/pascalandr), [@JDis03](https://github.com/JDis03), [@heunghingwan](https://github.com/heunghingwan), [@VooDisss](https://github.com/VooDisss), [@detain](https://github.com/detain), [@bluelovers](https://github.com/bluelovers), [@jollyxenon](https://github.com/jollyxenon), and [@OctoBored](https://github.com/OctoBored). GitHub will append the complete merged pull request and contributor list to the generated release notes. **Full Changelog**: https://github.com/NeuralNomadsAI/CodeNomad/compare/v0.18.0...v0.19.0
This commit is contained in:
commit
db0716e799
455 changed files with 42555 additions and 7285 deletions
236
.github/workflows/build-and-upload.yml
vendored
236
.github/workflows/build-and-upload.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
5
.github/workflows/comment-pr-artifacts.yml
vendored
5
.github/workflows/comment-pr-artifacts.yml
vendored
|
|
@ -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 }
|
||||
|
|
|
|||
131
.github/workflows/pr-build.yml
vendored
131
.github/workflows/pr-build.yml
vendored
|
|
@ -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,129 @@ 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/components/unified-picker-path.test.ts
|
||||
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
|
||||
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
|
||||
packages/ui/src/lib/launch-errors.test.ts
|
||||
packages/ui/src/lib/message-selection-position.test.ts
|
||||
packages/ui/src/lib/model-visibility.test.ts
|
||||
packages/ui/src/lib/runtime-env.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/instance-store.test.ts
|
||||
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
|
||||
packages/ui/src/stores/message-v2/message-status.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/lib/hooks/use-active-session-message-load.test.ts
|
||||
packages/ui/src/stores/instances-restore-ownership.test.ts
|
||||
packages/ui/src/stores/permission-lifecycle.test.ts
|
||||
packages/ui/src/stores/session-request-authority.test.ts
|
||||
packages/ui/src/stores/session-send-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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
BUILD.md
48
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -179,7 +181,7 @@ See full workaround in the original README.
|
|||
|
||||
## Community
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
[](https://star-history.dera.page/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
28
THIRD_PARTY_NOTICES.md
Normal file
28
THIRD_PARTY_NOTICES.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
|
@ -13409,7 +13409,7 @@
|
|||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yaml": "^2.4.2"
|
||||
|
|
@ -13436,7 +13436,7 @@
|
|||
},
|
||||
"packages/opencode-plugin": {
|
||||
"name": "@codenomad/codenomad-opencode-plugin",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.7"
|
||||
|
|
@ -13448,12 +13448,13 @@
|
|||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
|
|
@ -13490,7 +13491,7 @@
|
|||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
|
|
@ -13498,7 +13499,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"minServerVersion": "0.17.0",
|
||||
"minServerVersion": "0.18.0",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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<boolean>
|
||||
}
|
||||
|
||||
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<boolean>((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<void> {
|
||||
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<void> {
|
||||
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"))
|
||||
})
|
||||
344
packages/electron-app/electron/main/client-state-cross-host.ts
Normal file
344
packages/electron-app/electron/main/client-state-cross-host.ts
Normal file
|
|
@ -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<ProcessOwner>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
64
packages/electron-app/electron/main/client-state-ipc.test.ts
Normal file
64
packages/electron-app/electron/main/client-state-ipc.test.ts
Normal file
|
|
@ -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<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>()
|
||||
const listeners = new Map<string, (...args: unknown[]) => 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<unknown>)
|
||||
})
|
||||
|
||||
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"])
|
||||
})
|
||||
78
packages/electron-app/electron/main/client-state-ipc.ts
Normal file
78
packages/electron-app/electron/main/client-state-ipc.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<unknown>
|
||||
stop?: () => Promise<void>
|
||||
nativeFlush?: () => Promise<void>
|
||||
otherWindow?: boolean
|
||||
sessionEndCleanupTimeoutMs?: number
|
||||
sessionEndReleaseTimeoutMs?: number
|
||||
release?: () => Promise<void>
|
||||
} = {}) {
|
||||
const windows = new Map<string, (event?: { preventDefault(): void }) => void>()
|
||||
const appEvents = new Map<string, (event?: { preventDefault(): void }) => 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<void>((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<void>((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<void>((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"])
|
||||
})
|
||||
192
packages/electron-app/electron/main/client-state-lifecycle.ts
Normal file
192
packages/electron-app/electron/main/client-state-lifecycle.ts
Normal file
|
|
@ -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<void> | null = null
|
||||
private sessionEnd: Promise<void> | null = null
|
||||
private exitAllowed = false
|
||||
private trackedMainWindow: BrowserWindow | null = null
|
||||
private windowStateTracker: WindowStateTracker | null = null
|
||||
private windowsHiddenForShutdown = false
|
||||
private primaryRelease: Promise<void> | 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<void> {
|
||||
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<void> {
|
||||
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<void>((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<void> {
|
||||
if (!this.primaryRelease) {
|
||||
this.primaryRelease = this.runStage("primary release", () => this.dependencies.clientStateManager.drainAndReleasePrimary())
|
||||
}
|
||||
return this.primaryRelease
|
||||
}
|
||||
|
||||
private async flushRenderer(window: BrowserWindow | null): Promise<void> {
|
||||
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<void> {
|
||||
if (this.windowStateTracker) await this.windowStateTracker.flush()
|
||||
else await this.dependencies.clientStateManager.flush()
|
||||
}
|
||||
|
||||
private async runStage(name: string, operation: () => Promise<unknown>): Promise<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<unknown> = async () => {}) {
|
||||
return { isDestroyed: () => false, webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript } }
|
||||
}
|
||||
function controller(win: ReturnType<typeof window>, 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<void>((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"])
|
||||
})
|
||||
|
|
@ -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<ClientStateManager, "isPrimary">
|
||||
isTrustedOrigin(url: string): boolean
|
||||
reportFlushError(error: unknown): void
|
||||
}
|
||||
|
||||
export class ClientStateNavigationController {
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
private generation = 0
|
||||
|
||||
constructor(
|
||||
private readonly window: BrowserWindow,
|
||||
private readonly dependencies: ClientStateNavigationDependencies,
|
||||
) {}
|
||||
|
||||
navigate(operation: (window: BrowserWindow, generation: number) => void | Promise<void>): Promise<void> {
|
||||
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<void>,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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> | 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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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
|
||||
}
|
||||
}
|
||||
190
packages/electron-app/electron/main/client-state-process.test.ts
Normal file
190
packages/electron-app/electron/main/client-state-process.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
511
packages/electron-app/electron/main/client-state-process.ts
Normal file
511
packages/electron-app/electron/main/client-state-process.ts
Normal file
|
|
@ -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<ProcessOwner>
|
||||
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")
|
||||
}
|
||||
}
|
||||
282
packages/electron-app/electron/main/client-state.test.ts
Normal file
282
packages/electron-app/electron/main/client-state.test.ts
Normal file
|
|
@ -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<void>((resolve) => { started = resolve })
|
||||
const gate = new Promise<void>((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<void>((resolve) => { started = resolve })
|
||||
const gate = new Promise<void>((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)
|
||||
})
|
||||
511
packages/electron-app/electron/main/client-state.ts
Normal file
511
packages/electron-app/electron/main/client-state.ts
Normal file
|
|
@ -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<void>
|
||||
|
||||
interface ClientStateManagerOptions {
|
||||
crossHostElectionDirectory?: string
|
||||
crossHostDependencies?: CrossHostLeaseDependencies
|
||||
legacyTauriDataPath?: string | null
|
||||
processOwner?: ProcessOwner
|
||||
removeLegacyState?(path: string): void
|
||||
}
|
||||
|
||||
async function writeClientStateTemporary(temporaryPath: string, serializedState: string): Promise<void> {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown> | 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<string, unknown>
|
||||
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<void> = Promise.resolve()
|
||||
private drainAndReleasePromise: Promise<void> | 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<readonly ["electron" | "tauri", string]>
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.writeQueue
|
||||
}
|
||||
|
||||
drainAndReleasePrimary(): Promise<void> {
|
||||
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<readonly ["electron" | "tauri", string]>,
|
||||
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<typeof candidate> => 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<boolean> | 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<boolean> {
|
||||
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<void> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import { requestMicrophoneAccess } from "./permissions"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open"
|
||||
import { setWorkspaceMenuEnabled } from "./menu"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
|
||||
|
|
@ -18,6 +20,34 @@ interface DialogOpenResult {
|
|||
paths: string[]
|
||||
}
|
||||
|
||||
async function resolveLocalWorkspaceFolder(
|
||||
mainWindow: BrowserWindow,
|
||||
cliManager: CliProcessManager,
|
||||
instanceId: string,
|
||||
worktreeSlug: string,
|
||||
): Promise<string> {
|
||||
const baseUrl = cliManager.getStatus().url
|
||||
if (!baseUrl) throw new Error("Local CodeNomad server is unavailable")
|
||||
const cookieName = cliManager.getAuthCookieName()
|
||||
const cookie = (await mainWindow.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0]
|
||||
const headers = cookie ? { Cookie: `${cookie.name}=${cookie.value}` } : undefined
|
||||
const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, { headers })
|
||||
if (!workspaceResponse.ok) throw new Error("Workspace is not active")
|
||||
const workspace = await workspaceResponse.json() as { path?: unknown }
|
||||
if (typeof workspace.path !== "string") throw new Error("Workspace path is unavailable")
|
||||
if (worktreeSlug === "root") return workspace.path
|
||||
|
||||
const worktreeResponse = await fetch(
|
||||
`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`,
|
||||
{ headers },
|
||||
)
|
||||
if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable")
|
||||
const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> }
|
||||
const worktree = payload.worktrees?.find((candidate) => candidate.slug === worktreeSlug)
|
||||
if (!worktree || typeof worktree.directory !== "string") throw new Error("Selected worktree is unavailable")
|
||||
return worktree.directory
|
||||
}
|
||||
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
|
|
@ -41,8 +71,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<DialogOpenResult> => {
|
||||
|
|
@ -89,6 +118,49 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||
return directories
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"workspace:openTarget",
|
||||
async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => {
|
||||
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error("Workspace open requests are limited to the local main window")
|
||||
}
|
||||
const localUrl = cliManager.getStatus().url
|
||||
if (!localUrl || new URL(event.senderFrame.url).origin !== new URL(localUrl).origin) {
|
||||
throw new Error("Workspace open requests require the local CodeNomad origin")
|
||||
}
|
||||
const target = payload?.target
|
||||
const instanceId = payload?.instanceId
|
||||
const worktreeSlug = payload?.worktreeSlug
|
||||
const editor = payload?.editor
|
||||
if (
|
||||
(target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor")
|
||||
|| typeof instanceId !== "string"
|
||||
|| typeof worktreeSlug !== "string"
|
||||
|| (payload.path !== undefined && typeof payload.path !== "string")
|
||||
|| (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium")
|
||||
) {
|
||||
throw new Error("Invalid workspace open request")
|
||||
}
|
||||
const workspaceFolder = await resolveLocalWorkspaceFolder(mainWindow, cliManager, instanceId, worktreeSlug)
|
||||
await openWorkspaceTarget(
|
||||
target as WorkspaceOpenTarget,
|
||||
workspaceFolder,
|
||||
payload.path as string | undefined,
|
||||
editor as WorkspaceEditor | undefined,
|
||||
{ openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path) },
|
||||
)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => {
|
||||
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error("Workspace menu updates are limited to the local main window")
|
||||
}
|
||||
setWorkspaceMenuEnabled(enabled === true)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
|
|
|
|||
|
|
@ -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<number, Set<string>>()
|
||||
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||
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<boolean> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,34 @@
|
|||
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
|
||||
export function createApplicationMenu(mainWindow: BrowserWindow) {
|
||||
interface ApplicationMenuActions {
|
||||
reload(): void
|
||||
forceReload(): void
|
||||
}
|
||||
|
||||
let workspaceActionsRequested = false
|
||||
let applicationMenu: Menu | null = null
|
||||
let localMainWindow: BrowserWindow | null = null
|
||||
|
||||
function updateWorkspaceMenuState() {
|
||||
const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow
|
||||
for (const id of ["open-workspace-folder", "open-workspace-terminal", "open-workspace-editor"]) {
|
||||
const item = applicationMenu?.getMenuItemById(id)
|
||||
if (item) item.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
export function setWorkspaceMenuEnabled(enabled: boolean) {
|
||||
workspaceActionsRequested = enabled
|
||||
updateWorkspaceMenuState()
|
||||
}
|
||||
|
||||
export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) {
|
||||
localMainWindow = mainWindow
|
||||
const isMac = process.platform === "darwin"
|
||||
const sendCommand = (id: string) => () => {
|
||||
if (id.startsWith("open-workspace-") && BrowserWindow.getFocusedWindow() !== mainWindow) return
|
||||
mainWindow.webContents.send("menu:action", id)
|
||||
}
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
|
|
@ -26,9 +53,20 @@ export function createApplicationMenu(mainWindow: BrowserWindow) {
|
|||
{
|
||||
label: "New Instance",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => {
|
||||
mainWindow.webContents.send("menu:newInstance")
|
||||
},
|
||||
click: sendCommand("new-instance"),
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
{ id: "open-workspace-folder", label: "Open Project Folder", click: sendCommand("open-workspace-folder") },
|
||||
{ id: "open-workspace-terminal", label: "Open Terminal Here", click: sendCommand("open-workspace-terminal") },
|
||||
{
|
||||
id: "open-workspace-editor",
|
||||
label: "Open Project In",
|
||||
submenu: [
|
||||
{ label: "VS Code", click: sendCommand("open-workspace-editor-vscode") },
|
||||
{ label: "Cursor", click: sendCommand("open-workspace-editor-cursor") },
|
||||
{ label: "Zed", click: sendCommand("open-workspace-editor-zed") },
|
||||
{ label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") },
|
||||
],
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
isMac ? { role: "close" as const } : { role: "quit" as const },
|
||||
|
|
@ -51,8 +89,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 },
|
||||
|
|
@ -80,5 +118,14 @@ export function createApplicationMenu(mainWindow: BrowserWindow) {
|
|||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
applicationMenu = menu
|
||||
Menu.setApplicationMenu(menu)
|
||||
updateWorkspaceMenuState()
|
||||
mainWindow.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => {
|
||||
if (!isMainFrame) return
|
||||
workspaceActionsRequested = false
|
||||
updateWorkspaceMenuState()
|
||||
})
|
||||
app.on("browser-window-focus", updateWorkspaceMenuState)
|
||||
app.on("browser-window-blur", updateWorkspaceMenuState)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>
|
||||
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<CliStatus> {
|
||||
start(options: StartOptions): Promise<CliStatus> {
|
||||
return this.lifecycle.enqueue(() => this.startNow(options))
|
||||
}
|
||||
|
||||
restart(options: StartOptions): Promise<CliStatus> {
|
||||
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<void> {
|
||||
return this.lifecycle.enqueue(() => this.stopNow())
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
return this.lifecycle.stop(() => this.stopNow())
|
||||
}
|
||||
|
||||
private async startNow(options: StartOptions): Promise<CliStatus> {
|
||||
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<CliStatus>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
|
@ -284,18 +264,14 @@ export class CliProcessManager extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
private async stopNow(): Promise<void> {
|
||||
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<boolean> | 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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
542
packages/electron-app/electron/main/process-stop.test.ts
Normal file
542
packages/electron-app/electron/main/process-stop.test.ts
Normal file
|
|
@ -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<boolean>(() => {}),
|
||||
}), /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<boolean>(() => {}),
|
||||
}), /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<typeof mergeCapturedProcessTrees>
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
361
packages/electron-app/electron/main/process-stop.ts
Normal file
361
packages/electron-app/electron/main/process-stop.ts
Normal file
|
|
@ -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> | 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> | 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<AsyncCommandResult> {
|
||||
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<ProcessRow[] | undefined> {
|
||||
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<CapturedProcessTree | undefined> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let hardTimer: ReturnType<typeof setTimeout> | 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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<unknown>): 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/)
|
||||
})
|
||||
|
|
@ -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<unknown>
|
||||
}
|
||||
|
||||
export interface RendererFlushWindow {
|
||||
isDestroyed(): boolean
|
||||
webContents: RendererFlushWebContents
|
||||
}
|
||||
|
||||
export type RendererFlushResult = "flushed" | "not-primary" | "window-unavailable" | "untrusted-origin"
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((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<RendererFlushResult> {
|
||||
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"
|
||||
}
|
||||
25
packages/electron-app/electron/main/renderer-origin.test.ts
Normal file
25
packages/electron-app/electron/main/renderer-origin.test.ts
Normal file
|
|
@ -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"],
|
||||
)
|
||||
})
|
||||
24
packages/electron-app/electron/main/renderer-origin.ts
Normal file
24
packages/electron-app/electron/main/renderer-origin.ts
Normal file
|
|
@ -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 | undefined>,
|
||||
): string[] {
|
||||
const candidates = isPackaged ? [currentCliUrl] : [currentCliUrl, ...devCandidates]
|
||||
const origins = new Set<string>()
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch {}
|
||||
}
|
||||
return [...origins]
|
||||
}
|
||||
|
|
@ -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<void>((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)
|
||||
})
|
||||
22
packages/electron-app/electron/main/serialized-lifecycle.ts
Normal file
22
packages/electron-app/electron/main/serialized-lifecycle.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export class SerializedLifecycle {
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
stopped = false
|
||||
|
||||
enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const queued = this.tail.catch(() => {}).then(operation)
|
||||
this.tail = queued.then(() => {}, () => {})
|
||||
return queued
|
||||
}
|
||||
|
||||
stop<T>(operation: () => Promise<T>): Promise<T> {
|
||||
this.stopped = true
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
this.stopped = false
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
110
packages/electron-app/electron/main/window-state.test.ts
Normal file
110
packages/electron-app/electron/main/window-state.test.ts
Normal file
|
|
@ -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<string, (...args: any[]) => 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<string, (...args: any[]) => 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)
|
||||
})
|
||||
252
packages/electron-app/electron/main/window-state.ts
Normal file
252
packages/electron-app/electron/main/window-state.ts
Normal file
|
|
@ -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<NativeWindowState>
|
||||
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<typeof setTimeout> | 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<void> {
|
||||
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<boolean> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
80
packages/electron-app/electron/main/workspace-open.test.ts
Normal file
80
packages/electron-app/electron/main/workspace-open.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import test from "node:test"
|
||||
import type { ChildProcess } from "node:child_process"
|
||||
import { defaultOpenMode, editorCandidates, openWorkspaceTarget } from "./workspace-open"
|
||||
|
||||
test("selects only the requested editor", () => {
|
||||
assert.deepEqual(editorCandidates("zed", "linux", {}), [{ command: "zed", verifyStart: true }])
|
||||
assert.deepEqual(editorCandidates("vscodium", "darwin", {}), [
|
||||
{ command: "/usr/bin/open", args: ["-a", "VSCodium"], waitForExit: true },
|
||||
])
|
||||
})
|
||||
|
||||
test("opens only paths inside the workspace", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-workspace-"))
|
||||
const outside = mkdtempSync(join(tmpdir(), "codenomad-outside-"))
|
||||
const opened: string[] = []
|
||||
const dependencies = {
|
||||
openPath: async (path: string) => { opened.push(path); return "" },
|
||||
revealPath: () => undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
await openWorkspaceTarget("default", root, ".", undefined, dependencies)
|
||||
assert.deepEqual(opened, [root])
|
||||
await assert.rejects(openWorkspaceTarget("default", root, outside, undefined, dependencies), /outside the workspace/)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("edits Windows scripts and rejects executable default-open targets", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-executable-"))
|
||||
const script = join(root, "run.cmd")
|
||||
writeFileSync(script, "echo unsafe")
|
||||
try {
|
||||
assert.equal(defaultOpenMode(script, "win32"), "edit")
|
||||
const document = join(root, "notes.txt")
|
||||
writeFileSync(document, "safe")
|
||||
assert.equal(defaultOpenMode(document, "win32"), "open")
|
||||
const executable = join(root, "run.exe")
|
||||
writeFileSync(executable, "unsafe")
|
||||
assert.equal(defaultOpenMode(executable, "win32"), "choose")
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("launches terminals in the selected directory without a shell", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-terminal-"))
|
||||
const nested = join(root, "nested")
|
||||
mkdirSync(nested)
|
||||
const launches: Array<{ command: string; args: readonly string[]; cwd: string }> = []
|
||||
const spawnProcess = (command: string, args: readonly string[], options: { cwd: string }) => {
|
||||
launches.push({ command, args, cwd: options.cwd })
|
||||
const child = new EventEmitter() as ChildProcess
|
||||
child.unref = () => child
|
||||
queueMicrotask(() => {
|
||||
child.emit("spawn")
|
||||
child.emit("exit", 0)
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
try {
|
||||
await openWorkspaceTarget("terminal", root, "nested", undefined, {
|
||||
openPath: async () => "",
|
||||
revealPath: () => undefined,
|
||||
spawnProcess,
|
||||
})
|
||||
assert.equal(launches[0]?.cwd, nested)
|
||||
assert.equal(launches[0]?.args.includes(nested), false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
238
packages/electron-app/electron/main/workspace-open.ts
Normal file
238
packages/electron-app/electron/main/workspace-open.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { existsSync, realpathSync, statSync } from "node:fs"
|
||||
import { extname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
|
||||
export type WorkspaceOpenTarget = "default" | "reveal" | "terminal" | "editor"
|
||||
export type WorkspaceEditor = "vscode" | "cursor" | "zed" | "vscodium"
|
||||
|
||||
interface LaunchCandidate {
|
||||
command: string
|
||||
args?: string[]
|
||||
passPath?: boolean
|
||||
waitForExit?: boolean
|
||||
verifyStart?: boolean
|
||||
}
|
||||
|
||||
type SpawnProcess = (command: string, args: readonly string[], options: {
|
||||
cwd: string
|
||||
detached: boolean
|
||||
stdio: "ignore"
|
||||
}) => ChildProcess
|
||||
|
||||
interface WorkspaceOpenDependencies {
|
||||
openPath: (path: string) => Promise<string>
|
||||
revealPath: (path: string) => void
|
||||
spawnProcess?: SpawnProcess
|
||||
}
|
||||
|
||||
const BLOCKED_DEFAULT_OPEN_EXTENSIONS = new Set([
|
||||
".appimage", ".application", ".chm", ".com", ".cpl", ".desktop", ".exe", ".jar", ".lnk", ".msi",
|
||||
".msp", ".pif", ".scr", ".url",
|
||||
])
|
||||
|
||||
const SAFE_WINDOWS_OPEN_EXTENSIONS = new Set([
|
||||
".7z", ".avi", ".bmp", ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".csv", ".dart",
|
||||
".diff", ".docx", ".env", ".flac", ".fs", ".fsx", ".gif", ".go", ".gz", ".h", ".hpp",
|
||||
".htm", ".html", ".ini", ".java", ".jpeg", ".jpg", ".json", ".jsonc", ".jsx", ".kt", ".kts",
|
||||
".less", ".lock", ".log", ".lua", ".md", ".markdown", ".mkv", ".mov", ".mp3", ".mp4", ".ogg",
|
||||
".patch", ".pdf", ".png", ".pptx", ".r", ".rar", ".rmd", ".rs", ".scss", ".sql", ".svg",
|
||||
".svelte", ".swift", ".tar", ".toml", ".ts", ".tsx", ".txt", ".vue", ".wav", ".webm", ".webp",
|
||||
".xlsx", ".xml", ".yaml", ".yml", ".zip",
|
||||
])
|
||||
|
||||
const WINDOWS_EDIT_EXTENSIONS = new Set([
|
||||
".bat", ".cmd", ".js", ".jse", ".pl", ".ps1", ".psd1", ".psm1", ".py", ".pyw", ".rb", ".reg",
|
||||
".vbe", ".vbs", ".wsf", ".wsh",
|
||||
])
|
||||
|
||||
export function defaultOpenMode(path: string, platform: NodeJS.Platform): "open" | "edit" | "choose" {
|
||||
const stats = statSync(path)
|
||||
const extension = extname(path).toLowerCase()
|
||||
if (stats.isDirectory()) {
|
||||
if (platform === "darwin" && extension === ".app") throw new Error("Application bundles cannot be opened externally")
|
||||
return "open"
|
||||
}
|
||||
if (platform === "win32") {
|
||||
if (SAFE_WINDOWS_OPEN_EXTENSIONS.has(extension)) return "open"
|
||||
if (WINDOWS_EDIT_EXTENSIONS.has(extension)) return "edit"
|
||||
return "choose"
|
||||
}
|
||||
if (BLOCKED_DEFAULT_OPEN_EXTENSIONS.has(extension)) {
|
||||
throw new Error("Executable files cannot be opened externally")
|
||||
}
|
||||
if (stats.isFile() && (stats.mode & 0o111) !== 0) {
|
||||
throw new Error("Executable files cannot be opened externally")
|
||||
}
|
||||
return "open"
|
||||
}
|
||||
|
||||
export function editorCandidates(editor: WorkspaceEditor, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): LaunchCandidate[] {
|
||||
if (platform === "darwin") {
|
||||
const names: Record<WorkspaceEditor, string> = {
|
||||
vscode: "Visual Studio Code",
|
||||
cursor: "Cursor",
|
||||
zed: "Zed",
|
||||
vscodium: "VSCodium",
|
||||
}
|
||||
return [{ command: "/usr/bin/open", args: ["-a", names[editor]], waitForExit: true }]
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
const paths: Record<WorkspaceEditor, Array<string | undefined>> = {
|
||||
vscode: [
|
||||
env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Microsoft VS Code", "Code.exe"),
|
||||
env.ProgramFiles && join(env.ProgramFiles, "Microsoft VS Code", "Code.exe"),
|
||||
],
|
||||
cursor: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "cursor", "Cursor.exe")],
|
||||
zed: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Zed", "Zed.exe")],
|
||||
vscodium: [
|
||||
env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "VSCodium", "VSCodium.exe"),
|
||||
env.ProgramFiles && join(env.ProgramFiles, "VSCodium", "VSCodium.exe"),
|
||||
],
|
||||
}
|
||||
return paths[editor]
|
||||
.filter((command): command is string => Boolean(command))
|
||||
.filter((command) => !isAbsolute(command) || existsSync(command))
|
||||
.map((command) => ({ command }))
|
||||
}
|
||||
|
||||
const commands: Record<WorkspaceEditor, string> = { vscode: "code", cursor: "cursor", zed: "zed", vscodium: "codium" }
|
||||
return [{ command: commands[editor], verifyStart: true }]
|
||||
}
|
||||
|
||||
export function terminalCandidates(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, folder = "."): LaunchCandidate[] {
|
||||
if (platform === "darwin") return [{ command: "/usr/bin/open", args: ["-a", "Terminal"], waitForExit: true }]
|
||||
if (platform === "win32") {
|
||||
const systemRoot = env.SystemRoot?.trim() || "C:\\Windows"
|
||||
const command = env.ComSpec?.trim() || join(systemRoot, "System32", "cmd.exe")
|
||||
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
const script = `Set-Location -LiteralPath '${folder.replace(/'/g, "''")}'`
|
||||
const encoded = Buffer.from(script, "utf16le").toString("base64")
|
||||
return [{
|
||||
command,
|
||||
args: ["/D", "/C", "start", "", powershell, "-NoExit", "-EncodedCommand", encoded],
|
||||
passPath: false,
|
||||
waitForExit: true,
|
||||
}]
|
||||
}
|
||||
|
||||
const configured = env.TERMINAL?.trim()
|
||||
return [
|
||||
...(configured ? [{ command: configured, passPath: false, verifyStart: true }] : []),
|
||||
...["xdg-terminal-exec", "x-terminal-emulator", "gnome-terminal", "konsole", "kitty", "alacritty", "wezterm"]
|
||||
.map((command) => ({ command, passPath: false, verifyStart: true })),
|
||||
]
|
||||
}
|
||||
|
||||
function launch(candidate: LaunchCandidate, selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let verificationTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (verificationTimer) clearTimeout(verificationTimer)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const args = [...(candidate.args ?? []), ...(candidate.passPath === false ? [] : [selectedPath])]
|
||||
const child = spawnProcess(candidate.command, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
if (!candidate.waitForExit) {
|
||||
if (candidate.verifyStart) {
|
||||
verificationTimer = setTimeout(() => {
|
||||
child.unref()
|
||||
finish()
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
child.unref()
|
||||
finish()
|
||||
}
|
||||
})
|
||||
child.once("exit", (code) => {
|
||||
if (!candidate.waitForExit && !candidate.verifyStart) return
|
||||
if (code === 0) finish()
|
||||
else finish(new Error(`${candidate.command} exited with code ${code ?? "unknown"}`))
|
||||
})
|
||||
child.once("error", (error) => finish(error))
|
||||
})
|
||||
}
|
||||
|
||||
async function launchFirst(candidates: LaunchCandidate[], selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise<void> {
|
||||
let lastError: unknown
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await launch(candidate, selectedPath, cwd, spawnProcess)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("No supported application was found")
|
||||
}
|
||||
|
||||
export async function openWorkspaceTarget(
|
||||
target: WorkspaceOpenTarget,
|
||||
workspaceFolder: string,
|
||||
path = ".",
|
||||
editor?: WorkspaceEditor,
|
||||
dependencies?: WorkspaceOpenDependencies,
|
||||
): Promise<void> {
|
||||
if (!dependencies) throw new Error("Native workspace openers are unavailable")
|
||||
const spawnProcess = dependencies.spawnProcess ?? spawn
|
||||
const root = realpathSync(workspaceFolder)
|
||||
if (!statSync(root).isDirectory()) throw new Error("Workspace folder does not exist")
|
||||
const selectedPath = realpathSync(resolve(root, path))
|
||||
const relativePath = relative(root, selectedPath)
|
||||
if (relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(relativePath)) {
|
||||
throw new Error("Selected path is outside the workspace")
|
||||
}
|
||||
const cwd = statSync(selectedPath).isDirectory() ? selectedPath : resolve(selectedPath, "..")
|
||||
|
||||
if (target === "default") {
|
||||
const mode = defaultOpenMode(selectedPath, process.platform)
|
||||
if (mode === "edit" || mode === "choose") {
|
||||
const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows"
|
||||
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
const rundll32 = join(systemRoot, "System32", "rundll32.exe")
|
||||
const script = `$ErrorActionPreference='Stop'; Start-Process -FilePath '${selectedPath.replace(/'/g, "''")}' -Verb Edit`
|
||||
const encoded = Buffer.from(script, "utf16le").toString("base64")
|
||||
const candidates: LaunchCandidate[] = [
|
||||
...(mode === "edit" ? [{
|
||||
command: powershell,
|
||||
args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
||||
passPath: false,
|
||||
waitForExit: true,
|
||||
}] : []),
|
||||
{
|
||||
command: rundll32,
|
||||
args: ["shell32.dll,OpenAs_RunDLL"],
|
||||
verifyStart: true,
|
||||
},
|
||||
]
|
||||
await launchFirst(candidates, selectedPath, cwd, spawnProcess)
|
||||
return
|
||||
}
|
||||
const error = await dependencies.openPath(selectedPath)
|
||||
if (error) throw new Error(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (target === "reveal") {
|
||||
dependencies.revealPath(selectedPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (!statSync(selectedPath).isDirectory() && target === "terminal") throw new Error("Terminal target is not a folder")
|
||||
if (target === "editor" && !editor) throw new Error("Editor is required")
|
||||
|
||||
const candidates = target === "terminal"
|
||||
? terminalCandidates(process.platform, process.env, selectedPath)
|
||||
: editorCandidates(editor!, process.platform, process.env)
|
||||
await launchFirst(candidates, selectedPath, cwd, spawnProcess)
|
||||
}
|
||||
|
|
@ -26,6 +26,13 @@ const localElectronAPI = {
|
|||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||
openWorkspaceTarget: (payload) => ipcRenderer.invoke("workspace:openTarget", payload),
|
||||
setWorkspaceMenuEnabled: (enabled) => ipcRenderer.invoke("workspace:setMenuEnabled", Boolean(enabled)),
|
||||
onMenuAction: (callback) => {
|
||||
const handler = (_event, action) => callback(action)
|
||||
ipcRenderer.on("menu:action", handler)
|
||||
return () => ipcRenderer.removeListener("menu:action", handler)
|
||||
},
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file)
|
||||
|
|
@ -37,6 +44,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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
|
@ -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 electron/main/workspace-open.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}",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@codenomad/codenomad-opencode-plugin",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
26
packages/server/THIRD_PARTY_NOTICES.md
Normal file
26
packages/server/THIRD_PARTY_NOTICES.md
Normal file
|
|
@ -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.
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, ProviderUsageWindow>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
153
packages/server/src/background-processes/manager.test.ts
Normal file
153
packages/server/src/background-processes/manager.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { promises as fs } from "node:fs"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
|
||||
import { BackgroundProcessManager } from "./manager"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const WORKSPACE_ID = "ws-test"
|
||||
const SESSION_ID = "sess-1"
|
||||
const INSTANCE_PORT = 9999
|
||||
const AUTH_HEADER = "Basic test-auth"
|
||||
const TERMINAL_TIMEOUT_MS = 3000
|
||||
|
||||
interface CapturedRequest {
|
||||
method: string
|
||||
url: string
|
||||
headers: Headers
|
||||
body: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the real {@link BackgroundProcessManager} lifecycle (spawn a
|
||||
* fast-exiting command with notify enabled) against a mocked transport, so the
|
||||
* migrated `sendCompletionPrompt` path — factory + SDK client + `fetch` — is
|
||||
* exercised end to end without touching production wiring.
|
||||
*
|
||||
* The workspace temp directory is intentionally left in place (under
|
||||
* `os.tmpdir()`, OS-reaped): removing it from the test races the manager's
|
||||
* asynchronous finalization writes, which intermittently fail with ENOENT.
|
||||
*/
|
||||
async function runCompletionPrompt(
|
||||
fetchImpl: (input: Request, init: RequestInit | undefined) => Promise<Response>,
|
||||
): Promise<{ requests: CapturedRequest[]; warned: boolean; directory: string }> {
|
||||
const requests: CapturedRequest[] = []
|
||||
const originalFetch = globalThis.fetch
|
||||
// Captured now but swapped in only inside the try below, so a failure during
|
||||
// setup (mkdtemp, manager construction) can't leak the mocked fetch.
|
||||
const fetchMock = (async (input: any, init: any) => {
|
||||
const req = input instanceof Request ? input : new Request(String(input), init)
|
||||
requests.push({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: await req.text(),
|
||||
})
|
||||
return fetchImpl(input instanceof Request ? input : req, init)
|
||||
}) as typeof fetch
|
||||
|
||||
let warned = false
|
||||
const logger = {
|
||||
warn: () => { warned = true },
|
||||
debug: () => {},
|
||||
trace: () => {},
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
fatal: () => {},
|
||||
isLevelEnabled: () => false,
|
||||
level: "info",
|
||||
child: () => logger,
|
||||
} as unknown as Logger
|
||||
|
||||
const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "bp-test-"))
|
||||
// Distinct from the workspace root so the directory-override assertion is
|
||||
// discriminating: if `sendCompletionPrompt` stops passing `notify.directory`,
|
||||
// the factory would fall back to `workspacePath` and the header check fails.
|
||||
const sessionDir = path.join(workspacePath, "session-worktree")
|
||||
|
||||
// Resolve once the manager publishes a terminal (non-running) status update.
|
||||
let resolveTerminal: () => void = () => {}
|
||||
const terminal = new Promise<void>((resolve) => { resolveTerminal = resolve })
|
||||
const eventBus = {
|
||||
on: () => {},
|
||||
publish: (event: any) => {
|
||||
if (event?.type === "instance.event") {
|
||||
const type = event?.event?.type
|
||||
const status = event?.event?.properties?.process?.status
|
||||
if (type === "background.process.removed" || (status && status !== "running")) resolveTerminal()
|
||||
}
|
||||
return true
|
||||
},
|
||||
} as unknown as EventBus
|
||||
|
||||
const workspaceManager = {
|
||||
get: () => ({ path: workspacePath }),
|
||||
getInstancePort: () => INSTANCE_PORT,
|
||||
getInstanceAuthorizationHeader: () => AUTH_HEADER,
|
||||
} as unknown as WorkspaceManager
|
||||
|
||||
const manager = new BackgroundProcessManager({ workspaceManager, eventBus, logger })
|
||||
|
||||
try {
|
||||
globalThis.fetch = fetchMock
|
||||
await manager.start(WORKSPACE_ID, "test-proc", "true", {
|
||||
notify: true,
|
||||
notification: { sessionID: SESSION_ID, directory: sessionDir },
|
||||
})
|
||||
// The terminal status update is published at the very end of finalize, so
|
||||
// resolving on it is a deterministic completion signal. Fail loudly rather
|
||||
// than racing a silent timeout that could mask a hang.
|
||||
let timeoutHandle: NodeJS.Timeout | undefined
|
||||
const reachedTerminal = await Promise.race([
|
||||
terminal.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timeoutHandle = setTimeout(() => resolve(false), TERMINAL_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
if (!reachedTerminal) {
|
||||
throw new Error("background process did not reach a terminal state in time")
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
return { requests, warned, directory: sessionDir }
|
||||
}
|
||||
|
||||
describe("BackgroundProcessManager.sendCompletionPrompt", () => {
|
||||
it("posts the synthetic completion prompt to the instance via the SDK route", async () => {
|
||||
const { requests, directory } = await runCompletionPrompt(async () =>
|
||||
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
|
||||
)
|
||||
|
||||
const promptCall = requests.find((r) => r.url.includes("/prompt_async"))
|
||||
assert.ok(promptCall, "expected a prompt_async request")
|
||||
assert.equal(promptCall.method, "POST")
|
||||
assert.equal(
|
||||
promptCall.url,
|
||||
`http://127.0.0.1:${INSTANCE_PORT}/session/${SESSION_ID}/prompt_async`,
|
||||
)
|
||||
assert.equal(promptCall.headers.get("authorization"), AUTH_HEADER)
|
||||
// The prompt is scoped to the session's directory (a POST keeps the
|
||||
// directory as a header — the SDK only rewrites header→query for GET/HEAD).
|
||||
assert.equal(promptCall.headers.get("x-opencode-directory"), encodeURIComponent(directory))
|
||||
|
||||
const body = JSON.parse(promptCall.body)
|
||||
assert.equal(body.parts.length, 1)
|
||||
assert.equal(body.parts[0].type, "text")
|
||||
assert.equal(body.parts[0].synthetic, true)
|
||||
assert.match(body.parts[0].text, /test-proc/)
|
||||
})
|
||||
|
||||
it("swallows a failed prompt and logs it without aborting finalization", async () => {
|
||||
const { warned } = await runCompletionPrompt(async () =>
|
||||
new Response("boom", { status: 500 }),
|
||||
)
|
||||
assert.equal(warned, true)
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import path from "path"
|
|||
import { randomBytes } from "crypto"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import { createInstanceClient } from "../workspaces/instance-client"
|
||||
import type { Logger } from "../logger"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
|
||||
|
||||
|
|
@ -625,29 +626,16 @@ export class BackgroundProcessManager {
|
|||
const notify = record.notify
|
||||
if (!notify || !record.terminalReason) return
|
||||
|
||||
if (!this.deps.workspaceManager.get(workspaceId)) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
|
||||
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
const client = createInstanceClient(this.deps.workspaceManager, workspaceId, {
|
||||
directory: notify.directory,
|
||||
})
|
||||
if (!client) {
|
||||
throw new Error("Workspace instance is not ready")
|
||||
}
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
if (authorization) {
|
||||
headers.authorization = authorization
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
await client.session.promptAsync(
|
||||
{
|
||||
sessionID: notify.sessionID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
|
|
@ -655,13 +643,9 @@ export class BackgroundProcessManager {
|
|||
synthetic: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Prompt request failed with ${response.status}`)
|
||||
}
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
}
|
||||
|
||||
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||
|
|
|
|||
15
packages/server/src/config/schema.test.ts
Normal file
15
packages/server/src/config/schema.test.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
45
packages/server/src/events/bus.test.ts
Normal file
45
packages/server/src/events/bus.test.ts
Normal file
|
|
@ -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, [])
|
||||
})
|
||||
})
|
||||
|
|
@ -3,11 +3,22 @@ import { WorkspaceEventPayload } from "../api-types"
|
|||
import { Logger } from "../logger"
|
||||
|
||||
export class EventBus extends EventEmitter {
|
||||
private readonly instanceStatuses = new Map<string, Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
63
packages/server/src/filesystem/__tests__/search.test.ts
Normal file
63
packages/server/src/filesystem/__tests__/search.test.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
|
|
@ -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<string, WorkspaceCandidateCacheEntry>()
|
||||
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -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<string> }> = [
|
||||
{ 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<string>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
73
packages/server/src/index.test.ts
Normal file
73
packages/server/src/index.test.ts
Normal file
|
|
@ -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<string, () => void>()
|
||||
let finishCleanup!: () => void
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve
|
||||
})
|
||||
const exits: number[] = []
|
||||
let handled: Promise<void> | 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<string, (chunk: Buffer | string) => 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>,
|
||||
): 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>,
|
||||
): 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()
|
||||
|
|
@ -234,8 +278,8 @@ function resolveHost(input: string | undefined): string {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
function programHasArg(argv: string[], flag: string): boolean {
|
||||
return argv.includes(flag)
|
||||
export function programHasArg(argv: string[], flag: string): boolean {
|
||||
return argv.some((argument) => argument === flag || argument.startsWith(`${flag}=`))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -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<void>(() => { 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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
129
packages/server/src/opencode-update/service.test.ts
Normal file
129
packages/server/src/opencode-update/service.test.ts
Normal file
|
|
@ -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> = {}): 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<void>((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",
|
||||
)
|
||||
})
|
||||
194
packages/server/src/opencode-update/service.ts
Normal file
194
packages/server/src/opencode-update/service.ts
Normal file
|
|
@ -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<string, Promise<OpenCodeUpdateResponse>>()
|
||||
|
||||
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<UpgradeResult>
|
||||
fetchLatestVersion: () => Promise<string>
|
||||
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<OpenCodeUpdateStatus> {
|
||||
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<OpenCodeUpdateResponse> {
|
||||
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<OpenCodeUpdateResponse> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
881
packages/server/src/permissions/auto-accept-manager.test.ts
Normal file
881
packages/server/src/permissions/auto-accept-manager.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<void>((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<string, unknown>[] = []
|
||||
bus.on("yolo.stateChanged", (event) => changes.push(event))
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((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<string, unknown>[] = []
|
||||
bus.on("yolo.stateChanged", (event) => changes.push(event))
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((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<void>((resolve) => { releaseFirst = resolve })
|
||||
const secondGate = new Promise<void>((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<void>((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<string, unknown>[] = []
|
||||
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<string, unknown>[] = []
|
||||
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<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
475
packages/server/src/permissions/auto-accept-manager.ts
Normal file
475
packages/server/src/permissions/auto-accept-manager.ts
Normal file
|
|
@ -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<void>
|
||||
|
||||
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<PersistedAutoAcceptSession[]>
|
||||
persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise<void>
|
||||
}
|
||||
|
||||
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<string>()
|
||||
/** instanceId -> (permissionId -> pending permission) awaiting a reply */
|
||||
private readonly pending = new Map<string, Map<string, PendingPermission>>()
|
||||
/** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
|
||||
private readonly replyAttempts = new Map<string, number>()
|
||||
private readonly hydratedInstances = new Set<string>()
|
||||
private readonly hydration = new Map<string, Promise<void>>()
|
||||
private readonly queuedEvents = new Map<string, InstanceStreamPayload[]>()
|
||||
private readonly instanceGeneration = new Map<string, number>()
|
||||
private readonly sessionWorkspaces = new Map<string, Map<string, string>>()
|
||||
private readonly mutations = new Map<string, Promise<boolean>>()
|
||||
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<void> {
|
||||
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<string, string>()
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string, string>()
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
182
packages/server/src/permissions/auto-accept-store.test.ts
Normal file
182
packages/server/src/permissions/auto-accept-store.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
132
packages/server/src/permissions/auto-accept-store.ts
Normal file
132
packages/server/src/permissions/auto-accept-store.ts
Normal file
|
|
@ -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<string>()
|
||||
|
||||
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<string, Set<string>>()
|
||||
/** instanceId -> (sessionId -> info) */
|
||||
private readonly sessions = new Map<string, Map<string, AutoAcceptSessionInfo>>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
packages/server/src/permissions/opencode-replier.ts
Normal file
47
packages/server/src/permissions/opencode-replier.ts
Normal file
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Record<string, unknown>> = []
|
||||
const client = {
|
||||
session: {
|
||||
async list() { return { data: [{ id: "root", parentID: null, workspaceID: "workspace", metadata: {} }] } },
|
||||
async get(parameters: Record<string, unknown>) { calls.push(parameters); return { data: { metadata: {} } } },
|
||||
async update(parameters: Record<string, unknown>) { 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<string, unknown> = { thirdParty: true }
|
||||
const client = {
|
||||
session: {
|
||||
async get() { return { data: { metadata } } },
|
||||
async update(parameters: Record<string, unknown>) {
|
||||
metadata = parameters.metadata as Record<string, unknown>
|
||||
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" },
|
||||
})
|
||||
})
|
||||
})
|
||||
111
packages/server/src/permissions/opencode-yolo-metadata.ts
Normal file
111
packages/server/src/permissions/opencode-yolo-metadata.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
|
||||
export interface OpencodeYoloPersistence extends AutoAcceptPersistence {
|
||||
hasProjectSession(instanceId: string, sessionId: string): Promise<boolean>
|
||||
setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string): Promise<Metadata>
|
||||
}
|
||||
|
||||
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<string, Promise<unknown>>()
|
||||
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<Metadata> => {
|
||||
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<PersistedAutoAcceptSession[]> {
|
||||
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<void> {
|
||||
return updateMetadata(instanceId, rootSessionId, workspaceId,
|
||||
(metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined)
|
||||
},
|
||||
async hasProjectSession(instanceId, sessionId): Promise<boolean> {
|
||||
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<Metadata> {
|
||||
return updateMetadata(instanceId, sessionId, undefined,
|
||||
(metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug))
|
||||
},
|
||||
}
|
||||
}
|
||||
18
packages/server/src/server/__tests__/listener-retry.test.ts
Normal file
18
packages/server/src/server/__tests__/listener-retry.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { programHasArg } from "../../index"
|
||||
import { shouldRetryPreferredPort } from "../http-server"
|
||||
|
||||
test("automatic listeners retry Windows reserved ports without masking explicit failures", () => {
|
||||
assert.equal(shouldRetryPreferredPort({ code: "EADDRINUSE" }, true, "linux"), true)
|
||||
assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, true, "win32"), true)
|
||||
assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, true, "linux"), false)
|
||||
assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, false, "win32"), false)
|
||||
})
|
||||
|
||||
test("explicit listener ports are detected in both supported CLI forms", () => {
|
||||
assert.equal(programHasArg(["--http-port", "9899"], "--http-port"), true)
|
||||
assert.equal(programHasArg(["--https-port=9898"], "--https-port"), true)
|
||||
assert.equal(programHasArg(["--http-porter=9899"], "--http-port"), false)
|
||||
})
|
||||
|
|
@ -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<RemoteProxySessionManager>()
|
||||
|
||||
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<string, { lastAccessAt: number }>
|
||||
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
|
||||
|
||||
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<void>()
|
||||
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<void>
|
||||
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<never>((_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<string, any>
|
||||
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<void>()
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((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<ReturnType<typeof fetch>>): 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<typeof fetch>[1]) {
|
|||
return fetch(url, { dispatcher: httpsDispatcher, ...init })
|
||||
}
|
||||
|
||||
async function disposeManager(manager: RemoteProxySessionManager) {
|
||||
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).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<void>,
|
||||
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
|
||||
) {
|
||||
const server = http.createServer(handler)
|
||||
await new Promise<void>((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<void>((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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -74,6 +82,12 @@ interface HttpServerStartResult {
|
|||
displayHost: string
|
||||
}
|
||||
|
||||
export function shouldRetryPreferredPort(error: unknown, autoPortRequested: boolean, platform = process.platform): boolean {
|
||||
if (!autoPortRequested) return false
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||
return code === "EADDRINUSE" || (platform === "win32" && code === "EACCES")
|
||||
}
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
||||
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
||||
|
|
@ -269,6 +283,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 +296,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 +310,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 +331,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||
voiceModeManager: deps.voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerYoloRoutes(app, { yoloManager: deps.yoloManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
|
|
@ -329,18 +352,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||
const autoPortRequested = deps.bindPort === 0
|
||||
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
|
||||
|
||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||
if (!autoPortRequested) return false
|
||||
const err = error as NodeJS.ErrnoException | undefined
|
||||
return Boolean(err && err.code === "EADDRINUSE")
|
||||
}
|
||||
|
||||
let listenResult
|
||||
|
||||
try {
|
||||
listenResult = await attemptListen(primaryPort)
|
||||
} catch (error) {
|
||||
if (!shouldRetryWithEphemeral(error)) {
|
||||
if (!shouldRetryPreferredPort(error, autoPortRequested)) {
|
||||
throw error
|
||||
}
|
||||
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
|
||||
|
|
|
|||
|
|
@ -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<string, RemoteProxySession>()
|
||||
private readonly creations = new Set<Promise<unknown>>()
|
||||
private readonly disposals = new Set<Promise<unknown>>()
|
||||
private readonly sessionDisposals = new Map<string, Promise<boolean>>()
|
||||
private readonly cleanupTimer: NodeJS.Timeout
|
||||
private shuttingDown = false
|
||||
private shutdownPromise?: Promise<void>
|
||||
|
||||
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<RemoteProxySessionCreateResult> {
|
||||
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<RemoteProxySessionCreateResult> {
|
||||
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<boolean> {
|
||||
return this.disposeSession(sessionId)
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
private disposeSession(sessionId: string): Promise<boolean> {
|
||||
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<void> {
|
||||
app.server.closeAllConnections?.()
|
||||
const results = await Promise.race([
|
||||
Promise.allSettled([app.close(), dispatcher?.destroy()]),
|
||||
new Promise<never>((_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<T>(operations: Set<Promise<unknown>>, operation: Promise<T>): Promise<T> {
|
||||
operations.add(operation)
|
||||
void operation.finally(() => operations.delete(operation)).catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
private trackDisposal<T>(operation: Promise<T>): Promise<T> {
|
||||
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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@
|
|||
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
||||
|
||||
<label for="username">Username</label>
|
||||
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
||||
<input id="username" autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="{{DEFAULT_USERNAME}}" value="{{DEFAULT_USERNAME}}" />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" autocomplete="current-password" value="" />
|
||||
<input id="password" type="password" autocomplete="current-password" autocapitalize="none" autocorrect="off" spellcheck="false" value="" />
|
||||
|
||||
<button id="submit" type="button">Continue</button>
|
||||
<div id="error" class="error" style="display: none"></div>
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
25
packages/server/src/server/routes/auth.test.ts
Normal file
25
packages/server/src/server/routes/auth.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
39
packages/server/src/server/routes/opencode-update.test.ts
Normal file
39
packages/server/src/server/routes/opencode-update.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
43
packages/server/src/server/routes/opencode-update.ts
Normal file
43
packages/server/src/server/routes/opencode-update.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
87
packages/server/src/server/routes/settings.test.ts
Normal file
87
packages/server/src/server/routes/settings.test.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown>) }
|
||||
const speech = patch.speech
|
||||
if (!speech || typeof speech !== "object") return patch
|
||||
const speechPatch = { ...(speech as Record<string, unknown>) }
|
||||
const cur = (currentSpeech && typeof currentSpeech === "object") ? currentSpeech as Record<string, unknown> : {}
|
||||
const curSpeech = (cur.speech && typeof cur.speech === "object") ? cur.speech as Record<string, unknown> : 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<string, unknown>) }
|
||||
if ("baseUrl" in dirPatch && !("apiKey" in dirPatch)) {
|
||||
const curDir = (curSpeech[dir] && typeof curSpeech[dir] === "object") ? curSpeech[dir] as Record<string, unknown> : {}
|
||||
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<string, unknown>) }
|
||||
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)
|
||||
|
|
|
|||
28
packages/server/src/server/routes/usage.test.ts
Normal file
28
packages/server/src/server/routes/usage.test.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
24
packages/server/src/server/routes/usage.ts
Normal file
24
packages/server/src/server/routes/usage.ts
Normal file
|
|
@ -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" }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
126
packages/server/src/server/routes/workspaces.test.ts
Normal file
126
packages/server/src/server/routes/workspaces.test.ts
Normal file
|
|
@ -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<void>((resolve) => { cancellationStarted = resolve })
|
||||
const deletion = new Promise<void>((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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
27
packages/server/src/server/routes/yolo.ts
Normal file
27
packages/server/src/server/routes/yolo.ts
Normal file
|
|
@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
20
packages/server/src/settings/binaries.test.ts
Normal file
20
packages/server/src/settings/binaries.test.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
61
packages/server/src/settings/public-config.test.ts
Normal file
61
packages/server/src/settings/public-config.test.ts
Normal file
|
|
@ -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" })
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
58
packages/server/src/settings/service.test.ts
Normal file
58
packages/server/src/settings/service.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { SettingsService } from "./service"
|
||||
|
||||
function serviceWithStore(store: Record<string, unknown>) {
|
||||
const service = Object.create(SettingsService.prototype) as SettingsService
|
||||
Object.assign(service as any, { configStore: store, eventBus: undefined })
|
||||
return service
|
||||
}
|
||||
|
||||
describe("SettingsService config persistence", () => {
|
||||
it("normalizes and persists a document patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
get: () => ({ server: { logLevel: "info" } }),
|
||||
replace: (value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatch: () => assert.fail("must not persist an intermediate document"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchDoc("config", { ui: { theme: "dark" } })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { server: { logLevel: "INFO" }, ui: { theme: "dark" } })
|
||||
})
|
||||
|
||||
it("normalizes and persists a server-owner patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({ logLevel: "info", sidecars: [] }),
|
||||
replaceOwner: (_owner: string, value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatchOwner: () => assert.fail("must not persist an intermediate owner"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] })
|
||||
})
|
||||
|
||||
it("does not report a persisted patch as failed when an event listener throws", () => {
|
||||
let warnings = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({}),
|
||||
mergePatchOwner: (_owner: string, patch: unknown) => patch,
|
||||
})
|
||||
Object.assign(service as any, {
|
||||
eventBus: { publish: () => { throw new Error("listener failed") } },
|
||||
logger: { warn: () => { warnings += 1 } },
|
||||
})
|
||||
|
||||
assert.deepEqual(service.mergePatchOwner("config", "ui", { theme: "dark" }), { theme: "dark" })
|
||||
assert.equal(warnings, 1)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
|||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
import { sanitizeConfigOwner } from "./public-config"
|
||||
import { applyMergePatch } from "./merge-patch"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
|
|
@ -67,8 +68,16 @@ export class SettingsService {
|
|||
private readonly logger: Logger,
|
||||
) {
|
||||
migrateSettingsLayout(location, logger)
|
||||
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||
this.configStore = new YamlDocStore(
|
||||
location.configYamlPath,
|
||||
logger.child({ component: "settings-config" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
this.stateStore = new YamlDocStore(
|
||||
location.stateYamlPath,
|
||||
logger.child({ component: "settings-state" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
|
|
@ -85,9 +94,12 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
? this.configStore.replace(normalizeConfigDoc(applyMergePatch(this.configStore.get(), patch) as SettingsDoc))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
|
|
@ -104,10 +116,16 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
? this.configStore.replaceOwner(
|
||||
owner,
|
||||
normalizeServerConfigOwner(applyMergePatch(this.configStore.getOwner(owner), patch) as SettingsDoc),
|
||||
)
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
|
|
@ -123,6 +141,10 @@ export class SettingsService {
|
|||
owner,
|
||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
try {
|
||||
this.eventBus.publish(payload)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, kind, owner }, "Failed to publish settings change")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import { YamlDocStore } from "./yaml-doc-store"
|
||||
|
||||
describe("YamlDocStore", () => {
|
||||
it("reports persistence failures without replacing the cached document", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const parent = path.join(root, "settings")
|
||||
const file = path.join(parent, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.rmSync(parent, { recursive: true })
|
||||
fs.writeFileSync(parent, "blocks directory creation")
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps the live document intact when atomic replacement fails", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
const renameSync = fs.renameSync
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
;(fs as any).renameSync = () => { throw new Error("replacement failed") }
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 1/)
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
|
||||
;(fs as any).renameSync = renameSync
|
||||
store.replace({ version: 2 })
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
;(fs as any).renameSync = renameSync
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves private file permissions", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.chmodSync(file, 0o664)
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o664)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("updates the final target without replacing a symlink chain", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const target = path.join(root, "target.yaml")
|
||||
const intermediate = path.join(root, "current.yaml")
|
||||
const link = path.join(root, "config.yaml")
|
||||
fs.writeFileSync(target, "version: 1\n")
|
||||
fs.symlinkSync(target, intermediate)
|
||||
fs.symlinkSync(intermediate, link)
|
||||
const store = new YamlDocStore(link, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
assert.equal(fs.lstatSync(intermediate).isSymbolicLink(), true)
|
||||
assert.match(fs.readFileSync(target, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||
|
|
@ -18,6 +19,25 @@ function normalizeDoc(input: unknown): SettingsDoc {
|
|||
return input
|
||||
}
|
||||
|
||||
function resolveWriteDestination(filePath: string): string {
|
||||
let current = path.resolve(filePath)
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (true) {
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.lstatSync(current)
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return current
|
||||
throw error
|
||||
}
|
||||
if (!stat.isSymbolicLink()) return current
|
||||
if (seen.has(current)) throw new Error(`Circular settings symlink: ${filePath}`)
|
||||
seen.add(current)
|
||||
current = path.resolve(path.dirname(current), fs.readlinkSync(current))
|
||||
}
|
||||
}
|
||||
|
||||
export class YamlDocStore {
|
||||
private cache: SettingsDoc = {}
|
||||
private loaded = false
|
||||
|
|
@ -25,6 +45,7 @@ export class YamlDocStore {
|
|||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly options: { throwOnPersistError?: boolean } = {},
|
||||
) {}
|
||||
|
||||
load(): SettingsDoc {
|
||||
|
|
@ -58,9 +79,17 @@ export class YamlDocStore {
|
|||
|
||||
replace(next: unknown): SettingsDoc {
|
||||
const normalized = normalizeDoc(next)
|
||||
const previousCache = this.cache
|
||||
const previousLoaded = this.loaded
|
||||
this.cache = normalized
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
try {
|
||||
this.persist()
|
||||
} catch (error) {
|
||||
this.cache = previousCache
|
||||
this.loaded = previousLoaded
|
||||
throw error
|
||||
}
|
||||
return this.cache
|
||||
}
|
||||
|
||||
|
|
@ -99,12 +128,27 @@ export class YamlDocStore {
|
|||
}
|
||||
|
||||
private persist() {
|
||||
let tempPath: string | undefined
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
const destination = resolveWriteDestination(this.filePath)
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
||||
const yaml = stringifyYaml(this.cache as any)
|
||||
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o600
|
||||
tempPath = `${destination}.${process.pid}.${randomUUID()}.tmp`
|
||||
fs.writeFileSync(tempPath, ensureTrailingNewline(yaml), { encoding: "utf-8", mode })
|
||||
if (process.platform !== "win32") fs.chmodSync(tempPath, mode)
|
||||
fs.renameSync(tempPath, destination)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||
if (this.options.throwOnPersistError) throw error
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, tempPath }, "Failed to remove temporary YAML doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue