mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-27 08:39:44 +00:00
Compare commits
37 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
269cff64ee | ||
|
|
70c9548f93 | ||
|
|
9d47f73ea7 | ||
|
|
b1bb8a723a | ||
|
|
b81044ffa2 | ||
|
|
30563a22e2 | ||
|
|
25af76c243 | ||
|
|
d74a8a39f1 | ||
|
|
4c50829da0 | ||
|
|
24a26807e0 | ||
|
|
47ae6626dd | ||
|
|
8f6aa44b15 | ||
|
|
b766b76a69 | ||
|
|
0bab9e3438 | ||
|
|
e586ea39f4 | ||
|
|
7584ccfe29 | ||
|
|
18564d4094 | ||
|
|
e169c0aaa1 | ||
|
|
f6cf6c5352 | ||
|
|
40cee071a3 | ||
|
|
709557d12d | ||
|
|
e815eba872 | ||
|
|
f31d8c17f6 | ||
|
|
1efe5e91d1 | ||
|
|
b1d6421b0d | ||
|
|
07e8ce6810 | ||
|
|
f900af1b4d | ||
|
|
483fac2e07 | ||
|
|
f34e807108 | ||
|
|
c8d4a5099a | ||
|
|
56d1e00f2f | ||
|
|
34706228bb | ||
|
|
ca06bd99d7 | ||
|
|
bce975d940 | ||
|
|
275770b51b | ||
|
|
e8623d3132 | ||
|
|
31414521f6 |
392 changed files with 36240 additions and 6405 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)
|
- name: Verify bundled Node resource (Electron Linux)
|
||||||
run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@linux-x64
|
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
|
- name: Upload release assets
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
files=(packages/electron-app/release/*.tar.gz)
|
||||||
[ -f "$file" ] || continue
|
if [ "${#files[@]}" -ne 1 ]; then
|
||||||
echo "Uploading $file"
|
echo "Expected one Electron Linux release asset, found ${#files[@]}" >&2
|
||||||
gh release upload "$TAG" "$file" --clobber
|
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
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Linux)
|
- name: Upload Actions artifacts (Electron Linux)
|
||||||
|
|
@ -333,9 +416,7 @@ jobs:
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||||
path: |
|
path: packages/electron-app/release/*.tar.gz
|
||||||
packages/electron-app/release/*.zip
|
|
||||||
packages/electron-app/release/*.AppImage
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
|
@ -364,6 +445,9 @@ jobs:
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-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
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
|
|
@ -451,6 +535,9 @@ jobs:
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-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
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
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
|
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
- name: Sync Tauri package version
|
||||||
|
run: npm run sync:version --workspace @codenomad/tauri-app
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
|
|
@ -643,6 +733,9 @@ jobs:
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-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
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
|
|
@ -669,53 +762,135 @@ jobs:
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Build Linux bundle (Tauri)
|
- name: Build Debian package (Tauri)
|
||||||
working-directory: packages/tauri-app
|
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 }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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"
|
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"
|
rm -rf "$ARTIFACT_DIR"
|
||||||
mkdir -p "$ARTIFACT_DIR"
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
shopt -s nullglob globstar
|
shopt -s nullglob
|
||||||
|
|
||||||
find_one() {
|
debs=("$BUNDLE_ROOT"/*.deb)
|
||||||
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
|
if [ "${#debs[@]}" -ne 1 ]; then
|
||||||
}
|
echo "Expected one Tauri deb under $BUNDLE_ROOT, found ${#debs[@]}" >&2
|
||||||
|
|
||||||
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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.AppImage"
|
artifact="$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION_TO_USE}.deb"
|
||||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.zip" "$fallback_bin"
|
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)
|
- name: Upload Actions artifacts (Tauri Linux)
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
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 }}
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
if-no-files-found: warn
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for file in packages/tauri-app/release-tauri/*; do
|
files=(packages/tauri-app/release-tauri/*.deb)
|
||||||
[ -f "$file" ] || continue
|
if [ "${#files[@]}" -ne 1 ]; then
|
||||||
echo "Uploading $file"
|
echo "Expected one Tauri Linux release asset, found ${#files[@]}" >&2
|
||||||
gh release upload "$TAG" "$file" --clobber
|
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
|
done
|
||||||
|
|
||||||
build-tauri-linux-arm64:
|
build-tauri-linux-arm64:
|
||||||
|
|
@ -772,6 +947,9 @@ jobs:
|
||||||
- name: Set workspace versions
|
- name: Set workspace versions
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-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
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (matchedRun.conclusion !== 'success') {
|
||||||
|
core.setFailed(`PR Build Validation run ${matchedRun.id} concluded ${matchedRun.conclusion}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const artifacts = await github.paginate(
|
const artifacts = await github.paginate(
|
||||||
github.rest.actions.listWorkflowRunArtifacts,
|
github.rest.actions.listWorkflowRunArtifacts,
|
||||||
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
||||||
|
|
|
||||||
121
.github/workflows/pr-build.yml
vendored
121
.github/workflows/pr-build.yml
vendored
|
|
@ -46,7 +46,10 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: authorize
|
needs:
|
||||||
|
- authorize
|
||||||
|
- tests
|
||||||
|
- tests-tauri-windows
|
||||||
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
|
|
@ -56,3 +59,119 @@ jobs:
|
||||||
actions_artifacts_retention_days: 7
|
actions_artifacts_retention_days: 7
|
||||||
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
||||||
set_versions: false
|
set_versions: false
|
||||||
|
|
||||||
|
tests:
|
||||||
|
needs: authorize
|
||||||
|
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install Linux test dependencies (Tauri)
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck desktop clients
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Test Electron client state
|
||||||
|
run: npm run test:native --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Test changed runnable UI behavior
|
||||||
|
run: >-
|
||||||
|
node --import tsx --test
|
||||||
|
packages/ui/src/components/session-list-visibility.test.ts
|
||||||
|
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
|
||||||
|
packages/ui/src/lib/message-selection-position.test.ts
|
||||||
|
packages/ui/src/lib/trailing-resync.test.ts
|
||||||
|
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
|
||||||
|
packages/ui/src/stores/app-session-reconciliation.test.ts
|
||||||
|
packages/ui/src/stores/app-session-restore-gate.test.ts
|
||||||
|
packages/ui/src/stores/app-session-restore-queue.test.ts
|
||||||
|
packages/ui/src/stores/app-session-restore-timeout.test.ts
|
||||||
|
packages/ui/src/stores/app-session-snapshot-merge.test.ts
|
||||||
|
packages/ui/src/stores/restore-workspace-commit-gates.test.ts
|
||||||
|
packages/ui/src/stores/client-state-codec.test.ts
|
||||||
|
packages/ui/src/stores/client-state.test.ts
|
||||||
|
packages/ui/src/stores/instances-restore-cancellation.test.ts
|
||||||
|
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
|
||||||
|
packages/ui/src/stores/session-generation-recovery.test.ts
|
||||||
|
packages/ui/src/stores/session-metadata.test.ts
|
||||||
|
packages/ui/src/stores/session-pagination.test.ts
|
||||||
|
packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts
|
||||||
|
|
||||||
|
- name: Test restore ownership integration
|
||||||
|
run: >-
|
||||||
|
node --conditions=browser --import tsx --test --test-force-exit
|
||||||
|
packages/ui/src/stores/instances-restore-ownership.test.ts
|
||||||
|
packages/ui/src/stores/permission-lifecycle.test.ts
|
||||||
|
|
||||||
|
- name: Test server
|
||||||
|
run: node --import tsx --test "packages/server/src/**/*.test.ts"
|
||||||
|
|
||||||
|
- name: Prepare Tauri test resources
|
||||||
|
run: >-
|
||||||
|
npm run dev:prep --workspace @codenomad/tauri-app &&
|
||||||
|
node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})"
|
||||||
|
|
||||||
|
- name: Test Tauri crate
|
||||||
|
working-directory: packages/tauri-app/src-tauri
|
||||||
|
run: cargo test --locked
|
||||||
|
|
||||||
|
tests-tauri-windows:
|
||||||
|
needs: authorize
|
||||||
|
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Test Windows server spawn behavior
|
||||||
|
run: node --import tsx --test packages/server/src/workspaces/__tests__/spawn.test.ts
|
||||||
|
|
||||||
|
- name: Prepare Tauri test resources
|
||||||
|
run: >-
|
||||||
|
npm run dev:prep --workspace @codenomad/tauri-app &&
|
||||||
|
node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})"
|
||||||
|
|
||||||
|
- name: Test Tauri crate on Windows
|
||||||
|
working-directory: packages/tauri-app/src-tauri
|
||||||
|
run: cargo test --locked
|
||||||
|
|
|
||||||
|
|
@ -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`
|
1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated`
|
||||||
- Events are pushed through the instance event stream
|
- Events are pushed through the instance event stream
|
||||||
|
|
||||||
2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler
|
2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled)
|
||||||
- **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true
|
- **File:** `packages/server/src/permissions/auto-accept-manager.ts`
|
||||||
- **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
|
- **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup
|
||||||
- **Action:** Automatically calls `Permission.reply()`, skips modal display
|
- **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes
|
||||||
- **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()`
|
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)
|
- **Branch:** ELSE (normal flow)
|
||||||
- **Mechanism:** Permission queued in `permissionQueues` signal
|
- **Mechanism:** Permission queued in `permissionQueues` signal
|
||||||
- **Action:** Display approval modal
|
- **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
|
1. **Server emits** `permission.asked` or `permission.updated` SSE event
|
||||||
- Pushed through instance event stream
|
- 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`
|
- File: `packages/ui/src/stores/instances.ts`
|
||||||
- **Branch:** IF `isPermissionAutoAcceptEnabled()`
|
- **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue
|
||||||
- Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
|
- **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal
|
||||||
- Action: Calls reply immediately, skips modal
|
4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
|
||||||
- **Branch:** ELSE
|
5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
|
||||||
- Mechanism: Queued in `permissionQueues`
|
6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
|
||||||
- Action: Display modal
|
7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
|
||||||
3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
|
8. **Optimistic Update:** `removePermissionV2()` in bridge
|
||||||
4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
|
9. **SSE Confirmation:** `permission.replied` event
|
||||||
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 SSE disconnected → `syncPendingPermissions()` reconciles on reconnect
|
- **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect
|
||||||
|
|
||||||
## Session Event Handling
|
## Session Event Handling
|
||||||
|
|
|
||||||
48
BUILD.md
48
BUILD.md
|
|
@ -56,14 +56,14 @@ bun run build:win-arm64
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# x64 (64-bit)
|
# Portable Electron archive (x64)
|
||||||
bun run build:linux
|
npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
# ARM64
|
# Tauri Debian package (x64)
|
||||||
bun run build:linux-arm64
|
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
|
### Build All Platforms
|
||||||
|
|
||||||
|
|
@ -83,27 +83,29 @@ The build script performs these steps:
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
Binaries are generated in the `release/` directory:
|
Build artifacts are generated in package-specific output directories:
|
||||||
|
|
||||||
```
|
```
|
||||||
release/
|
packages/electron-app/release/
|
||||||
├── CodeNomad-0.1.0-mac-universal.dmg
|
└── CodeNomad-Electron-linux-x64-0.18.0.tar.gz
|
||||||
├── CodeNomad-0.1.0-mac-universal.zip
|
|
||||||
├── CodeNomad-0.1.0-win-x64.exe
|
packages/tauri-app/target/release/bundle/deb/
|
||||||
├── CodeNomad-0.1.0-linux-x64.AppImage
|
└── CodeNomad_0.18.0_amd64.deb
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## File Naming Convention
|
## 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`)
|
- **version**: From package.json (e.g., `0.18.0`)
|
||||||
- **os**: `mac`, `win`, `linux`
|
- **os**: `macos`, `windows`, `linux`
|
||||||
- **arch**: `x64`, `arm64`, `universal`
|
- **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
|
## Platform Requirements
|
||||||
|
|
||||||
|
|
@ -121,9 +123,9 @@ CodeNomad-{version}-{os}-{arch}.{ext}
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
- **Build on:** Any platform
|
- **Build on:** Linux x64
|
||||||
- **Run on:** Ubuntu 18.04+, Debian 10+, Fedora 32+, Arch
|
- **Electron portable:** extract the tar.gz and run the `CodeNomad` executable
|
||||||
- **Dependencies:** Varies by distro
|
- **Tauri deb:** built and installation-tested on Ubuntu 24.04; older distributions are not yet guaranteed
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
@ -136,13 +138,7 @@ xcode-select --install
|
||||||
|
|
||||||
### Build fails on Linux
|
### Build fails on Linux
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
# Install dependencies (Debian/Ubuntu)
|
|
||||||
sudo apt-get install -y rpm
|
|
||||||
|
|
||||||
# Install dependencies (Fedora)
|
|
||||||
sudo dnf install -y rpm-build
|
|
||||||
```
|
|
||||||
|
|
||||||
### "electron-builder not found"
|
### "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) |
|
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||||
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
| 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
|
### 💻 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:
|
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad-tauri
|
||||||
```
|
```
|
||||||
|
|
||||||
See full workaround in the original README.
|
See full workaround in the original README.
|
||||||
|
|
|
||||||
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
|
- macOS: DMG + auto-update
|
||||||
- Windows: NSIS installer + auto-update
|
- Windows: NSIS installer + auto-update
|
||||||
- Linux: AppImage + deb/rpm
|
- Linux: Electron portable tar.gz + Tauri deb
|
||||||
|
|
||||||
## Configuration Files
|
## 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.
|
||||||
71
package-lock.json
generated
71
package-lock.json
generated
|
|
@ -73,6 +73,7 @@
|
||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -4295,6 +4296,7 @@
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.20.7",
|
"@babel/parser": "^7.20.7",
|
||||||
"@babel/types": "^7.20.7",
|
"@babel/types": "^7.20.7",
|
||||||
|
|
@ -4396,6 +4398,7 @@
|
||||||
"version": "22.19.0",
|
"version": "22.19.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4470,6 +4473,7 @@
|
||||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
|
|
@ -4552,6 +4556,7 @@
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
|
|
@ -4754,7 +4759,6 @@
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^2.1.0",
|
"archiver-utils": "^2.1.0",
|
||||||
"async": "^3.2.4",
|
"async": "^3.2.4",
|
||||||
|
|
@ -4772,7 +4776,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.4",
|
"glob": "^7.1.4",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
|
|
@ -4793,7 +4796,6 @@
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
|
|
@ -4807,14 +4809,12 @@
|
||||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5128,7 +5128,6 @@
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer": "^5.5.0",
|
"buffer": "^5.5.0",
|
||||||
"inherits": "^2.0.4",
|
"inherits": "^2.0.4",
|
||||||
|
|
@ -5192,6 +5191,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -5682,7 +5682,6 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-crc32": "^0.2.13",
|
"buffer-crc32": "^0.2.13",
|
||||||
"crc32-stream": "^4.0.2",
|
"crc32-stream": "^4.0.2",
|
||||||
|
|
@ -5812,7 +5811,6 @@
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"crc32": "bin/crc32.njs"
|
"crc32": "bin/crc32.njs"
|
||||||
},
|
},
|
||||||
|
|
@ -5824,7 +5822,6 @@
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crc-32": "^1.2.0",
|
"crc-32": "^1.2.0",
|
||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
|
|
@ -6190,6 +6187,7 @@
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "24.13.3",
|
"app-builder-lib": "24.13.3",
|
||||||
"builder-util": "24.13.1",
|
"builder-util": "24.13.1",
|
||||||
|
|
@ -6356,7 +6354,6 @@
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "24.13.3",
|
"app-builder-lib": "24.13.3",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|
@ -6368,7 +6365,6 @@
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
|
|
@ -6382,7 +6378,6 @@
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -6394,7 +6389,6 @@
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -7129,8 +7123,7 @@
|
||||||
"node_modules/fs-constants": {
|
"node_modules/fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
|
|
@ -8347,8 +8340,7 @@
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/isbinaryfile": {
|
"node_modules/isbinaryfile": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
|
|
@ -8398,6 +8390,7 @@
|
||||||
"version": "1.21.7",
|
"version": "1.21.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
|
|
@ -8554,7 +8547,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readable-stream": "^2.0.5"
|
"readable-stream": "^2.0.5"
|
||||||
},
|
},
|
||||||
|
|
@ -8566,7 +8558,6 @@
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
|
|
@ -8580,14 +8571,12 @@
|
||||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lazystream/node_modules/string_decoder": {
|
"node_modules/lazystream/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -8652,26 +8641,22 @@
|
||||||
"node_modules/lodash.defaults": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.difference": {
|
"node_modules/lodash.difference": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.flatten": {
|
"node_modules/lodash.flatten": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isplainobject": {
|
"node_modules/lodash.isplainobject": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.sortby": {
|
"node_modules/lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
|
|
@ -8683,8 +8668,7 @@
|
||||||
"node_modules/lodash.union": {
|
"node_modules/lodash.union": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lowercase-keys": {
|
"node_modules/lowercase-keys": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
|
@ -8738,6 +8722,7 @@
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "12.0.2",
|
"version": "12.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
|
|
@ -9498,6 +9483,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -9645,8 +9631,7 @@
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
|
|
@ -9895,7 +9880,6 @@
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"string_decoder": "^1.1.1",
|
"string_decoder": "^1.1.1",
|
||||||
|
|
@ -9909,7 +9893,6 @@
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^5.1.0"
|
"minimatch": "^5.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -10212,6 +10195,7 @@
|
||||||
"version": "4.52.5",
|
"version": "4.52.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -10435,6 +10419,7 @@
|
||||||
"node_modules/seroval": {
|
"node_modules/seroval": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
|
|
@ -10758,6 +10743,7 @@
|
||||||
"node_modules/solid-js": {
|
"node_modules/solid-js": {
|
||||||
"version": "1.9.10",
|
"version": "1.9.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.1.0",
|
"csstype": "^3.1.0",
|
||||||
"seroval": "~1.3.0",
|
"seroval": "~1.3.0",
|
||||||
|
|
@ -10898,7 +10884,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -11232,7 +11217,6 @@
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bl": "^4.0.3",
|
"bl": "^4.0.3",
|
||||||
"end-of-stream": "^1.4.1",
|
"end-of-stream": "^1.4.1",
|
||||||
|
|
@ -11425,6 +11409,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -11674,6 +11659,7 @@
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -12021,6 +12007,7 @@
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|
@ -12879,6 +12866,7 @@
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
|
|
@ -13073,6 +13061,7 @@
|
||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
|
|
@ -13361,7 +13350,6 @@
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^3.0.4",
|
"archiver-utils": "^3.0.4",
|
||||||
"compress-commons": "^4.1.2",
|
"compress-commons": "^4.1.2",
|
||||||
|
|
@ -13375,7 +13363,6 @@
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.2.3",
|
"glob": "^7.2.3",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
|
|
@ -13395,6 +13382,7 @@
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
@ -13454,6 +13442,7 @@
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@opencode-ai/sdk": "^1.17.8",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"minServerVersion": "0.17.0",
|
"minServerVersion": "0.18.0",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,8 +41,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||||
|
|
||||||
ipcMain.handle("cli:restart", async () => {
|
ipcMain.handle("cli:restart", async () => {
|
||||||
const devMode = process.env.NODE_ENV === "development"
|
const devMode = process.env.NODE_ENV === "development"
|
||||||
await cliManager.stop()
|
return cliManager.restart({ dev: devMode })
|
||||||
return cliManager.start({ dev: devMode })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||||
|
|
|
||||||
|
|
@ -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 http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
import { existsSync, mkdirSync, rmSync } from "fs"
|
import { existsSync, mkdirSync, rmSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
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 { setupCliIPC } from "./ipc"
|
||||||
import { configureMediaPermissionHandlers } from "./permissions"
|
import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions"
|
||||||
|
import { resolveConfiguredRendererOrigins } from "./renderer-origin"
|
||||||
import { CliProcessManager } from "./process-manager"
|
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 mainFilename = fileURLToPath(import.meta.url)
|
||||||
const mainDirname = dirname(mainFilename)
|
const mainDirname = dirname(mainFilename)
|
||||||
|
|
@ -82,6 +95,7 @@ function cleanupPackagedChromiumStorage() {
|
||||||
|
|
||||||
cleanupPackagedChromiumStorage()
|
cleanupPackagedChromiumStorage()
|
||||||
|
|
||||||
|
const clientStateManager = new ClientStateManager(app.getPath("userData"))
|
||||||
const cliManager = new CliProcessManager()
|
const cliManager = new CliProcessManager()
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
|
|
@ -89,8 +103,24 @@ let pendingCliUrl: string | null = null
|
||||||
let pendingBootstrapToken: string | null = null
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
let mainNavigationController: ClientStateNavigationController | null = null
|
||||||
const remoteWindowOrigins = new Map<number, Set<string>>()
|
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||||
const insecureWindowOrigins = 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) {
|
if (isMac) {
|
||||||
app.commandLine.appendSwitch("disable-spell-checking")
|
app.commandLine.appendSwitch("disable-spell-checking")
|
||||||
|
|
@ -151,19 +181,18 @@ function resolveLoadingFilePath() {
|
||||||
return join(app.getAppPath(), "dist/renderer/loading.html")
|
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLoadingScreen(window: BrowserWindow) {
|
async function loadLoadingScreen(window: BrowserWindow): Promise<boolean> {
|
||||||
const target = resolveLoadingTarget()
|
const target = resolveLoadingTarget()
|
||||||
const loader =
|
try {
|
||||||
target.type === "url"
|
await (target.type === "url" ? window.loadURL(target.source) : window.loadFile(target.source))
|
||||||
? window.loadURL(target.source)
|
return true
|
||||||
: window.loadFile(target.source)
|
} catch (error) {
|
||||||
|
|
||||||
loader.catch((error) => {
|
|
||||||
if (isIgnorableNavigationError(error)) {
|
if (isIgnorableNavigationError(error)) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
console.error("[cli] failed to load loading screen:", error)
|
console.error("[cli] failed to load loading screen:", error)
|
||||||
})
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIgnorableNavigationError(error: unknown): boolean {
|
function isIgnorableNavigationError(error: unknown): boolean {
|
||||||
|
|
@ -183,16 +212,11 @@ function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||||
origins.add(origin)
|
origins.add(origin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
for (const origin of resolveConfiguredRendererOrigins(currentCliUrl, app.isPackaged, [
|
||||||
for (const candidate of rendererCandidates) {
|
process.env.VITE_DEV_SERVER_URL,
|
||||||
if (!candidate) {
|
process.env.ELECTRON_RENDERER_URL,
|
||||||
continue
|
])) {
|
||||||
}
|
origins.add(origin)
|
||||||
try {
|
|
||||||
origins.add(new URL(candidate).origin)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[cli] failed to parse origin for", candidate, error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Array.from(origins)
|
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) => {
|
const handleExternal = (url: string) => {
|
||||||
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
|
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) => {
|
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)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleExternal(url)
|
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) {
|
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||||
remoteWindowOrigins.delete(window.id)
|
remoteWindowOrigins.delete(window.id)
|
||||||
}
|
}
|
||||||
|
|
@ -320,10 +373,19 @@ function createWindow() {
|
||||||
const prefersDark = true
|
const prefersDark = true
|
||||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||||
const iconPath = getIconPath()
|
const iconPath = getIconPath()
|
||||||
|
const savedWindowState = clientStateManager.getWindowState()
|
||||||
|
const restoredBounds = savedWindowState
|
||||||
|
? clampWindowBounds(
|
||||||
|
savedWindowState.bounds,
|
||||||
|
screen.getAllDisplays().map((display) => display.workArea),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH,
|
||||||
height: 900,
|
height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT,
|
||||||
|
useContentSize: true,
|
||||||
|
...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}),
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
|
@ -332,14 +394,36 @@ function createWindow() {
|
||||||
preload: getPreloadPath(),
|
preload: getPreloadPath(),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
...(savedWindowState ? { zoomFactor: savedWindowState.zoomFactor } : {}),
|
||||||
spellcheck: !isMac,
|
spellcheck: !isMac,
|
||||||
additionalArguments: ["--codenomad-window-context=local"],
|
additionalArguments: ["--codenomad-window-context=local"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const window = mainWindow
|
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) {
|
if (isMac) {
|
||||||
window.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
|
|
@ -348,23 +432,34 @@ function createWindow() {
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
clearWindowAllowedOrigin(window)
|
clearWindowAllowedOrigin(window)
|
||||||
loadLoadingScreen(window)
|
void loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
window.webContents.openDevTools({ mode: "detach" })
|
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)
|
setupCliIPC(window, cliManager)
|
||||||
|
bindClientStateWindow(window)
|
||||||
|
clientStateLifecycle.attachMainWindow(window, windowStateTracker)
|
||||||
|
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
destroyPreloadingView()
|
destroyPreloadingView()
|
||||||
clearWindowAllowedOrigin(window)
|
clearWindowAllowedOrigin(window)
|
||||||
clearWindowInsecureOrigin(window)
|
clearWindowInsecureOrigin(window)
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
|
if (mainNavigationController === navigationController) mainNavigationController = null
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
|
clientStateLifecycle.detachMainWindow(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (pendingCliUrl) {
|
if (pendingCliUrl) {
|
||||||
|
|
@ -383,11 +478,19 @@ function showLoadingScreen(force = false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyPreloadingView()
|
const window = mainWindow
|
||||||
|
const wasShowingLoadingScreen = showingLoadingScreen
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
destroyPreloadingView()
|
||||||
pendingCliUrl = null
|
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 {
|
function isBootstrapTokenUrl(url: string): boolean {
|
||||||
|
|
@ -460,16 +563,23 @@ function finalizeCliSwap(url: string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const window = mainWindow
|
const navigate = async (target: BrowserWindow) => {
|
||||||
showingLoadingScreen = false
|
const rollbackOrigin = stageWindowAllowedOrigin(target, url)
|
||||||
currentCliUrl = url
|
try {
|
||||||
setWindowAllowedOrigin(window, url)
|
await target.loadURL(url)
|
||||||
pendingCliUrl = null
|
} catch (error) {
|
||||||
window.loadURL(url).catch((error) => {
|
rollbackOrigin()
|
||||||
if (isIgnorableNavigationError(error)) {
|
throw error
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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) => {
|
clientStateLifecycle.registerAppEvents()
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||||
|
|
||||||
export function createApplicationMenu(mainWindow: BrowserWindow) {
|
interface ApplicationMenuActions {
|
||||||
|
reload(): void
|
||||||
|
forceReload(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) {
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
const template: MenuItemConstructorOptions[] = [
|
const template: MenuItemConstructorOptions[] = [
|
||||||
|
|
@ -51,8 +56,8 @@ export function createApplicationMenu(mainWindow: BrowserWindow) {
|
||||||
{
|
{
|
||||||
label: "View",
|
label: "View",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" as const },
|
{ label: "Reload", accelerator: "CmdOrCtrl+R", click: actions.reload },
|
||||||
{ role: "forceReload" as const },
|
{ label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: actions.forceReload },
|
||||||
{ role: "toggleDevTools" as const },
|
{ role: "toggleDevTools" as const },
|
||||||
{ type: "separator" as const },
|
{ type: "separator" as const },
|
||||||
{ role: "resetZoom" as const },
|
{ role: "resetZoom" as const },
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,10 @@
|
||||||
import { session, systemPreferences } from "electron"
|
import { session, systemPreferences } from "electron"
|
||||||
|
import { isAllowedRendererOrigin } from "./renderer-origin"
|
||||||
|
|
||||||
|
export { isAllowedRendererOrigin } from "./renderer-origin"
|
||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
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[]) {
|
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
||||||
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
||||||
if (permission !== "media") {
|
if (permission !== "media") {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
import { spawn, type ChildProcess } from "child_process"
|
||||||
import { app, utilityProcess, type UtilityProcess } from "electron"
|
import { app } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
|
|
@ -8,6 +8,16 @@ import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { parse as parseYaml } from "yaml"
|
import { parse as parseYaml } from "yaml"
|
||||||
import { ensureManagedNodeBinary } from "./managed-node"
|
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"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
|
@ -15,8 +25,9 @@ const mainFilename = fileURLToPath(import.meta.url)
|
||||||
const mainDirname = path.dirname(mainFilename)
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
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"
|
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
|
|
||||||
|
|
@ -45,9 +56,6 @@ interface CliEntryResolution {
|
||||||
nodeArgs?: string[]
|
nodeArgs?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagedChild = ChildProcess | UtilityProcess
|
|
||||||
type ChildLaunchMode = "spawn" | "utility"
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
function isYamlPath(filePath: string): boolean {
|
function isYamlPath(filePath: string): boolean {
|
||||||
|
|
@ -127,18 +135,42 @@ export declare interface CliProcessManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CliProcessManager extends EventEmitter {
|
export class CliProcessManager extends EventEmitter {
|
||||||
private child?: ManagedChild
|
private child?: ChildProcess
|
||||||
private childLaunchMode: ChildLaunchMode = "spawn"
|
private childStartIdentity?: Promise<string | undefined>
|
||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
private bootstrapToken: string | null = null
|
private bootstrapToken: string | null = null
|
||||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
private requestedStop = false
|
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) {
|
if (this.child) {
|
||||||
await this.stop()
|
await this.stopNow()
|
||||||
|
if (this.child) throw new Error("CLI process did not exit before restart")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
|
|
@ -146,6 +178,8 @@ export class CliProcessManager extends EventEmitter {
|
||||||
this.bootstrapToken = null
|
this.bootstrapToken = null
|
||||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
|
this.shutdownStatus = null
|
||||||
|
this.childStartIdentity = undefined
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
|
|
@ -153,63 +187,32 @@ export class CliProcessManager extends EventEmitter {
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
const cliEntry = await this.resolveCliEntry(options)
|
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 env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
const runtimePath = this.resolveShellNodeCommand()
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info(
|
const spawnDetails = supportsUserShell()
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
|
||||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
|
||||||
|
|
||||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
env: { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
cwd: process.cwd(),
|
||||||
stdio: "pipe",
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
serviceName: "CodeNomad CLI Supervisor",
|
env,
|
||||||
})
|
shell: false,
|
||||||
this.childLaunchMode = "utility"
|
detached: process.platform !== "win32",
|
||||||
} else {
|
})
|
||||||
console.info(
|
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
if (!child.pid) {
|
||||||
|
|
||||||
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.error("[cli] spawn failed: no pid")
|
console.error("[cli] spawn failed: no pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.child = child
|
this.child = child
|
||||||
|
this.childStartIdentity = child.pid ? getProcessStartIdentityAsync(child.pid, 1_500) : Promise.resolve(undefined)
|
||||||
this.updateStatus({ pid: child.pid ?? undefined })
|
this.updateStatus({ pid: child.pid ?? undefined })
|
||||||
|
|
||||||
const stdout = child.stdout as NodeJS.ReadableStream | undefined
|
const stdout = child.stdout as NodeJS.ReadableStream | undefined
|
||||||
|
|
@ -223,48 +226,25 @@ export class CliProcessManager extends EventEmitter {
|
||||||
this.handleStream(data.toString(), "stderr")
|
this.handleStream(data.toString(), "stderr")
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.childLaunchMode === "utility") {
|
child.on("error", (error) => {
|
||||||
const utilityChild = child as UtilityProcess
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
utilityChild.on("error", (error) => {
|
child.on("exit", (code, signal) => {
|
||||||
const message = this.describeUtilityProcessError(error)
|
if (this.child !== child) return
|
||||||
console.error("[cli] utility supervisor failed:", error)
|
const failed = this.status.state !== "ready"
|
||||||
this.updateStatus({ state: "error", error: message })
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||||
this.emit("error", new Error(message))
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||||
})
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
utilityChild.on("exit", (code) => {
|
this.emit("error", new Error(error))
|
||||||
const failed = this.status.state !== "ready"
|
}
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
this.emit("exit", this.status)
|
||||||
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
|
this.child = undefined
|
||||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
this.childStartIdentity = undefined
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<CliStatus>((resolve, reject) => {
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
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
|
const child = this.child
|
||||||
if (!child) {
|
if (!child) {
|
||||||
this.updateStatus({ state: "stopped" })
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.childLaunchMode === "utility") {
|
const spawnedChild = child
|
||||||
return this.stopUtilityChild(child as UtilityProcess)
|
|
||||||
}
|
|
||||||
|
|
||||||
const spawnedChild = child as ChildProcess
|
|
||||||
|
|
||||||
this.requestedStop = true
|
this.requestedStop = true
|
||||||
|
|
||||||
|
|
@ -308,138 +284,64 @@ export class CliProcessManager extends EventEmitter {
|
||||||
|
|
||||||
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||||
|
|
||||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
const deadlineAt = Date.now() + CLI_STOP_DEADLINE_MS
|
||||||
try {
|
const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined)
|
||||||
// Negative PID targets the process group (POSIX).
|
const { tree: initialCapture, rootStartIdentity } = await captureInitialProcessTree(
|
||||||
process.kill(-pid, signal)
|
pid,
|
||||||
return true
|
process.platform,
|
||||||
} catch (error) {
|
undefined,
|
||||||
const err = error as NodeJS.ErrnoException
|
() => spawnedIdentity,
|
||||||
if (err?.code === "ESRCH") {
|
deadlineAt,
|
||||||
return true
|
)
|
||||||
}
|
let processTree = rootStartIdentity
|
||||||
return false
|
? 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) => {
|
let forceConfirmed = false
|
||||||
try {
|
const enforceIncompleteCleanup = () => {
|
||||||
process.kill(pid, signal)
|
void forceProcessTree().then((confirmed) => {
|
||||||
return true
|
forceConfirmed = confirmed
|
||||||
} catch (error) {
|
if (!confirmed) console.warn(`[cli] immediate enforcement after incomplete cleanup was not confirmed (pid=${pid})`)
|
||||||
const err = error as NodeJS.ErrnoException
|
}, (error) => {
|
||||||
if (err?.code === "ESRCH") {
|
console.warn(`[cli] immediate enforcement after incomplete cleanup failed (pid=${pid})`, error)
|
||||||
return true
|
forceConfirmed = false
|
||||||
}
|
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
this.once("shutdownIncomplete", enforceIncompleteCleanup)
|
||||||
|
|
||||||
if (isAlreadyExited()) {
|
try {
|
||||||
clearTimeout(killTimeout)
|
await stopManagedChild({
|
||||||
this.child = undefined
|
child: spawnedChild,
|
||||||
this.updateStatus({ state: "stopped" })
|
isExited: isAlreadyExited,
|
||||||
resolve()
|
force: async (forceDeadline) => forceConfirmed || await forceProcessTree(forceDeadline),
|
||||||
return
|
isCleanupComplete: () => this.shutdownStatus === "complete",
|
||||||
}
|
deadlineMs: CLI_STOP_DEADLINE_MS,
|
||||||
|
deadlineAt,
|
||||||
sendStopSignal("SIGTERM")
|
forceReserveMs: 5_000,
|
||||||
})
|
warn: (message, error) => console.warn(`[cli] ${message} (pid=${pid})`, error ?? ""),
|
||||||
}
|
})
|
||||||
|
} finally {
|
||||||
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
this.off("shutdownIncomplete", enforceIncompleteCleanup)
|
||||||
this.requestedStop = true
|
}
|
||||||
|
if (this.shutdownStatus !== "complete") {
|
||||||
const pid = child.pid
|
console.warn(`[cli] CLI exited without a complete graceful-shutdown handshake (status=${this.shutdownStatus ?? "missing"})`)
|
||||||
if (!pid) {
|
}
|
||||||
|
console.info("[cli] CLI process exited")
|
||||||
|
if (this.child === spawnedChild) {
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
|
this.childStartIdentity = undefined
|
||||||
this.updateStatus({ state: "stopped" })
|
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 {
|
getStatus(): CliStatus {
|
||||||
|
|
@ -455,26 +357,23 @@ export class CliProcessManager extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
const timedOutChild = this.child
|
||||||
const pid = this.child.pid
|
if (timedOutChild) {
|
||||||
if (this.childLaunchMode === "utility") {
|
const pid = timedOutChild.pid
|
||||||
if (pid) {
|
if (pid) {
|
||||||
try {
|
const deadlineAt = Date.now() + 5_000
|
||||||
process.kill(pid, "SIGKILL")
|
const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined)
|
||||||
} catch {
|
void captureInitialProcessTree(pid, process.platform, undefined, () => spawnedIdentity, deadlineAt).then(async ({ tree, rootStartIdentity }) => {
|
||||||
// no-op
|
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
|
||||||
}
|
}
|
||||||
}
|
}).catch((error) => console.warn(`[cli] startup-timeout process tree cleanup failed (pid=${pid})`, error))
|
||||||
} 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")
|
|
||||||
}
|
}
|
||||||
this.child = undefined
|
|
||||||
}
|
}
|
||||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||||
this.emit("error", new 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()
|
const trimmed = line.trim()
|
||||||
if (!trimmed) continue
|
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)) {
|
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
||||||
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
||||||
if (token && !this.bootstrapToken) {
|
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.")
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,12 @@ const localElectronAPI = {
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", 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 = {
|
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()
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"prebuild": "npm run prepare:resources",
|
"prebuild": "npm run prepare:resources",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"build:binaries": "node scripts/build.js",
|
"build:binaries": "node scripts/build.js",
|
||||||
"build:mac": "node scripts/build.js mac",
|
"build:mac": "node scripts/build.js mac",
|
||||||
|
|
@ -131,12 +132,10 @@
|
||||||
"createStartMenuShortcut": true
|
"createStartMenuShortcut": true
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
|
"executableName": "CodeNomad",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "zip"
|
"target": "tar.gz"
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "AppImage"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}",
|
"artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}",
|
||||||
|
|
|
||||||
|
|
@ -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`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
- **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.
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@opencode-ai/sdk": "^1.17.8",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
|
||||||
|
|
||||||
export interface WorkspaceDescriptor {
|
export interface WorkspaceDescriptor {
|
||||||
id: string
|
id: string
|
||||||
|
/** Correlates creation events with the client request that initiated them. */
|
||||||
|
requestId?: string
|
||||||
/** Absolute path on the server host. */
|
/** Absolute path on the server host. */
|
||||||
path: string
|
path: string
|
||||||
name?: string
|
name?: string
|
||||||
|
|
@ -38,6 +40,9 @@ export interface WorkspaceDescriptor {
|
||||||
export interface WorkspaceCreateRequest {
|
export interface WorkspaceCreateRequest {
|
||||||
path: string
|
path: string
|
||||||
name?: string
|
name?: string
|
||||||
|
binaryPath?: string
|
||||||
|
requestId?: string
|
||||||
|
forceNew?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCloneRequest {
|
export interface WorkspaceCloneRequest {
|
||||||
|
|
@ -50,7 +55,10 @@ export interface WorkspaceCloneResponse {
|
||||||
path: string
|
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 WorkspaceListResponse = WorkspaceDescriptor[]
|
||||||
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
||||||
|
|
||||||
|
|
@ -59,6 +67,26 @@ export interface WorkspaceDeleteResponse {
|
||||||
status: WorkspaceStatus
|
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 type WorktreeKind = "root" | "worktree"
|
||||||
|
|
||||||
export interface WorktreeDescriptor {
|
export interface WorktreeDescriptor {
|
||||||
|
|
@ -328,6 +356,19 @@ export interface BinaryValidationResult {
|
||||||
error?: string
|
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 {
|
export interface SpeechSegment {
|
||||||
startMs: number
|
startMs: number
|
||||||
endMs: number
|
endMs: number
|
||||||
|
|
@ -347,6 +388,11 @@ export interface SpeechCapabilitiesResponse {
|
||||||
ttsVoice: string
|
ttsVoice: string
|
||||||
ttsFormats: string[]
|
ttsFormats: string[]
|
||||||
streamingTtsFormats: string[]
|
streamingTtsFormats: string[]
|
||||||
|
separateProviders?: boolean
|
||||||
|
sttConfigured?: boolean
|
||||||
|
ttsConfigured?: boolean
|
||||||
|
sttBaseUrl?: string
|
||||||
|
ttsBaseUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeechTranscriptionResponse {
|
export interface SpeechTranscriptionResponse {
|
||||||
|
|
@ -365,6 +411,14 @@ export interface VoiceModeStateResponse {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface YoloStateResponse {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMetadataResponse {
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteServerProfile {
|
export interface RemoteServerProfile {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -414,12 +468,14 @@ export type WorkspaceEventType =
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
|
| "yolo.stateChanged"
|
||||||
|
| "yolo.autoAccepted"
|
||||||
|
|
||||||
export type WorkspaceEventPayload =
|
export type WorkspaceEventPayload =
|
||||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.error"; 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: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "sidecar.updated"; sidecar: SideCar }
|
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||||
| { type: "sidecar.removed"; sidecarId: string }
|
| { type: "sidecar.removed"; sidecarId: string }
|
||||||
|
|
@ -428,6 +484,8 @@ export type WorkspaceEventPayload =
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { 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 {
|
export interface NetworkAddress {
|
||||||
ip: string
|
ip: 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({}),
|
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
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),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
|
usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
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"
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
export class EventBus extends EventEmitter {
|
export class EventBus extends EventEmitter {
|
||||||
|
private readonly instanceStatuses = new Map<string, Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>>()
|
||||||
|
|
||||||
constructor(private readonly logger?: Logger) {
|
constructor(private readonly logger?: Logger) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(event: WorkspaceEventPayload): boolean {
|
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") {
|
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||||
this.logger?.debug({ type: event.type }, "Publishing workspace event")
|
this.logger?.debug({ type: event.type }, "Publishing workspace event")
|
||||||
if (this.logger?.isLevelEnabled("trace")) {
|
if (this.logger?.isLevelEnabled("trace")) {
|
||||||
|
|
@ -31,6 +42,9 @@ export class EventBus extends EventEmitter {
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", 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 () => {
|
return () => {
|
||||||
this.off("workspace.created", handler)
|
this.off("workspace.created", handler)
|
||||||
this.off("workspace.started", handler)
|
this.off("workspace.started", handler)
|
||||||
|
|
@ -44,6 +58,8 @@ export class EventBus extends EventEmitter {
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", 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 workspacePath = "/tmp/workspace"
|
||||||
const startTime = 1_000
|
const startTime = 1_000
|
||||||
|
|
||||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
|
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], startTime)
|
||||||
|
|
||||||
const beforeExpiry = getWorkspaceCandidates(
|
const beforeExpiry = getWorkspaceCandidates(
|
||||||
workspacePath,
|
workspacePath,
|
||||||
|
"query-a",
|
||||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
||||||
)
|
)
|
||||||
assert.ok(beforeExpiry)
|
assert.ok(beforeExpiry)
|
||||||
|
|
@ -29,6 +30,7 @@ describe("workspace search cache", () => {
|
||||||
|
|
||||||
const afterExpiry = getWorkspaceCandidates(
|
const afterExpiry = getWorkspaceCandidates(
|
||||||
workspacePath,
|
workspacePath,
|
||||||
|
"query-a",
|
||||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
||||||
)
|
)
|
||||||
assert.equal(afterExpiry, undefined)
|
assert.equal(afterExpiry, undefined)
|
||||||
|
|
@ -37,16 +39,32 @@ describe("workspace search cache", () => {
|
||||||
it("replaces cached entries when manually refreshed", () => {
|
it("replaces cached entries when manually refreshed", () => {
|
||||||
const workspacePath = "/tmp/workspace"
|
const workspacePath = "/tmp/workspace"
|
||||||
|
|
||||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
|
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000)
|
||||||
const initial = getWorkspaceCandidates(workspacePath, 5_001)
|
const initial = getWorkspaceCandidates(workspacePath, "query-a", 5_001)
|
||||||
assert.ok(initial)
|
assert.ok(initial)
|
||||||
assert.equal(initial[0].name, "file-a")
|
assert.equal(initial[0].name, "file-a")
|
||||||
|
|
||||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
|
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-b")], 6_000)
|
||||||
const refreshed = getWorkspaceCandidates(workspacePath, 6_001)
|
const refreshed = getWorkspaceCandidates(workspacePath, "query-a", 6_001)
|
||||||
assert.ok(refreshed)
|
assert.ok(refreshed)
|
||||||
assert.equal(refreshed[0].name, "file-b")
|
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 {
|
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
|
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
|
||||||
|
|
||||||
interface WorkspaceCandidateCacheEntry {
|
interface WorkspaceCandidateCacheEntry {
|
||||||
|
scope: string
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
candidates: FileSystemEntry[]
|
candidates: FileSystemEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
|
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 key = normalizeKey(rootDir)
|
||||||
const cached = workspaceCandidateCache.get(key)
|
const cached = workspaceCandidateCache.get(key)
|
||||||
if (!cached) {
|
if (!cached || cached.scope !== scope) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,19 +28,16 @@ export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileS
|
||||||
|
|
||||||
export function refreshWorkspaceCandidates(
|
export function refreshWorkspaceCandidates(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
|
scope: string,
|
||||||
builder: () => FileSystemEntry[],
|
builder: () => FileSystemEntry[],
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
): FileSystemEntry[] {
|
): FileSystemEntry[] {
|
||||||
const key = normalizeKey(rootDir)
|
const key = normalizeKey(rootDir)
|
||||||
const freshCandidates = builder()
|
const freshCandidates = builder()
|
||||||
|
|
||||||
if (!freshCandidates || freshCandidates.length === 0) {
|
|
||||||
workspaceCandidateCache.delete(key)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedCandidates = cloneEntries(freshCandidates)
|
const storedCandidates = cloneEntries(freshCandidates)
|
||||||
workspaceCandidateCache.set(key, {
|
workspaceCandidateCache.set(key, {
|
||||||
|
scope,
|
||||||
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||||
candidates: storedCandidates,
|
candidates: storedCandidates,
|
||||||
})
|
})
|
||||||
|
|
@ -53,8 +51,7 @@ export function clearWorkspaceSearchCache(rootDir?: string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = normalizeKey(rootDir)
|
workspaceCandidateCache.delete(normalizeKey(rootDir))
|
||||||
workspaceCandidateCache.delete(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,19 @@ export function searchWorkspaceFiles(
|
||||||
const limit = normalizeLimit(options.limit)
|
const limit = normalizeLimit(options.limit)
|
||||||
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
||||||
const refreshRequested = options.refresh === true
|
const refreshRequested = options.refresh === true
|
||||||
|
const cacheScope = `${typeFilter}\0${trimmedQuery.toLowerCase()}`
|
||||||
|
|
||||||
let entries: FileSystemEntry[] | undefined
|
let entries: FileSystemEntry[] | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!refreshRequested) {
|
if (!refreshRequested) {
|
||||||
entries = getWorkspaceCandidates(normalizedRoot)
|
entries = getWorkspaceCandidates(normalizedRoot, cacheScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
|
entries = refreshWorkspaceCandidates(normalizedRoot, cacheScope, () =>
|
||||||
|
collectCandidates(normalizedRoot, trimmedQuery, typeFilter),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearWorkspaceSearchCache(normalizedRoot)
|
clearWorkspaceSearchCache(normalizedRoot)
|
||||||
|
|
@ -57,7 +60,6 @@ export function searchWorkspaceFiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entries || entries.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
clearWorkspaceSearchCache(normalizedRoot)
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,16 +82,25 @@ export function searchWorkspaceFiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function collectCandidates(rootDir: string): FileSystemEntry[] {
|
function collectCandidates(rootDir: string, query: string, filter: WorkspaceFileSearchType): FileSystemEntry[] {
|
||||||
const queue: string[] = [""]
|
const queue: Array<{ relativeDir: string; ancestors: ReadonlySet<string> }> = [
|
||||||
|
{ relativeDir: "", ancestors: new Set() },
|
||||||
|
]
|
||||||
const entries: FileSystemEntry[] = []
|
const entries: FileSystemEntry[] = []
|
||||||
|
|
||||||
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
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
|
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
|
||||||
|
|
||||||
let dirents: fs.Dirent[]
|
let dirents: fs.Dirent[]
|
||||||
|
let branchAncestors: ReadonlySet<string>
|
||||||
try {
|
try {
|
||||||
|
const realDir = normalizeDirectoryIdentity(fs.realpathSync.native(absoluteDir))
|
||||||
|
if (ancestors.has(realDir)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
branchAncestors = new Set([...ancestors, realDir])
|
||||||
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
|
|
@ -115,9 +126,7 @@ function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||||
const isDirectory = stats.isDirectory()
|
const isDirectory = stats.isDirectory()
|
||||||
|
|
||||||
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
||||||
if (entries.length < MAX_CANDIDATES) {
|
queue.push({ relativeDir: relativePath, ancestors: branchAncestors })
|
||||||
queue.push(relativePath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
||||||
|
|
@ -131,8 +140,11 @@ function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||||
modifiedAt: stats.mtime.toISOString(),
|
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) {
|
if (entries.length >= MAX_CANDIDATES) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -182,3 +194,7 @@ function normalizeRelativeEntryPath(relativePath: string): string {
|
||||||
function buildSearchKey(entry: FileSystemEntry) {
|
function buildSearchKey(entry: FileSystemEntry) {
|
||||||
return entry.path.toLowerCase()
|
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 { PluginChannelManager } from "./plugins/channel"
|
||||||
import { VoiceModeManager } from "./plugins/voice-mode"
|
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||||
import { runCliUpgrade } from "./cli-upgrade"
|
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)
|
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_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
const DEFAULT_HTTPS_PORT = 9898
|
const DEFAULT_HTTPS_PORT = 9898
|
||||||
const DEFAULT_HTTP_PORT = 9899
|
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 {
|
function parseCliOptions(argv: string[]): CliOptions {
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
|
|
@ -343,6 +387,15 @@ async function main() {
|
||||||
logger: logger.child({ component: "sidecars" }),
|
logger: logger.child({ component: "sidecars" }),
|
||||||
})
|
})
|
||||||
const previewManager = new PreviewManager()
|
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({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
|
|
@ -444,6 +497,8 @@ async function main() {
|
||||||
pluginChannel,
|
pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager,
|
||||||
remoteProxySessionManager,
|
remoteProxySessionManager,
|
||||||
|
yoloManager,
|
||||||
|
sessionMetadataPersistence,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
|
|
@ -471,6 +526,8 @@ async function main() {
|
||||||
pluginChannel,
|
pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager,
|
||||||
remoteProxySessionManager,
|
remoteProxySessionManager,
|
||||||
|
yoloManager,
|
||||||
|
sessionMetadataPersistence,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
logger,
|
logger,
|
||||||
|
|
@ -555,68 +612,46 @@ async function main() {
|
||||||
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
|
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 () => {
|
installShutdownSignalHandlers(process, shutdown)
|
||||||
if (shuttingDown) {
|
installShutdownStdinHandler(process.stdin, shutdown)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
if (path.resolve(process.argv[1] ?? "") === __filename) {
|
||||||
const logger = createLogger({ component: "app" })
|
main().catch((error) => {
|
||||||
logger.error({ err: error }, "CLI server crashed")
|
const logger = createLogger({ component: "app" })
|
||||||
process.exit(1)
|
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))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,32 +13,20 @@ import { RemoteProxySessionManager } from "../remote-proxy"
|
||||||
import { resolveHttpsOptions } from "../tls"
|
import { resolveHttpsOptions } from "../tls"
|
||||||
|
|
||||||
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
|
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
|
||||||
const sharedTls = resolveHttpsOptions({
|
const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() })
|
||||||
enabled: true,
|
if (!sharedTls) throw new Error("Failed to generate HTTPS options for remote proxy tests")
|
||||||
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 sharedHttpsOptions = sharedTls.httpsOptions
|
||||||
|
|
||||||
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
|
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
|
||||||
const managers = new Set<RemoteProxySessionManager>()
|
const managers = new Set<RemoteProxySessionManager>()
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const manager of managers) {
|
for (const manager of managers) await manager.shutdown().catch(() => undefined)
|
||||||
await disposeManager(manager)
|
|
||||||
}
|
|
||||||
managers.clear()
|
managers.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
after(() => {
|
after(async () => {
|
||||||
fs.rmSync(sharedTempDir, { recursive: true, force: true })
|
fs.rmSync(sharedTempDir, { recursive: true, force: true })
|
||||||
httpsDispatcher.close().catch(() => {})
|
await httpsDispatcher.destroy().catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("RemoteProxySessionManager", () => {
|
describe("RemoteProxySessionManager", () => {
|
||||||
|
|
@ -47,10 +35,8 @@ describe("RemoteProxySessionManager", () => {
|
||||||
const manager = createSessionManager()
|
const manager = createSessionManager()
|
||||||
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
|
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
|
||||||
assert.equal(blocked.status, 403)
|
assert.equal(blocked.status, 403)
|
||||||
|
|
||||||
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
|
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
|
|
@ -70,9 +56,7 @@ describe("RemoteProxySessionManager", () => {
|
||||||
await withUpstreamServer(async (upstreamBaseUrl) => {
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
const manager = createSessionManager()
|
const manager = createSessionManager()
|
||||||
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
await activateSession(session)
|
await activateSession(session)
|
||||||
|
|
||||||
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
|
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
|
||||||
assert.equal(apiResponse.status, 200)
|
assert.equal(apiResponse.status, 200)
|
||||||
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
|
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
|
||||||
|
|
@ -84,10 +68,8 @@ describe("RemoteProxySessionManager", () => {
|
||||||
const requestUrl = req.url ?? ""
|
const requestUrl = req.url ?? ""
|
||||||
if (requestUrl === "/base/redirect") {
|
if (requestUrl === "/base/redirect") {
|
||||||
res.writeHead(302, { location: "/base/after?ok=1" })
|
res.writeHead(302, { location: "/base/after?ok=1" })
|
||||||
res.end()
|
return res.end()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, { "content-type": "text/plain" })
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
res.end(requestUrl)
|
res.end(requestUrl)
|
||||||
})
|
})
|
||||||
|
|
@ -97,21 +79,17 @@ describe("RemoteProxySessionManager", () => {
|
||||||
await withUpstreamServer(async (upstreamBaseUrl) => {
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
const manager = createSessionManager()
|
const manager = createSessionManager()
|
||||||
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
await activateSession(session)
|
await activateSession(session)
|
||||||
|
|
||||||
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
|
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
|
||||||
assert.equal(loginResponse.status, 200)
|
assert.equal(loginResponse.status, 200)
|
||||||
const setCookie = getSetCookie(loginResponse)[0]
|
const setCookie = getSetCookie(loginResponse)[0]
|
||||||
|
|
||||||
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
|
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
|
||||||
assert.doesNotMatch(setCookie, /domain=/i)
|
assert.doesNotMatch(setCookie, /domain=/i)
|
||||||
|
|
||||||
const cookieHeader = setCookie.split(";", 1)[0]
|
const cookieHeader = setCookie.split(";", 1)[0]
|
||||||
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
|
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
|
||||||
headers: { cookie: cookieHeader },
|
headers: { cookie: cookieHeader },
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(await whoamiResponse.text(), "session=abc123")
|
assert.equal(await whoamiResponse.text(), "session=abc123")
|
||||||
}, (req, res) => {
|
}, (req, res) => {
|
||||||
const requestUrl = req.url ?? ""
|
const requestUrl = req.url ?? ""
|
||||||
|
|
@ -120,16 +98,12 @@ describe("RemoteProxySessionManager", () => {
|
||||||
"content-type": "text/plain",
|
"content-type": "text/plain",
|
||||||
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
|
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
|
||||||
})
|
})
|
||||||
res.end("ok")
|
return res.end("ok")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestUrl === "/base/whoami") {
|
if (requestUrl === "/base/whoami") {
|
||||||
res.writeHead(200, { "content-type": "text/plain" })
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
res.end(req.headers.cookie ?? "")
|
return res.end(req.headers.cookie ?? "")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(404, { "content-type": "text/plain" })
|
res.writeHead(404, { "content-type": "text/plain" })
|
||||||
res.end(requestUrl)
|
res.end(requestUrl)
|
||||||
})
|
})
|
||||||
|
|
@ -139,17 +113,13 @@ describe("RemoteProxySessionManager", () => {
|
||||||
await withUpstreamServer(async (upstreamBaseUrl) => {
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
const manager = createSessionManager()
|
const manager = createSessionManager()
|
||||||
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
assert.equal(await manager.deleteSession(session.sessionId), true)
|
assert.equal(await manager.deleteSession(session.sessionId), true)
|
||||||
assert.equal(await manager.deleteSession(session.sessionId), false)
|
assert.equal(await manager.deleteSession(session.sessionId), false)
|
||||||
|
|
||||||
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
|
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
|
||||||
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
|
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
|
||||||
|
|
||||||
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
|
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
|
||||||
await internalCleanup.call(manager)
|
await internalCleanup.call(manager)
|
||||||
|
|
||||||
assert.equal(internalSessions.has(session3.sessionId), false)
|
assert.equal(internalSessions.has(session3.sessionId), false)
|
||||||
assert.equal(await manager.deleteSession(session3.sessionId), false)
|
assert.equal(await manager.deleteSession(session3.sessionId), false)
|
||||||
}, (_req, res) => {
|
}, (_req, res) => {
|
||||||
|
|
@ -157,20 +127,153 @@ describe("RemoteProxySessionManager", () => {
|
||||||
res.end("ok")
|
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({
|
const manager = new RemoteProxySessionManager({
|
||||||
authManager: {
|
authManager: { isLoopbackRequest: () => true } as unknown as AuthManager,
|
||||||
isLoopbackRequest: () => true,
|
logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options,
|
||||||
} as unknown as AuthManager,
|
|
||||||
logger: createStubLogger(),
|
|
||||||
httpsOptions: sharedHttpsOptions,
|
|
||||||
})
|
})
|
||||||
managers.add(manager)
|
managers.add(manager)
|
||||||
return 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) {
|
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
|
||||||
const created = await manager.createSession(baseUrl, false)
|
const created = await manager.createSession(baseUrl, false)
|
||||||
const windowUrl = new URL(created.windowUrl)
|
const windowUrl = new URL(created.windowUrl)
|
||||||
|
|
@ -188,18 +291,14 @@ async function activateSession(session: { proxyOrigin: string; token: string })
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ token: session.token }),
|
body: JSON.stringify({ token: session.token }),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
const body = (await response.json()) as { ok?: boolean }
|
const body = (await response.json()) as { ok?: boolean }
|
||||||
return body.ok === true
|
return body.ok === true
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
|
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
|
||||||
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
|
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
|
||||||
if (Array.isArray(values) && values.length > 0) {
|
if (Array.isArray(values) && values.length > 0) return values
|
||||||
return values
|
|
||||||
}
|
|
||||||
const fallback = response.headers.get("set-cookie")
|
const fallback = response.headers.get("set-cookie")
|
||||||
return fallback ? [fallback] : []
|
return fallback ? [fallback] : []
|
||||||
}
|
}
|
||||||
|
|
@ -208,26 +307,15 @@ async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
|
||||||
return fetch(url, { dispatcher: httpsDispatcher, ...init })
|
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(
|
async function withUpstreamServer(
|
||||||
callback: (baseUrl: string) => Promise<void>,
|
callback: (baseUrl: string) => Promise<void>,
|
||||||
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
|
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
|
||||||
) {
|
) {
|
||||||
const server = http.createServer(handler)
|
const server = http.createServer(handler)
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const address = server.address()
|
const address = server.address()
|
||||||
if (!address || typeof address === "string") {
|
if (!address || typeof address === "string") throw new Error("Failed to resolve upstream server address")
|
||||||
throw new Error("Failed to resolve upstream server address")
|
|
||||||
}
|
|
||||||
await callback(`http://127.0.0.1:${address.port}`)
|
await callback(`http://127.0.0.1:${address.port}`)
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
||||||
|
|
@ -235,14 +323,6 @@ async function withUpstreamServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStubLogger(): Logger {
|
function createStubLogger(): Logger {
|
||||||
const logger = {
|
const logger = { info() {}, warn() {}, error() {}, child() { return logger } }
|
||||||
info() {},
|
|
||||||
warn() {},
|
|
||||||
error() {},
|
|
||||||
child() {
|
|
||||||
return logger
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return logger as unknown as Logger
|
return logger as unknown as Logger
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,20 @@ import { registerEventRoutes } from "./routes/events"
|
||||||
import { registerStorageRoutes } from "./routes/storage"
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
|
import { registerYoloRoutes } from "./routes/yolo"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
|
import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update"
|
||||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
||||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||||
import { registerPreviewRoutes } from "./routes/previews"
|
import { registerPreviewRoutes } from "./routes/previews"
|
||||||
|
import { registerUsageRoutes } from "./routes/usage"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
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 type { AuthManager } from "../auth/manager"
|
||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-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 { SideCarManager } from "../sidecars/manager"
|
||||||
import type { PreviewManager } from "../previews/manager"
|
import type { PreviewManager } from "../previews/manager"
|
||||||
import type { RemoteProxySessionManager } from "./remote-proxy"
|
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||||
|
import { createOpenCodeUpdateService } from "../opencode-update/service"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
|
|
@ -63,6 +69,8 @@ interface HttpServerDeps {
|
||||||
pluginChannel: PluginChannelManager
|
pluginChannel: PluginChannelManager
|
||||||
voiceModeManager: VoiceModeManager
|
voiceModeManager: VoiceModeManager
|
||||||
remoteProxySessionManager: RemoteProxySessionManager
|
remoteProxySessionManager: RemoteProxySessionManager
|
||||||
|
yoloManager: AutoAcceptManager
|
||||||
|
sessionMetadataPersistence: OpencodeYoloPersistence
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
|
@ -269,6 +277,10 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||||
|
registerOpenCodeUpdateRoutes(app, {
|
||||||
|
service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager),
|
||||||
|
logger: apiLogger,
|
||||||
|
})
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerConfigFileRoutes(app)
|
registerConfigFileRoutes(app)
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
|
|
@ -278,7 +290,10 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||||
logger: sseLogger,
|
logger: sseLogger,
|
||||||
connectionManager: deps.clientConnectionManager,
|
connectionManager: deps.clientConnectionManager,
|
||||||
})
|
})
|
||||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorktreeRoutes(app, {
|
||||||
|
workspaceManager: deps.workspaceManager,
|
||||||
|
sessionMetadataPersistence: deps.sessionMetadataPersistence,
|
||||||
|
})
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
instanceStore: deps.instanceStore,
|
instanceStore: deps.instanceStore,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
|
|
@ -289,6 +304,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||||
registerPreviewRoutes(app, { previewManager: deps.previewManager })
|
registerPreviewRoutes(app, { previewManager: deps.previewManager })
|
||||||
|
registerUsageRoutes(app)
|
||||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||||
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger })
|
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger })
|
||||||
setupSideCarWebSocketProxy(app, {
|
setupSideCarWebSocketProxy(app, {
|
||||||
|
|
@ -309,6 +325,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||||
voiceModeManager: deps.voiceModeManager,
|
voiceModeManager: deps.voiceModeManager,
|
||||||
})
|
})
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
|
registerYoloRoutes(app, { yoloManager: deps.yoloManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,18 @@ const LOOPBACK_HOST = "127.0.0.1"
|
||||||
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
|
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
|
||||||
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
|
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
|
||||||
const SESSION_IDLE_TTL_MS = 30 * 60_000
|
const SESSION_IDLE_TTL_MS = 30 * 60_000
|
||||||
|
const SESSION_DISPOSAL_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
interface RemoteProxySession {
|
interface RemoteProxySession {
|
||||||
id: string
|
id: string
|
||||||
bootstrapToken: string
|
bootstrapToken: string
|
||||||
targetBaseUrl: URL
|
targetBaseUrl: URL
|
||||||
skipTlsVerify: boolean
|
|
||||||
localBaseUrl: URL
|
localBaseUrl: URL
|
||||||
entryUrl: URL
|
|
||||||
bootstrapUrl: string
|
|
||||||
activated: boolean
|
activated: boolean
|
||||||
cookiePrefix: string
|
cookiePrefix: string
|
||||||
app: FastifyInstance
|
app: FastifyInstance
|
||||||
dispatcher?: Agent
|
dispatcher?: Agent
|
||||||
createdAt: number
|
abortController: AbortController
|
||||||
lastAccessAt: number
|
lastAccessAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,6 +29,7 @@ export interface RemoteProxySessionManagerOptions {
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
logger: Logger
|
logger: Logger
|
||||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
|
disposalTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteProxySessionCreateResult {
|
export interface RemoteProxySessionCreateResult {
|
||||||
|
|
@ -40,16 +39,26 @@ export interface RemoteProxySessionCreateResult {
|
||||||
|
|
||||||
export class RemoteProxySessionManager {
|
export class RemoteProxySessionManager {
|
||||||
private readonly sessions = new Map<string, RemoteProxySession>()
|
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 readonly cleanupTimer: NodeJS.Timeout
|
||||||
|
private shuttingDown = false
|
||||||
|
private shutdownPromise?: Promise<void>
|
||||||
|
|
||||||
constructor(private readonly options: RemoteProxySessionManagerOptions) {
|
constructor(private readonly options: RemoteProxySessionManagerOptions) {
|
||||||
this.cleanupTimer = setInterval(() => {
|
this.cleanupTimer = setInterval(() => void this.cleanupExpiredSessions().catch((error) =>
|
||||||
void this.cleanupExpiredSessions()
|
this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session")), 60_000)
|
||||||
}, 60_000)
|
|
||||||
this.cleanupTimer.unref()
|
this.cleanupTimer.unref()
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
|
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) {
|
if (!this.options.httpsOptions) {
|
||||||
throw new Error("Local HTTPS is required for remote proxy sessions")
|
throw new Error("Local HTTPS is required for remote proxy sessions")
|
||||||
}
|
}
|
||||||
|
|
@ -57,8 +66,9 @@ export class RemoteProxySessionManager {
|
||||||
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
||||||
const sessionId = randomUUID()
|
const sessionId = randomUUID()
|
||||||
const bootstrapToken = randomBytes(32).toString("base64url")
|
const bootstrapToken = randomBytes(32).toString("base64url")
|
||||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {})
|
||||||
const app = Fastify({ logger: false, https: this.options.httpsOptions })
|
const abortController = new AbortController()
|
||||||
|
const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true })
|
||||||
let session: RemoteProxySession | null = null
|
let session: RemoteProxySession | null = null
|
||||||
|
|
||||||
app.removeAllContentTypeParsers()
|
app.removeAllContentTypeParsers()
|
||||||
|
|
@ -99,7 +109,7 @@ export class RemoteProxySessionManager {
|
||||||
reply.send({ ok: true })
|
reply.send({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.all("/*", async (request, reply) => {
|
const handleProxyRequest = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||||
return
|
return
|
||||||
|
|
@ -112,58 +122,72 @@ export class RemoteProxySessionManager {
|
||||||
|
|
||||||
session.lastAccessAt = Date.now()
|
session.lastAccessAt = Date.now()
|
||||||
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
||||||
})
|
}
|
||||||
|
app.all("/*", handleProxyRequest)
|
||||||
app.setNotFoundHandler(async (request, reply) => {
|
app.setNotFoundHandler(handleProxyRequest)
|
||||||
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 })
|
|
||||||
})
|
|
||||||
|
|
||||||
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
|
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
|
||||||
const address = new URL(addressInfo)
|
const address = new URL(addressInfo)
|
||||||
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
|
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
|
||||||
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
|
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
|
||||||
const returnTo = buildReturnToTarget(entryUrl)
|
const returnTo = buildReturnToTarget(entryUrl)
|
||||||
|
const bootstrapUrl = `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`
|
||||||
|
|
||||||
session = {
|
session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
bootstrapToken,
|
bootstrapToken,
|
||||||
targetBaseUrl,
|
targetBaseUrl,
|
||||||
skipTlsVerify,
|
|
||||||
localBaseUrl,
|
localBaseUrl,
|
||||||
entryUrl,
|
|
||||||
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
|
|
||||||
activated: false,
|
activated: false,
|
||||||
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
||||||
app,
|
app,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
createdAt: Date.now(),
|
abortController,
|
||||||
lastAccessAt: Date.now(),
|
lastAccessAt: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessions.set(sessionId, session)
|
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(
|
this.options.logger.info(
|
||||||
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
|
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
|
||||||
"Created remote proxy session",
|
"Created remote proxy session",
|
||||||
)
|
)
|
||||||
|
|
||||||
return { sessionId, windowUrl: session.bootstrapUrl }
|
return { sessionId, windowUrl: bootstrapUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSession(sessionId: string): Promise<boolean> {
|
async deleteSession(sessionId: string): Promise<boolean> {
|
||||||
return this.disposeSession(sessionId)
|
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() {
|
private async cleanupExpiredSessions() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const session of Array.from(this.sessions.values())) {
|
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)
|
const session = this.sessions.get(sessionId)
|
||||||
if (!session) {
|
if (!session) return Promise.resolve(false)
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sessions.delete(sessionId)
|
session.abortController.abort()
|
||||||
session.dispatcher?.close().catch(() => {})
|
const disposal = this.trackDisposal(this.disposeResources(session.app, session.dispatcher).then(() => {
|
||||||
await session.app.close().catch(() => {})
|
if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId)
|
||||||
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
||||||
return true
|
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,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
dispatcher: session.dispatcher,
|
dispatcher: session.dispatcher,
|
||||||
|
signal: session.abortController.signal,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@
|
||||||
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
||||||
|
|
||||||
<label for="username">Username</label>
|
<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>
|
<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>
|
<button id="submit" type="button">Continue</button>
|
||||||
<div id="error" class="error" style="display: none"></div>
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
hideError()
|
hideError()
|
||||||
const username = $("username").value.trim()
|
const username = $("username").value
|
||||||
const password = $("password").value
|
const password = $("password").value
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
showError("Username and password are required.")
|
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)
|
const escapedUsername = escapeHtml(defaultUsername)
|
||||||
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
|
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, () => escapedUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenHtml(): string {
|
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 }
|
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) {
|
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
// Full-document access
|
// Full-document access
|
||||||
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
||||||
app.patch("/api/storage/config", async (request, reply) => {
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
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) => {
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
try {
|
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(
|
return sanitizeConfigOwner(
|
||||||
request.params.owner,
|
request.params.owner,
|
||||||
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
deps.settings.mergePatchOwner("config", request.params.owner, processed),
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(400)
|
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({
|
const WorkspaceCreateSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
name: z.string().optional(),
|
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({
|
const WorkspaceCloneSchema = z.object({
|
||||||
|
|
@ -22,6 +25,10 @@ const WorkspaceCloneSchema = z.object({
|
||||||
cleanup: z.boolean().optional(),
|
cleanup: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WorkspaceCreationReleaseSchema = z.object({
|
||||||
|
requestId: z.string().trim().min(1).max(128),
|
||||||
|
})
|
||||||
|
|
||||||
const WorkspaceFilesQuerySchema = z.object({
|
const WorkspaceFilesQuerySchema = z.object({
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
@ -68,9 +75,13 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.post("/api/workspaces", async (request, reply) => {
|
app.post("/api/workspaces", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
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)
|
reply.code(201)
|
||||||
return workspace
|
return result.created ? result.workspace : { ...result.workspace, reused: true as const }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error({ err: error }, "Failed to create workspace")
|
request.log.error({ err: error }, "Failed to create workspace")
|
||||||
const message = error instanceof Error ? error.message : "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)
|
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<{
|
app.get<{
|
||||||
Params: { id: string }
|
Params: { id: string }
|
||||||
Querystring: { path?: string }
|
Querystring: { path?: string }
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ import {
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
} from "../../workspaces/git-worktrees"
|
} from "../../workspaces/git-worktrees"
|
||||||
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
|
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
|
||||||
|
import type { OpencodeYoloPersistence } from "../../permissions/opencode-yolo-metadata"
|
||||||
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
|
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
|
sessionMetadataPersistence: OpencodeYoloPersistence
|
||||||
}
|
}
|
||||||
|
|
||||||
const WorktreeMapSchema = z.object({
|
const WorktreeMapSchema = z.object({
|
||||||
|
|
@ -26,7 +28,34 @@ const WorktreeCreateSchema = z.object({
|
||||||
branch: z.string().trim().min(1).optional(),
|
branch: z.string().trim().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) })
|
||||||
|
|
||||||
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
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) => {
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||||
const workspace = deps.workspaceManager.get(request.params.id)
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
if (!workspace) {
|
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 {
|
resolveDefault(): ResolvedBinary {
|
||||||
|
return this.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(explicitPath?: string): ResolvedBinary {
|
||||||
const binaries = this.list()
|
const binaries = this.list()
|
||||||
const configuredDefault = readDefaultBinaryPath(this.settings)
|
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||||
const fallback = binaries[0]?.path
|
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)
|
const entry = binaries.find((b) => b.path === path)
|
||||||
return {
|
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
|
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
|
next.speech = speech
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
|
||||||
162
packages/server/src/shutdown.test.ts
Normal file
162
packages/server/src/shutdown.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import {
|
||||||
|
createServerShutdownHandler,
|
||||||
|
orchestrateServerShutdown,
|
||||||
|
SERVER_SHUTDOWN_COMPLETE,
|
||||||
|
SERVER_SHUTDOWN_INCOMPLETE,
|
||||||
|
type ServerShutdownOperations,
|
||||||
|
} from "./shutdown"
|
||||||
|
|
||||||
|
const logger = { info() {}, warn() {}, error() {} }
|
||||||
|
const operations = (overrides: Partial<ServerShutdownOperations> = {}): ServerShutdownOperations => ({
|
||||||
|
stopInstanceEventBridge() {}, stopSidecars() {}, stopClientConnections() {},
|
||||||
|
stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("server shutdown orchestration", () => {
|
||||||
|
it("retries workspace cleanup and preserves shutdown order", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
let attempts = 0
|
||||||
|
await orchestrateServerShutdown(operations({
|
||||||
|
stopRemoteProxySessions: () => { calls.push("remote-proxy") },
|
||||||
|
stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") },
|
||||||
|
stopHttpServers: () => { calls.push("http") },
|
||||||
|
}), logger)
|
||||||
|
assert.deepEqual(calls, ["workspaces-1", "remote-proxy", "workspaces-2", "http"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("closes remaining resources and aggregates the concrete current error", async () => {
|
||||||
|
const failure = new Error("workspace abc POSIX process group is still alive")
|
||||||
|
const closed: string[] = []
|
||||||
|
let attempts = 0
|
||||||
|
await assert.rejects(orchestrateServerShutdown(operations({
|
||||||
|
stopWorkspaces: () => { attempts++; throw failure },
|
||||||
|
stopHttpServers: () => { closed.push("http") },
|
||||||
|
stopReleaseMonitor: () => { closed.push("release-monitor") },
|
||||||
|
}), logger), (error: unknown) => {
|
||||||
|
assert.ok(error instanceof AggregateError)
|
||||||
|
assert.equal(error.errors.length, 1)
|
||||||
|
assert.match(error.errors[0].message, /^stopWorkspaces failed:/)
|
||||||
|
assert.strictEqual(error.errors[0].cause, failure)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.deepEqual([attempts, closed], [2, ["http", "release-monitor"]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts workspace cleanup without waiting for preliminary shutdown", async () => {
|
||||||
|
let releasePreliminary!: () => void
|
||||||
|
const preliminary = new Promise<void>((resolve) => { releasePreliminary = resolve })
|
||||||
|
let workspaceStarted = false
|
||||||
|
const shutdown = orchestrateServerShutdown(operations({
|
||||||
|
stopRemoteProxySessions: () => preliminary,
|
||||||
|
stopWorkspaces: () => { workspaceStarted = true },
|
||||||
|
}), logger)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
assert.equal(workspaceStarted, true)
|
||||||
|
releasePreliminary()
|
||||||
|
await shutdown
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("server shutdown signal boundary", () => {
|
||||||
|
it("reports incomplete cleanup and holds for final tree enforcement", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const exits: number[] = []
|
||||||
|
const statuses: string[] = []
|
||||||
|
let releaseHold!: () => void
|
||||||
|
const hold = new Promise<void>((resolve) => { releaseHold = resolve })
|
||||||
|
const handler = createServerShutdownHandler({
|
||||||
|
shutdown: async () => { calls.push("cleanup"); throw new Error("retained child survived") },
|
||||||
|
logger: { info: () => calls.push("info"), warn() {}, error: () => calls.push("error") },
|
||||||
|
forceExit: (code) => { calls.push("force-exit"); exits.push(code) },
|
||||||
|
setExitCode: () => undefined,
|
||||||
|
reportStatus: (status) => statuses.push(status),
|
||||||
|
holdAfterFailure: () => hold,
|
||||||
|
retryAttempts: 0,
|
||||||
|
})
|
||||||
|
const pending = handler("SIGTERM")
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
assert.deepEqual([exits, statuses, calls], [[], [SERVER_SHUTDOWN_INCOMPLETE], ["info", "cleanup", "error", "error"]])
|
||||||
|
handler("SIGTERM")
|
||||||
|
assert.deepEqual(exits, [1])
|
||||||
|
releaseHold()
|
||||||
|
await pending
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reports complete cleanup before allowing natural exit", async () => {
|
||||||
|
const statuses: string[] = [], exitCodes: number[] = []
|
||||||
|
const handler = createServerShutdownHandler({
|
||||||
|
shutdown: async () => undefined,
|
||||||
|
logger,
|
||||||
|
reportStatus: (status) => statuses.push(status),
|
||||||
|
setExitCode: (code) => exitCodes.push(code),
|
||||||
|
})
|
||||||
|
await handler("stdin")
|
||||||
|
assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE])
|
||||||
|
assert.deepEqual(exitCodes, [0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps retrying identity-aware cleanup during the final enforcement budget", async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const statuses: string[] = [], exitCodes: number[] = []
|
||||||
|
const handler = createServerShutdownHandler({
|
||||||
|
shutdown: async () => {
|
||||||
|
attempts++
|
||||||
|
if (attempts === 1) throw new Error("detached group still alive")
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
reportStatus: (status) => statuses.push(status),
|
||||||
|
setExitCode: (code) => exitCodes.push(code),
|
||||||
|
retryDelayMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
await handler("stdin")
|
||||||
|
assert.equal(attempts, 2)
|
||||||
|
assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE])
|
||||||
|
assert.deepEqual(exitCodes, [0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves containment after the standalone cleanup retry budget", async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const statuses: string[] = [], exitCodes: number[] = [], forcedExits: number[] = []
|
||||||
|
let releaseHold!: () => void
|
||||||
|
const hold = new Promise<void>((resolve) => { releaseHold = resolve })
|
||||||
|
const handler = createServerShutdownHandler({
|
||||||
|
shutdown: async () => { attempts++; throw new Error("process tree remains alive") },
|
||||||
|
logger,
|
||||||
|
reportStatus: (status) => statuses.push(status),
|
||||||
|
setExitCode: (code) => exitCodes.push(code),
|
||||||
|
forceExit: (code) => forcedExits.push(code),
|
||||||
|
holdAfterFailure: () => hold,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
retryAttempts: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = handler("stdin")
|
||||||
|
while (attempts < 3) await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
assert.equal(attempts, 3)
|
||||||
|
assert.deepEqual(statuses, [SERVER_SHUTDOWN_INCOMPLETE])
|
||||||
|
assert.deepEqual(exitCodes, [1])
|
||||||
|
assert.deepEqual(forcedExits, [])
|
||||||
|
handler("SIGTERM")
|
||||||
|
assert.deepEqual(forcedExits, [1])
|
||||||
|
releaseHold()
|
||||||
|
await pending
|
||||||
|
})
|
||||||
|
|
||||||
|
it("escalates a second signal while sharing first-signal cleanup", async () => {
|
||||||
|
let finish!: () => void
|
||||||
|
const cleanup = new Promise<void>((resolve) => { finish = resolve })
|
||||||
|
const exits: number[] = [], exitCodes: number[] = []
|
||||||
|
const handler = createServerShutdownHandler({ shutdown: () => cleanup, logger,
|
||||||
|
forceExit: (code) => exits.push(code), setExitCode: (code) => exitCodes.push(code), reportStatus: () => undefined })
|
||||||
|
const first = handler("SIGINT"), second = handler("SIGTERM")
|
||||||
|
assert.strictEqual(first, second)
|
||||||
|
assert.deepEqual(exits, [1])
|
||||||
|
finish(); await first
|
||||||
|
assert.deepEqual(exitCodes, [0])
|
||||||
|
})
|
||||||
|
})
|
||||||
101
packages/server/src/shutdown.ts
Normal file
101
packages/server/src/shutdown.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
type ShutdownLogger = Pick<import("./logger").Logger, "info" | "warn" | "error">
|
||||||
|
type ShutdownOperation = () => void | Promise<void>
|
||||||
|
|
||||||
|
export type ServerShutdownTrigger = NodeJS.Signals | "stdin"
|
||||||
|
export const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete"
|
||||||
|
export const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete"
|
||||||
|
|
||||||
|
export type ServerShutdownOperations = Record<
|
||||||
|
"stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteProxySessions" | "stopWorkspaces" |
|
||||||
|
"stopHttpServers" | "stopReleaseMonitor",
|
||||||
|
ShutdownOperation
|
||||||
|
>
|
||||||
|
|
||||||
|
export function createServerShutdownHandler(options: { shutdown: () => Promise<void>; logger: ShutdownLogger;
|
||||||
|
forceExit?: (code: number) => void; setExitCode?: (code: number) => void;
|
||||||
|
reportStatus?: (status: string) => void; holdAfterFailure?: () => Promise<void>;
|
||||||
|
retryDelayMs?: number; retryAttempts?: number }) {
|
||||||
|
const forceExit = options.forceExit ?? process.exit
|
||||||
|
const setExitCode = options.setExitCode ?? ((code: number) => { process.exitCode = code })
|
||||||
|
const reportStatus = options.reportStatus ?? ((status: string) => console.log(status))
|
||||||
|
let pending: Promise<void> | undefined
|
||||||
|
return (signal: ServerShutdownTrigger): Promise<void> => {
|
||||||
|
if (pending) {
|
||||||
|
options.logger.error({ signal }, "Additional shutdown signal received; forcing nonzero exit")
|
||||||
|
forceExit(1)
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
options.logger.info({ signal }, "Received shutdown signal, stopping workspaces and server")
|
||||||
|
pending = Promise.resolve().then(options.shutdown).then(() => {
|
||||||
|
options.logger.info({}, "Shutdown complete")
|
||||||
|
reportStatus(SERVER_SHUTDOWN_COMPLETE)
|
||||||
|
setExitCode(0)
|
||||||
|
}, async (error) => {
|
||||||
|
options.logger.error({ err: error }, "Server shutdown incomplete; retrying cleanup")
|
||||||
|
const retryAttempts = Math.max(0, Math.floor(options.retryAttempts ?? 3))
|
||||||
|
for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, options.retryDelayMs ?? 250))
|
||||||
|
try {
|
||||||
|
await options.shutdown()
|
||||||
|
options.logger.info({}, "Shutdown cleanup retry completed")
|
||||||
|
reportStatus(SERVER_SHUTDOWN_COMPLETE)
|
||||||
|
setExitCode(0)
|
||||||
|
return
|
||||||
|
} catch (retryError) {
|
||||||
|
options.logger.warn({ err: retryError, attempt, attempts: retryAttempts }, "Shutdown cleanup retry remains incomplete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.logger.error({ attempts: retryAttempts }, "Shutdown cleanup retries exhausted; preserving process-tree containment")
|
||||||
|
reportStatus(SERVER_SHUTDOWN_INCOMPLETE)
|
||||||
|
setExitCode(1)
|
||||||
|
if (options.holdAfterFailure) await options.holdAfterFailure()
|
||||||
|
})
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function orchestrateServerShutdown(
|
||||||
|
operations: ServerShutdownOperations,
|
||||||
|
logger: ShutdownLogger,
|
||||||
|
workspaceAttempts = 2,
|
||||||
|
): Promise<void> {
|
||||||
|
const errors: unknown[] = []
|
||||||
|
const namedError = (name: keyof ServerShutdownOperations, cause: unknown) => Object.assign(
|
||||||
|
new Error(`${name} failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
||||||
|
{ cause },
|
||||||
|
)
|
||||||
|
const settle = async (pending: Array<[keyof ServerShutdownOperations, ShutdownOperation]>) => {
|
||||||
|
const results = await Promise.allSettled(pending.map(([, run]) => Promise.resolve().then(run)))
|
||||||
|
for (let index = 0; index < results.length; index += 1) {
|
||||||
|
const result = results[index]!
|
||||||
|
if (result.status !== "rejected") continue
|
||||||
|
const error = namedError(pending[index]![0], result.reason)
|
||||||
|
errors.push(error)
|
||||||
|
logger.error({ err: error }, "Server resource shutdown failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceShutdown = (async () => {
|
||||||
|
const attempts = Math.max(1, Math.floor(workspaceAttempts))
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
const [result] = await Promise.allSettled([Promise.resolve().then(operations.stopWorkspaces)])
|
||||||
|
if (result.status === "fulfilled") break
|
||||||
|
if (attempt < attempts) {
|
||||||
|
logger.warn({ err: result.reason, attempt, attempts }, "Workspace manager shutdown failed; retrying cleanup")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const error = namedError("stopWorkspaces", result.reason)
|
||||||
|
errors.push(error)
|
||||||
|
logger.error({ err: error, attempts }, "Workspace manager shutdown failed")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
await Promise.all([
|
||||||
|
settle([
|
||||||
|
["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars],
|
||||||
|
["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions],
|
||||||
|
]),
|
||||||
|
workspaceShutdown,
|
||||||
|
])
|
||||||
|
await settle([["stopHttpServers", operations.stopHttpServers], ["stopReleaseMonitor", operations.stopReleaseMonitor]])
|
||||||
|
if (errors.length) throw new AggregateError(errors, "Server shutdown failed")
|
||||||
|
}
|
||||||
289
packages/server/src/speech/service.test.ts
Normal file
289
packages/server/src/speech/service.test.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import { SpeechService } from "./service"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
function createMockSettings(serverConfig: Record<string, unknown>): SettingsService {
|
||||||
|
return {
|
||||||
|
getOwner: () => serverConfig,
|
||||||
|
} as unknown as SettingsService
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLogger: Logger = {
|
||||||
|
child: () => mockLogger,
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
|
debug: () => {},
|
||||||
|
} as unknown as Logger
|
||||||
|
|
||||||
|
describe("SpeechService direction resolution", () => {
|
||||||
|
describe("separateProviders = false (default)", () => {
|
||||||
|
it("uses shared apiKey for both STT and TTS", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
sttModel: "whisper-1",
|
||||||
|
ttsModel: "tts-1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.separateProviders, false)
|
||||||
|
assert.equal(caps.sttConfigured, true)
|
||||||
|
assert.equal(caps.ttsConfigured, true)
|
||||||
|
assert.equal(caps.configured, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reports unconfigured when no apiKey is set", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: { sttModel: "whisper-1", ttsModel: "tts-1" },
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, false)
|
||||||
|
assert.equal(caps.ttsConfigured, false)
|
||||||
|
assert.equal(caps.configured, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("transcribe throws not-configured when apiKey absent", async () => {
|
||||||
|
const service = new SpeechService(createMockSettings({ speech: {} }), mockLogger)
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }),
|
||||||
|
/not configured/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("separateProviders = true", () => {
|
||||||
|
it("reports per-direction configured status independently", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1", model: "whisper-large-v3" },
|
||||||
|
tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.separateProviders, true)
|
||||||
|
assert.equal(caps.sttConfigured, true)
|
||||||
|
assert.equal(caps.ttsConfigured, true)
|
||||||
|
assert.equal(caps.configured, true)
|
||||||
|
assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1")
|
||||||
|
assert.equal(caps.ttsBaseUrl, "https://api.openai.com/v1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reports partial config when only STT has complete directional pair", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, true)
|
||||||
|
assert.equal(caps.ttsConfigured, false)
|
||||||
|
assert.equal(caps.configured, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT combine directional key with shared URL (incomplete pair)", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-groq" },
|
||||||
|
tts: { apiKey: "sk-deepseek" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, false, "directional key without directional URL should not be configured")
|
||||||
|
assert.equal(caps.ttsConfigured, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT combine shared key with foreign directional URL (incomplete pair)", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { baseUrl: "https://api.groq.com/openai/v1" },
|
||||||
|
tts: { baseUrl: "https://api.deepseek.com/v1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, false)
|
||||||
|
assert.equal(caps.ttsConfigured, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats directional baseUrl matching shared as inherited (no directional key)", () => {
|
||||||
|
const sharedUrl = "https://api.openai.com/v1"
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: sharedUrl,
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { baseUrl: sharedUrl },
|
||||||
|
tts: { baseUrl: sharedUrl },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, true, "directional baseUrl matching shared with no key should inherit shared key")
|
||||||
|
assert.equal(caps.ttsConfigured, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("inherits complete shared pair when neither directional value is set", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
separateProviders: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, true)
|
||||||
|
assert.equal(caps.ttsConfigured, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses complete directional pair when both key and URL are set", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" },
|
||||||
|
tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, true)
|
||||||
|
assert.equal(caps.ttsConfigured, true)
|
||||||
|
assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("directional key with matching shared URL forms a complete pair", () => {
|
||||||
|
const sharedUrl = "https://api.openai.com/v1"
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: sharedUrl,
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-alt-openai", baseUrl: sharedUrl },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, true, "directional key with matching shared URL should be a complete pair")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to shared sttModel/ttsModel when per-direction model is absent", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
sttModel: "shared-stt-model",
|
||||||
|
ttsModel: "shared-tts-model",
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttModel, "shared-stt-model")
|
||||||
|
assert.equal(caps.ttsModel, "shared-tts-model")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses per-direction model when set", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
sttModel: "shared-stt-model",
|
||||||
|
ttsModel: "shared-tts-model",
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1", model: "whisper-large-v3" },
|
||||||
|
tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttModel, "whisper-large-v3")
|
||||||
|
assert.equal(caps.ttsModel, "gpt-4o-mini-tts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("omits sttBaseUrl/ttsBaseUrl when separateProviders is false", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttBaseUrl, undefined)
|
||||||
|
assert.equal(caps.ttsBaseUrl, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("transcribe throws not-configured for incomplete pair (key without URL)", async () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
apiKey: "sk-shared",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-groq" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }),
|
||||||
|
/not configured/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("configured uses && in separate mode (both required for legacy compat)", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: {
|
||||||
|
separateProviders: true,
|
||||||
|
stt: { apiKey: "sk-stt", baseUrl: "https://api.stt.com/v1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.sttConfigured, true)
|
||||||
|
assert.equal(caps.ttsConfigured, false)
|
||||||
|
assert.equal(caps.configured, false, "configured should be false when only one direction is ready")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("configured uses || in shared mode", () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
})
|
||||||
|
const service = new SpeechService(settings, mockLogger)
|
||||||
|
const caps = service.getCapabilities()
|
||||||
|
|
||||||
|
assert.equal(caps.configured, true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -15,6 +15,21 @@ const ServerSpeechSettingsSchema = z.object({
|
||||||
ttsModel: z.string().optional(),
|
ttsModel: z.string().optional(),
|
||||||
ttsVoice: z.string().optional(),
|
ttsVoice: z.string().optional(),
|
||||||
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||||
|
separateProviders: z.boolean().optional(),
|
||||||
|
stt: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().nullish(),
|
||||||
|
baseUrl: z.string().nullish(),
|
||||||
|
model: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
tts: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().nullish(),
|
||||||
|
baseUrl: z.string().nullish(),
|
||||||
|
model: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
@ -38,7 +53,10 @@ export interface SpeechSynthesisStreamResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeechProvider {
|
export interface SpeechProvider {
|
||||||
getCapabilities(): SpeechCapabilitiesResponse
|
getCapabilities(): Omit<
|
||||||
|
SpeechCapabilitiesResponse,
|
||||||
|
"separateProviders" | "sttConfigured" | "ttsConfigured" | "sttBaseUrl" | "ttsBaseUrl"
|
||||||
|
>
|
||||||
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
||||||
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
||||||
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
|
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
|
||||||
|
|
@ -66,33 +84,76 @@ export class SpeechService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getCapabilities(): SpeechCapabilitiesResponse {
|
getCapabilities(): SpeechCapabilitiesResponse {
|
||||||
return this.createProvider().getCapabilities()
|
const parsed = this.parseConfig()
|
||||||
|
const speech = parsed.speech ?? {}
|
||||||
|
const separate = speech.separateProviders === true
|
||||||
|
const sttSettings = this.resolveSttSettingsFrom(speech)
|
||||||
|
const ttsSettings = this.resolveTtsSettingsFrom(speech)
|
||||||
|
|
||||||
|
const sttConfigured = Boolean(sttSettings.apiKey)
|
||||||
|
const ttsConfigured = Boolean(ttsSettings.apiKey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
configured: separate ? (sttConfigured && ttsConfigured) : (sttConfigured || ttsConfigured),
|
||||||
|
provider: sttSettings.provider,
|
||||||
|
supportsStt: true,
|
||||||
|
supportsTts: true,
|
||||||
|
supportsStreamingTts: true,
|
||||||
|
baseUrl: separate ? undefined : (speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined),
|
||||||
|
sttModel: sttSettings.sttModel,
|
||||||
|
ttsModel: ttsSettings.ttsModel,
|
||||||
|
ttsVoice: ttsSettings.ttsVoice,
|
||||||
|
ttsFormats: ["mp3", "wav", "opus", "aac"],
|
||||||
|
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
|
||||||
|
separateProviders: separate,
|
||||||
|
sttConfigured,
|
||||||
|
ttsConfigured,
|
||||||
|
...(separate ? { sttBaseUrl: sttSettings.baseUrl, ttsBaseUrl: ttsSettings.baseUrl } : {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||||
return this.createProvider().transcribe(input)
|
return this.createSttProvider().transcribe(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||||
return this.createProvider().synthesize(input)
|
return this.createTtsProvider().synthesize(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||||
return this.createProvider().synthesizeStream(input)
|
return this.createTtsProvider().synthesizeStream(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
private createProvider(): SpeechProvider {
|
private parseConfig() {
|
||||||
const settings = this.resolveSettings()
|
return ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSttProvider(): SpeechProvider {
|
||||||
|
const settings = this.resolveSttSettings()
|
||||||
return new OpenAICompatibleSpeechProvider({
|
return new OpenAICompatibleSpeechProvider({
|
||||||
settings,
|
settings,
|
||||||
logger: this.logger.child({ provider: settings.provider }),
|
logger: this.logger.child({ provider: settings.provider, direction: "stt" }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveSettings(): NormalizedSpeechSettings {
|
private createTtsProvider(): SpeechProvider {
|
||||||
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
|
const settings = this.resolveTtsSettings()
|
||||||
const speech = parsed.speech ?? {}
|
return new OpenAICompatibleSpeechProvider({
|
||||||
|
settings,
|
||||||
|
logger: this.logger.child({ provider: settings.provider, direction: "tts" }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSttSettings(): NormalizedSpeechSettings {
|
||||||
|
return this.resolveSttSettingsFrom(this.parseConfig().speech ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTtsSettings(): NormalizedSpeechSettings {
|
||||||
|
return this.resolveTtsSettingsFrom(this.parseConfig().speech ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveShared(speech: NonNullable<z.infer<typeof ServerSpeechSettingsSchema>["speech"]>): NormalizedSpeechSettings {
|
||||||
return {
|
return {
|
||||||
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
||||||
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
||||||
|
|
@ -103,4 +164,64 @@ export class SpeechService {
|
||||||
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveSttSettingsFrom(speech: NonNullable<z.infer<typeof ServerSpeechSettingsSchema>["speech"]>): NormalizedSpeechSettings {
|
||||||
|
if (speech.separateProviders !== true) {
|
||||||
|
return this.resolveShared(speech)
|
||||||
|
}
|
||||||
|
const stt = speech.stt ?? {}
|
||||||
|
const sharedBaseUrl = speech.baseUrl?.trim() || undefined
|
||||||
|
const rawDirBaseUrl = stt.baseUrl?.trim() || undefined
|
||||||
|
const dirApiKey = stt.apiKey?.trim() || undefined
|
||||||
|
const dirBaseUrl = dirApiKey
|
||||||
|
? rawDirBaseUrl
|
||||||
|
: (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined)
|
||||||
|
const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl)
|
||||||
|
const hasNoDirectional = !dirApiKey && !dirBaseUrl
|
||||||
|
return {
|
||||||
|
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
||||||
|
apiKey: hasCompleteDirectional
|
||||||
|
? dirApiKey
|
||||||
|
: hasNoDirectional
|
||||||
|
? speech.apiKey?.trim() || process.env.OPENAI_API_KEY
|
||||||
|
: undefined,
|
||||||
|
baseUrl: hasCompleteDirectional || hasNoDirectional
|
||||||
|
? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined)
|
||||||
|
: dirBaseUrl || undefined,
|
||||||
|
sttModel: stt.model?.trim() || speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
||||||
|
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
||||||
|
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
||||||
|
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTtsSettingsFrom(speech: NonNullable<z.infer<typeof ServerSpeechSettingsSchema>["speech"]>): NormalizedSpeechSettings {
|
||||||
|
if (speech.separateProviders !== true) {
|
||||||
|
return this.resolveShared(speech)
|
||||||
|
}
|
||||||
|
const tts = speech.tts ?? {}
|
||||||
|
const sharedBaseUrl = speech.baseUrl?.trim() || undefined
|
||||||
|
const rawDirBaseUrl = tts.baseUrl?.trim() || undefined
|
||||||
|
const dirApiKey = tts.apiKey?.trim() || undefined
|
||||||
|
const dirBaseUrl = dirApiKey
|
||||||
|
? rawDirBaseUrl
|
||||||
|
: (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined)
|
||||||
|
const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl)
|
||||||
|
const hasNoDirectional = !dirApiKey && !dirBaseUrl
|
||||||
|
return {
|
||||||
|
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
||||||
|
apiKey: hasCompleteDirectional
|
||||||
|
? dirApiKey
|
||||||
|
: hasNoDirectional
|
||||||
|
? speech.apiKey?.trim() || process.env.OPENAI_API_KEY
|
||||||
|
: undefined,
|
||||||
|
baseUrl: hasCompleteDirectional || hasNoDirectional
|
||||||
|
? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined)
|
||||||
|
: dirBaseUrl || undefined,
|
||||||
|
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
||||||
|
ttsModel: tts.model?.trim() || speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
||||||
|
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
||||||
|
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
182
packages/server/src/usage/providers/api-key.ts
Normal file
182
packages/server/src/usage/providers/api-key.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import type { UsageProvider } from "../types"
|
||||||
|
import {
|
||||||
|
fetchJson,
|
||||||
|
getCredential,
|
||||||
|
notConfigured,
|
||||||
|
resolveWindowLabel,
|
||||||
|
safeFetch,
|
||||||
|
toNumber,
|
||||||
|
toTimestamp,
|
||||||
|
toUsageWindow,
|
||||||
|
} from "../shared"
|
||||||
|
|
||||||
|
const kimiAliases = ["kimi-for-coding", "kimi"] as const
|
||||||
|
const kimi: UsageProvider = {
|
||||||
|
id: "kimi-for-coding",
|
||||||
|
name: "Kimi for Coding",
|
||||||
|
aliases: kimiAliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const key = getCredential(kimiAliases, ["key", "token"])
|
||||||
|
if (!key) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const payload = await fetchJson("https://api.kimi.com/coding/v1/usages", {
|
||||||
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
|
||||||
|
const usage = payload?.usage
|
||||||
|
if (usage) {
|
||||||
|
const limit = toNumber(usage.limit)
|
||||||
|
const remaining = toNumber(usage.remaining)
|
||||||
|
windows.weekly = toUsageWindow({
|
||||||
|
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
|
||||||
|
resetAt: toTimestamp(usage.resetTime),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const item of Array.isArray(payload?.limits) ? payload.limits : []) {
|
||||||
|
const duration = toNumber(item?.window?.duration)
|
||||||
|
const unit = item?.window?.timeUnit
|
||||||
|
const multiplier = unit === "TIME_UNIT_MINUTE" ? 60 : unit === "TIME_UNIT_HOUR" ? 3600 : unit === "TIME_UNIT_DAY" ? 86_400 : null
|
||||||
|
const seconds = duration !== null && multiplier ? duration * multiplier : null
|
||||||
|
const limit = toNumber(item?.detail?.limit)
|
||||||
|
const remaining = toNumber(item?.detail?.remaining)
|
||||||
|
windows[resolveWindowLabel(seconds)] = toUsageWindow({
|
||||||
|
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
|
||||||
|
windowSeconds: seconds,
|
||||||
|
resetAt: toTimestamp(item?.detail?.resetTime),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const nanoAliases = ["nano-gpt", "nanogpt", "nano_gpt"] as const
|
||||||
|
const nanoGpt: UsageProvider = {
|
||||||
|
id: "nano-gpt",
|
||||||
|
name: "NanoGPT",
|
||||||
|
aliases: nanoAliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const key = getCredential(nanoAliases, ["key", "token"])
|
||||||
|
if (!key) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const payload = await fetchJson("https://nano-gpt.com/api/subscription/v1/usage", {
|
||||||
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
|
||||||
|
for (const [label, source] of [["daily", payload?.daily], ["monthly", payload?.monthly]] as const) {
|
||||||
|
if (!source) continue
|
||||||
|
const fraction = toNumber(source.percentUsed)
|
||||||
|
const used = toNumber(source.used)
|
||||||
|
const limit = toNumber(source.limit ?? source.limits?.[label])
|
||||||
|
windows[label] = toUsageWindow({
|
||||||
|
usedPercent: fraction !== null ? fraction * 100 : used !== null && limit ? (used / limit) * 100 : null,
|
||||||
|
windowSeconds: label === "daily" ? 86_400 : null,
|
||||||
|
resetAt: toTimestamp(source.resetAt ?? payload?.period?.currentPeriodEnd),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRouterAliases = ["openrouter"] as const
|
||||||
|
const openRouter: UsageProvider = {
|
||||||
|
id: "openrouter",
|
||||||
|
name: "OpenRouter",
|
||||||
|
aliases: openRouterAliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const key = getCredential(openRouterAliases, ["key", "token"])
|
||||||
|
if (!key) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const payload = await fetchJson("https://openrouter.ai/api/v1/credits", {
|
||||||
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
const total = toNumber(payload?.data?.total_credits)
|
||||||
|
const used = toNumber(payload?.data?.total_usage)
|
||||||
|
const remaining = total !== null && used !== null ? Math.max(0, total - used) : null
|
||||||
|
return {
|
||||||
|
windows: {
|
||||||
|
credits: toUsageWindow({
|
||||||
|
usedPercent: total && used !== null ? (used / total) * 100 : null,
|
||||||
|
valueLabel: remaining !== null && total !== null ? `$${remaining.toFixed(2)} / $${total.toFixed(2)}` : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTokenLimitProvider(input: { id: string; name: string; aliases: readonly string[]; url: string }): UsageProvider {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
aliases: input.aliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const key = getCredential(input.aliases, ["key", "token"])
|
||||||
|
if (!key) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const payload = await fetchJson(input.url, { headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" } })
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
|
||||||
|
for (const limit of Array.isArray(payload?.data?.limits) ? payload.data.limits : []) {
|
||||||
|
if (limit?.type !== "TOKENS_LIMIT" && limit?.type !== "TIME_LIMIT") continue
|
||||||
|
const duration = toNumber(limit.number)
|
||||||
|
const seconds = limit.type === "TIME_LIMIT" ? 30 * 86_400 : limit.unit === 3 && duration ? duration * 3600 : null
|
||||||
|
const label = limit.type === "TIME_LIMIT" ? "mcp-tools" : resolveWindowLabel(seconds)
|
||||||
|
windows[label] = toUsageWindow({
|
||||||
|
usedPercent: toNumber(limit.percentage),
|
||||||
|
windowSeconds: seconds,
|
||||||
|
resetAt: toTimestamp(limit.nextResetTime),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zai = createTokenLimitProvider({
|
||||||
|
id: "zai-coding-plan",
|
||||||
|
name: "z.ai",
|
||||||
|
aliases: ["zai-coding-plan", "zai", "z.ai"],
|
||||||
|
url: "https://api.z.ai/api/monitor/usage/quota/limit",
|
||||||
|
})
|
||||||
|
|
||||||
|
const zhipu = createTokenLimitProvider({
|
||||||
|
id: "zhipuai-coding-plan",
|
||||||
|
name: "Zhipu AI Coding Plan",
|
||||||
|
aliases: ["zhipuai-coding-plan", "zhipuai", "zhipu"],
|
||||||
|
url: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
|
||||||
|
})
|
||||||
|
|
||||||
|
const waferAliases = ["wafer", "wafer-ai", "wafer_ai", "wafer.ai"] as const
|
||||||
|
const wafer: UsageProvider = {
|
||||||
|
id: "wafer",
|
||||||
|
name: "Wafer.ai",
|
||||||
|
aliases: waferAliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const key = getCredential(waferAliases, ["key", "token"])
|
||||||
|
if (!key) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const payload = await fetchJson("https://pass.wafer.ai/v1/inference/quota", {
|
||||||
|
headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" },
|
||||||
|
})
|
||||||
|
const remaining = toNumber(payload?.remaining_included_requests)
|
||||||
|
const limit = toNumber(payload?.included_request_limit)
|
||||||
|
const startAt = toTimestamp(payload?.window_start)
|
||||||
|
const resetAt = toTimestamp(payload?.window_end)
|
||||||
|
const seconds = startAt !== null && resetAt !== null ? Math.round((resetAt - startAt) / 1000) : 5 * 3600
|
||||||
|
return {
|
||||||
|
windows: {
|
||||||
|
[resolveWindowLabel(seconds)]: toUsageWindow({
|
||||||
|
usedPercent: toNumber(payload?.current_period_used_percent),
|
||||||
|
windowSeconds: seconds,
|
||||||
|
resetAt,
|
||||||
|
valueLabel: remaining !== null && limit !== null ? `${remaining} / ${limit}` : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeyProviders: UsageProvider[] = [kimi, nanoGpt, openRouter, zai, zhipu, wafer]
|
||||||
179
packages/server/src/usage/providers/google.ts
Normal file
179
packages/server/src/usage/providers/google.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import fs from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import type { UsageProvider } from "../types"
|
||||||
|
import { asObject, fetchJson, getAuthEntry, getString, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared"
|
||||||
|
|
||||||
|
const GOOGLE_ENDPOINTS = [
|
||||||
|
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||||
|
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||||
|
"https://cloudcode-pa.googleapis.com",
|
||||||
|
] as const
|
||||||
|
const DEFAULT_PROJECT_ID = "rising-fact-p41fc"
|
||||||
|
|
||||||
|
interface GoogleSource {
|
||||||
|
id: "gemini" | "antigravity"
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string
|
||||||
|
projectId?: string
|
||||||
|
expires?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRefresh(value: unknown): { token?: string; projectId?: string } {
|
||||||
|
const raw = getString(value)
|
||||||
|
if (!raw) return {}
|
||||||
|
const [token, projectId, managedProjectId] = raw.split("|")
|
||||||
|
return { token: getString(token) ?? undefined, projectId: getString(projectId) ?? getString(managedProjectId) ?? undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath: string): any | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleSources(): GoogleSource[] {
|
||||||
|
const sources: GoogleSource[] = []
|
||||||
|
const entry = asObject(getAuthEntry(["google", "google.oauth"]))
|
||||||
|
const oauth = asObject(entry?.oauth) ?? entry
|
||||||
|
if (oauth) {
|
||||||
|
const refresh = parseRefresh(oauth.refresh)
|
||||||
|
const accessToken = getString(oauth.access) ?? getString(oauth.token) ?? undefined
|
||||||
|
if (accessToken || refresh.token) {
|
||||||
|
sources.push({
|
||||||
|
id: "gemini",
|
||||||
|
accessToken,
|
||||||
|
refreshToken: refresh.token,
|
||||||
|
projectId: refresh.projectId,
|
||||||
|
expires: toTimestamp(oauth.expires),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = os.homedir()
|
||||||
|
for (const candidate of [
|
||||||
|
path.join(home, ".config", "opencode", "antigravity-accounts.json"),
|
||||||
|
path.join(home, ".local", "share", "opencode", "antigravity-accounts.json"),
|
||||||
|
]) {
|
||||||
|
const data = readJson(candidate)
|
||||||
|
const accounts = Array.isArray(data?.accounts) ? data.accounts : []
|
||||||
|
const account = accounts[data?.activeIndex ?? 0] ?? accounts[0]
|
||||||
|
const refresh = parseRefresh(account?.refreshToken)
|
||||||
|
const accessToken = getString(account?.accessToken) ?? getString(account?.access_token) ?? undefined
|
||||||
|
if (accessToken || refresh.token) {
|
||||||
|
sources.push({
|
||||||
|
id: "antigravity",
|
||||||
|
accessToken,
|
||||||
|
refreshToken: refresh.token,
|
||||||
|
projectId: getString(account?.projectId) ?? refresh.projectId,
|
||||||
|
expires: toTimestamp(account?.expiresAt ?? account?.expires),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(source: GoogleSource): Promise<string | null> {
|
||||||
|
if (!source.refreshToken) return null
|
||||||
|
const prefix = source.id === "gemini" ? "GOOGLE" : "ANTIGRAVITY"
|
||||||
|
const clientId = getString(process.env[`${prefix}_OAUTH_CLIENT_ID`])
|
||||||
|
const clientSecret = getString(process.env[`${prefix}_OAUTH_CLIENT_SECRET`])
|
||||||
|
if (!clientId || !clientSecret) return null
|
||||||
|
const payload = await fetchJson("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: source.refreshToken,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return getString(payload?.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformModel(sourceId: string, modelId: string, data: any) {
|
||||||
|
const fraction = toNumber(data?.quotaInfo?.remainingFraction ?? data?.remainingFraction)
|
||||||
|
const resetAt = toTimestamp(data?.quotaInfo?.resetTime ?? data?.resetTime)
|
||||||
|
const label = sourceId === "gemini" || (resetAt !== null && resetAt - Date.now() > 10 * 3600 * 1000) ? "daily" : "5h"
|
||||||
|
return {
|
||||||
|
[`${sourceId}/${modelId}`]: {
|
||||||
|
windows: {
|
||||||
|
[label]: toUsageWindow({
|
||||||
|
usedPercent: fraction === null ? null : 100 - fraction * 100,
|
||||||
|
windowSeconds: label === "daily" ? 86_400 : 5 * 3600,
|
||||||
|
resetAt,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const google: UsageProvider = {
|
||||||
|
id: "google",
|
||||||
|
name: "Google",
|
||||||
|
aliases: ["google", "google.oauth", "gemini", "antigravity"],
|
||||||
|
async fetchQuota() {
|
||||||
|
const sources = googleSources()
|
||||||
|
if (sources.length === 0) return notConfigured(this.id, this.name)
|
||||||
|
const models: Record<string, { windows: Record<string, ReturnType<typeof toUsageWindow>> }> = {}
|
||||||
|
try {
|
||||||
|
for (const source of sources) {
|
||||||
|
const accessToken = source.accessToken && (!source.expires || source.expires > Date.now())
|
||||||
|
? source.accessToken
|
||||||
|
: await refreshAccessToken(source)
|
||||||
|
if (!accessToken) continue
|
||||||
|
const projectId = source.projectId ?? DEFAULT_PROJECT_ID
|
||||||
|
if (source.id === "gemini") {
|
||||||
|
try {
|
||||||
|
const quota = await fetchJson(`${GOOGLE_ENDPOINTS[2]}/v1internal:retrieveUserQuota`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ project: projectId }),
|
||||||
|
})
|
||||||
|
for (const bucket of Array.isArray(quota?.buckets) ? quota.buckets : []) {
|
||||||
|
const modelId = getString(bucket?.modelId)
|
||||||
|
if (modelId) Object.assign(models, transformModel(source.id, modelId, bucket))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The model endpoint below often remains available when quota buckets are not.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const endpoint of GOOGLE_ENDPOINTS) {
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(`${endpoint}/v1internal:fetchAvailableModels`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "antigravity/1.11.5 windows/amd64",
|
||||||
|
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ project: projectId }),
|
||||||
|
})
|
||||||
|
for (const [modelId, data] of Object.entries(payload?.models ?? {})) {
|
||||||
|
Object.assign(models, transformModel(source.id, modelId, data))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(models).length === 0) throw new Error("No model quota data available")
|
||||||
|
return result(this.id, this.name, { ok: true, configured: true, usage: { windows: {}, models } })
|
||||||
|
} catch (error) {
|
||||||
|
return result(this.id, this.name, {
|
||||||
|
ok: false,
|
||||||
|
configured: true,
|
||||||
|
error: error instanceof Error ? error.message : "Request failed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const googleProviders: UsageProvider[] = [google]
|
||||||
116
packages/server/src/usage/providers/minimax.ts
Normal file
116
packages/server/src/usage/providers/minimax.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import type { UsageProvider } from "../types"
|
||||||
|
import { fetchJson, getCredential, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared"
|
||||||
|
|
||||||
|
const INACTIVE_WINDOW_STATUS = 3
|
||||||
|
|
||||||
|
function pickChatModel(models: any[]): any | null {
|
||||||
|
return (
|
||||||
|
models.find((model) => /^minimax-m/i.test(String(model?.model_name ?? "")) && (toNumber(model?.current_interval_total_count) ?? 0) > 0) ??
|
||||||
|
models.find((model) => ["general", "chat", "text"].includes(String(model?.model_name ?? "").toLowerCase())) ??
|
||||||
|
models.find((model) => toNumber(model?.current_interval_remaining_percent) !== null) ??
|
||||||
|
models[0] ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsablePayload(payload: any): boolean {
|
||||||
|
if (payload?.base_resp && payload.base_resp.status_code !== 0) return false
|
||||||
|
return Array.isArray(payload?.model_remains) && payload.model_remains.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedPercent(model: any, prefix: "interval" | "weekly", tokenPlan: boolean): number | null {
|
||||||
|
const remainingPercent = toNumber(model?.[`current_${prefix}_remaining_percent`])
|
||||||
|
if (remainingPercent !== null) return 100 - remainingPercent
|
||||||
|
const total = toNumber(model?.[`current_${prefix}_total_count`])
|
||||||
|
const rawUsage = toNumber(model?.[`current_${prefix}_usage_count`])
|
||||||
|
if (!total || rawUsage === null) return null
|
||||||
|
const used = tokenPlan ? total - rawUsage : rawUsage
|
||||||
|
return (Math.max(0, used) / total) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowSeconds(model: any, prefix: "interval" | "weekly"): number | null {
|
||||||
|
const startAt = toTimestamp(prefix === "interval" ? model?.start_time : model?.weekly_start_time)
|
||||||
|
const resetAt = toTimestamp(prefix === "interval" ? model?.end_time : model?.weekly_end_time)
|
||||||
|
if (startAt !== null && resetAt !== null && resetAt > startAt) return Math.floor((resetAt - startAt) / 1000)
|
||||||
|
const remainsMs = toNumber(prefix === "interval" ? model?.remains_time : model?.weekly_remains_time)
|
||||||
|
return remainsMs && remainsMs > 0 ? Math.floor(remainsMs / 1000) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMiniMaxProvider(input: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
aliases: readonly string[]
|
||||||
|
tokenPlanUrl: string
|
||||||
|
codingPlanUrl: string
|
||||||
|
}): UsageProvider {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
aliases: input.aliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const key = getCredential(input.aliases, ["key", "token"])
|
||||||
|
if (!key) return notConfigured(this.id, this.name)
|
||||||
|
try {
|
||||||
|
let tokenPlan = true
|
||||||
|
let payload: any = null
|
||||||
|
try {
|
||||||
|
const tokenPayload = await fetchJson(input.tokenPlanUrl, { headers: { Authorization: `Bearer ${key}` } })
|
||||||
|
if (isUsablePayload(tokenPayload)) payload = tokenPayload
|
||||||
|
} catch {
|
||||||
|
// Fall back to the legacy Coding Plan endpoint below.
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
tokenPlan = false
|
||||||
|
const codingPayload = await fetchJson(input.codingPlanUrl, { headers: { Authorization: `Bearer ${key}` } })
|
||||||
|
if (isUsablePayload(codingPayload)) payload = codingPayload
|
||||||
|
}
|
||||||
|
if (!payload) throw new Error("Provider returned no usable quota data")
|
||||||
|
const model = pickChatModel(Array.isArray(payload?.model_remains) ? payload.model_remains : [])
|
||||||
|
if (!model) throw new Error("No model quota data available")
|
||||||
|
const intervalResetAt = toTimestamp(model?.end_time)
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {
|
||||||
|
"5h": toUsageWindow({
|
||||||
|
usedPercent: usedPercent(model, "interval", tokenPlan),
|
||||||
|
windowSeconds: windowSeconds(model, "interval"),
|
||||||
|
resetAt: intervalResetAt,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const weeklyStatus = toNumber(model?.current_weekly_status)
|
||||||
|
const hasWeekly = weeklyStatus !== INACTIVE_WINDOW_STATUS && (
|
||||||
|
toNumber(model?.current_weekly_remaining_percent) !== null || (toNumber(model?.current_weekly_total_count) ?? 0) > 0
|
||||||
|
)
|
||||||
|
if (hasWeekly) {
|
||||||
|
windows.weekly = toUsageWindow({
|
||||||
|
usedPercent: usedPercent(model, "weekly", tokenPlan),
|
||||||
|
windowSeconds: windowSeconds(model, "weekly"),
|
||||||
|
resetAt: toTimestamp(model?.weekly_end_time),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result(this.id, this.name, { ok: true, configured: true, usage: { windows } })
|
||||||
|
} catch (error) {
|
||||||
|
return result(this.id, this.name, {
|
||||||
|
ok: false,
|
||||||
|
configured: true,
|
||||||
|
error: error instanceof Error ? error.message : "Request failed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const miniMaxProviders: UsageProvider[] = [
|
||||||
|
createMiniMaxProvider({
|
||||||
|
id: "minimax-coding-plan",
|
||||||
|
name: "MiniMax Coding Plan",
|
||||||
|
aliases: ["minimax-coding-plan", "minimax"],
|
||||||
|
tokenPlanUrl: "https://api.minimax.io/v1/token_plan/remains",
|
||||||
|
codingPlanUrl: "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
|
||||||
|
}),
|
||||||
|
createMiniMaxProvider({
|
||||||
|
id: "minimax-cn-coding-plan",
|
||||||
|
name: "MiniMax Coding Plan CN",
|
||||||
|
aliases: ["minimax-cn-coding-plan", "minimaxi"],
|
||||||
|
tokenPlanUrl: "https://api.minimaxi.com/v1/token_plan/remains",
|
||||||
|
codingPlanUrl: "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains",
|
||||||
|
}),
|
||||||
|
]
|
||||||
86
packages/server/src/usage/providers/oauth.ts
Normal file
86
packages/server/src/usage/providers/oauth.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { UsageProvider } from "../types"
|
||||||
|
import {
|
||||||
|
fetchJson,
|
||||||
|
getAuthEntry,
|
||||||
|
getCredential,
|
||||||
|
notConfigured,
|
||||||
|
resolveWindowLabel,
|
||||||
|
safeFetch,
|
||||||
|
toNumber,
|
||||||
|
toTimestamp,
|
||||||
|
toUsageWindow,
|
||||||
|
} from "../shared"
|
||||||
|
|
||||||
|
const codexAliases = ["openai", "codex", "chatgpt"] as const
|
||||||
|
const codex: UsageProvider = {
|
||||||
|
id: "codex",
|
||||||
|
name: "Codex",
|
||||||
|
aliases: codexAliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const entry = getAuthEntry(codexAliases)
|
||||||
|
const token = entry && (typeof entry.access === "string" ? entry.access : typeof entry.token === "string" ? entry.token : null)
|
||||||
|
if (!token) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const accountId = typeof entry?.accountId === "string" ? entry.accountId : null
|
||||||
|
const payload = await fetchJson("https://chatgpt.com/backend-api/wham/usage", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(accountId ? { "ChatGPT-Account-Id": accountId } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
|
||||||
|
for (const source of [payload?.rate_limit?.primary_window, payload?.rate_limit?.secondary_window]) {
|
||||||
|
if (!source) continue
|
||||||
|
const seconds = toNumber(source.limit_window_seconds)
|
||||||
|
windows[resolveWindowLabel(seconds)] = toUsageWindow({
|
||||||
|
usedPercent: toNumber(source.used_percent),
|
||||||
|
windowSeconds: seconds,
|
||||||
|
resetAt: toTimestamp(source.reset_at),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const copilotAliases = ["github-copilot", "copilot"] as const
|
||||||
|
const copilot: UsageProvider = {
|
||||||
|
id: "github-copilot",
|
||||||
|
name: "GitHub Copilot",
|
||||||
|
aliases: copilotAliases,
|
||||||
|
async fetchQuota() {
|
||||||
|
const token = getCredential(copilotAliases, ["access", "token"])
|
||||||
|
if (!token) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const payload = await fetchJson("https://api.github.com/copilot_internal/user", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
"Editor-Version": "vscode/1.96.2",
|
||||||
|
"X-Github-Api-Version": "2025-04-01",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const resetAt = toTimestamp(payload?.quota_reset_date)
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
|
||||||
|
for (const [key, snapshot] of Object.entries({
|
||||||
|
chat: payload?.quota_snapshots?.chat,
|
||||||
|
completions: payload?.quota_snapshots?.completions,
|
||||||
|
premium: payload?.quota_snapshots?.premium_interactions,
|
||||||
|
})) {
|
||||||
|
if (!snapshot) continue
|
||||||
|
const source = snapshot as Record<string, unknown>
|
||||||
|
const entitlement = toNumber(source.entitlement)
|
||||||
|
const remaining = toNumber(source.remaining)
|
||||||
|
windows[key] = toUsageWindow({
|
||||||
|
usedPercent: entitlement && remaining !== null ? 100 - (remaining / entitlement) * 100 : null,
|
||||||
|
resetAt,
|
||||||
|
valueLabel: entitlement !== null && remaining !== null ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)}` : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const oauthProviders: UsageProvider[] = [codex, copilot]
|
||||||
145
packages/server/src/usage/providers/special.ts
Normal file
145
packages/server/src/usage/providers/special.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import type { UsageProvider } from "../types"
|
||||||
|
import { fetchJson, getString, notConfigured, safeFetch, toNumber, toTimestamp, toUsageWindow } from "../shared"
|
||||||
|
|
||||||
|
function decodeJwtExpiration(token: string): number | null {
|
||||||
|
try {
|
||||||
|
const payload = token.split(".")[1]
|
||||||
|
if (!payload) return null
|
||||||
|
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
|
||||||
|
return typeof parsed?.exp === "number" ? parsed.exp * 1000 : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCursorToken(): Promise<string | null> {
|
||||||
|
const accessToken = getString(process.env.CURSOR_ACCESS_TOKEN) ?? getString(process.env.CURSOR_TOKEN)
|
||||||
|
const refreshToken = getString(process.env.CURSOR_REFRESH_TOKEN)
|
||||||
|
const expiresAt = accessToken ? decodeJwtExpiration(accessToken) : null
|
||||||
|
if (accessToken && (!expiresAt || expiresAt > Date.now() + 5 * 60_000)) return accessToken
|
||||||
|
if (!refreshToken) return accessToken
|
||||||
|
const payload = await fetchJson("https://api2.cursor.sh/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ grant_type: "refresh_token", client_id: "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB", refresh_token: refreshToken }),
|
||||||
|
})
|
||||||
|
return getString(payload?.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cursorPost(url: string, token: string): Promise<any> {
|
||||||
|
return fetchJson(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
|
||||||
|
body: "{}",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor: UsageProvider = {
|
||||||
|
id: "cursor",
|
||||||
|
name: "Cursor",
|
||||||
|
aliases: ["cursor"],
|
||||||
|
async fetchQuota() {
|
||||||
|
const token = await resolveCursorToken().catch(() => null)
|
||||||
|
if (!token) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const usage = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", token)
|
||||||
|
const plan = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetPlanInfo", token).catch(() => null)
|
||||||
|
const source = usage?.planUsage ?? {}
|
||||||
|
const limit = toNumber(source.limit)
|
||||||
|
const remaining = toNumber(source.remaining)
|
||||||
|
const explicit = toNumber(source.totalPercentUsed)
|
||||||
|
const resetAt = toTimestamp(usage?.billingCycleEnd ?? plan?.planInfo?.billingCycleEnd)
|
||||||
|
return {
|
||||||
|
windows: {
|
||||||
|
billing_cycle: toUsageWindow({
|
||||||
|
usedPercent: explicit ?? (limit && remaining !== null ? ((limit - remaining) / limit) * 100 : null),
|
||||||
|
resetAt,
|
||||||
|
valueLabel: toNumber(source.totalSpend) !== null ? `$${(toNumber(source.totalSpend)! / 100).toFixed(2)}` : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOllamaUsage(html: string) {
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
|
||||||
|
for (const [label, pattern] of [
|
||||||
|
["session", /Session\s+usage[^0-9]*([0-9.]+)%/i],
|
||||||
|
["weekly", /Weekly\s+usage[^0-9]*([0-9.]+)%/i],
|
||||||
|
] as const) {
|
||||||
|
const match = html.match(pattern)
|
||||||
|
if (match) windows[label] = toUsageWindow({ usedPercent: toNumber(match[1]) })
|
||||||
|
}
|
||||||
|
const premium = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i)
|
||||||
|
if (premium) {
|
||||||
|
const used = toNumber(premium[1])
|
||||||
|
const total = toNumber(premium[2])
|
||||||
|
windows.premium = toUsageWindow({
|
||||||
|
usedPercent: total && used !== null ? (used / total) * 100 : null,
|
||||||
|
valueLabel: used !== null && total !== null ? `${used} / ${total}` : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
const ollamaCloud: UsageProvider = {
|
||||||
|
id: "ollama-cloud",
|
||||||
|
name: "Ollama Cloud",
|
||||||
|
aliases: ["ollama-cloud", "ollamacloud"],
|
||||||
|
async fetchQuota() {
|
||||||
|
const cookie = getString(process.env.OLLAMA_CLOUD_COOKIE)
|
||||||
|
if (!cookie) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const response = await fetch("https://ollama.com/settings", {
|
||||||
|
headers: { Cookie: cookie, "User-Agent": "CodeNomad usage provider" },
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`)
|
||||||
|
const windows = parseOllamaUsage(await response.text())
|
||||||
|
if (Object.keys(windows).length === 0) throw new Error("No usage data available")
|
||||||
|
return { windows }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenCodeGoUsage(html: string, now = Date.now()) {
|
||||||
|
const normalized = html.replace(/"|"|\\u0022|\\"/g, '"')
|
||||||
|
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
|
||||||
|
for (const [label, field] of Object.entries({ "5h": "rollingUsage", weekly: "weeklyUsage", monthly: "monthlyUsage" })) {
|
||||||
|
const match = normalized.match(new RegExp(`["']?${field}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, "s"))
|
||||||
|
if (!match) continue
|
||||||
|
const capture = (name: string) => toNumber(match[1]?.match(new RegExp(`["']?${name}["']?\\s*:\\s*["']?(-?\\d+(?:\\.\\d+)?)`))?.[1])
|
||||||
|
const usedPercent = capture("usagePercent")
|
||||||
|
const resetInSec = capture("resetInSec")
|
||||||
|
if (usedPercent !== null && resetInSec !== null) {
|
||||||
|
windows[label] = toUsageWindow({ usedPercent, resetAt: now + Math.max(0, resetInSec) * 1000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCodeGo: UsageProvider = {
|
||||||
|
id: "opencode-go",
|
||||||
|
name: "OpenCode Go",
|
||||||
|
aliases: ["opencode-go", "opencode"],
|
||||||
|
async fetchQuota() {
|
||||||
|
const workspaceId = getString(process.env.OPENCODE_GO_WORKSPACE_ID)
|
||||||
|
const authCookie = getString(process.env.OPENCODE_GO_AUTH_COOKIE)
|
||||||
|
if (!workspaceId || !authCookie) return notConfigured(this.id, this.name)
|
||||||
|
return safeFetch(this.id, this.name, async () => {
|
||||||
|
const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`, {
|
||||||
|
headers: { Cookie: `auth=${authCookie.replace(/^auth=/, "")}`, "User-Agent": "CodeNomad usage provider" },
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`)
|
||||||
|
const windows = parseOpenCodeGoUsage(await response.text())
|
||||||
|
if (Object.keys(windows).length === 0) throw new Error("No usage data available")
|
||||||
|
return { windows }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const specialProviders: UsageProvider[] = [cursor, ollamaCloud, openCodeGo]
|
||||||
70
packages/server/src/usage/service.test.ts
Normal file
70
packages/server/src/usage/service.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { getProviderUsage, resolveUsageProvider, selectModelWindows } from "./service"
|
||||||
|
import type { ProviderResult } from "./types"
|
||||||
|
|
||||||
|
const providerIds = [
|
||||||
|
"codex",
|
||||||
|
"github-copilot",
|
||||||
|
"google",
|
||||||
|
"kimi-for-coding",
|
||||||
|
"nano-gpt",
|
||||||
|
"openrouter",
|
||||||
|
"zai-coding-plan",
|
||||||
|
"zhipuai-coding-plan",
|
||||||
|
"minimax-coding-plan",
|
||||||
|
"minimax-cn-coding-plan",
|
||||||
|
"ollama-cloud",
|
||||||
|
"wafer",
|
||||||
|
"opencode-go",
|
||||||
|
"cursor",
|
||||||
|
]
|
||||||
|
|
||||||
|
test("registers every supported usage provider", () => {
|
||||||
|
for (const providerId of providerIds) {
|
||||||
|
assert.equal(resolveUsageProvider(providerId)?.id, providerId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps OpenCode provider aliases to their usage provider", () => {
|
||||||
|
assert.equal(resolveUsageProvider("openai")?.id, "codex")
|
||||||
|
assert.equal(resolveUsageProvider("copilot")?.id, "github-copilot")
|
||||||
|
assert.equal(resolveUsageProvider("gemini")?.id, "google")
|
||||||
|
assert.equal(resolveUsageProvider("opencode")?.id, "opencode-go")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not use Claude subscription OAuth credentials", () => {
|
||||||
|
assert.equal(resolveUsageProvider("anthropic"), null)
|
||||||
|
assert.equal(resolveUsageProvider("claude"), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("selects model-specific windows by normalized model id", () => {
|
||||||
|
const result: ProviderResult = {
|
||||||
|
providerId: "google",
|
||||||
|
providerName: "Google",
|
||||||
|
ok: true,
|
||||||
|
configured: true,
|
||||||
|
fetchedAt: 1,
|
||||||
|
usage: {
|
||||||
|
windows: {},
|
||||||
|
models: {
|
||||||
|
"gemini/gemini-2.5-flash": {
|
||||||
|
windows: {
|
||||||
|
daily: { usedPercent: 25, remainingPercent: 75, windowSeconds: 86_400, resetAt: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(selectModelWindows(result, "models/gemini-2.5-flash").daily?.usedPercent, 25)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns a stable unsupported response without making a provider request", async () => {
|
||||||
|
const response = await getProviderUsage("unknown-provider", { modelId: "model" })
|
||||||
|
assert.equal(response.supported, false)
|
||||||
|
assert.equal(response.configured, false)
|
||||||
|
assert.equal(response.providerId, null)
|
||||||
|
assert.deepEqual(response.windows, {})
|
||||||
|
})
|
||||||
91
packages/server/src/usage/service.ts
Normal file
91
packages/server/src/usage/service.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { ProviderUsageResponse, ProviderUsageWindow } from "../api-types"
|
||||||
|
import { apiKeyProviders } from "./providers/api-key"
|
||||||
|
import { googleProviders } from "./providers/google"
|
||||||
|
import { miniMaxProviders } from "./providers/minimax"
|
||||||
|
import { oauthProviders } from "./providers/oauth"
|
||||||
|
import { specialProviders } from "./providers/special"
|
||||||
|
import type { ProviderResult, UsageProvider } from "./types"
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60_000
|
||||||
|
const providers = [...oauthProviders, ...apiKeyProviders, ...miniMaxProviders, ...googleProviders, ...specialProviders]
|
||||||
|
const registry = new Map<string, UsageProvider>()
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
registry.set(provider.id, provider)
|
||||||
|
for (const alias of provider.aliases) registry.set(alias.toLowerCase(), provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, { result: ProviderResult; expiresAt: number }>()
|
||||||
|
const pending = new Map<string, Promise<ProviderResult>>()
|
||||||
|
|
||||||
|
export function resolveUsageProvider(providerId: string): UsageProvider | null {
|
||||||
|
return registry.get(providerId.trim().toLowerCase()) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelId(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/^models\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectModelWindows(result: ProviderResult, modelId?: string): Record<string, ProviderUsageWindow> {
|
||||||
|
const usage = result.usage
|
||||||
|
if (!usage) return {}
|
||||||
|
if (!modelId || !usage.models) return usage.windows
|
||||||
|
const target = normalizeModelId(modelId)
|
||||||
|
const entries = Object.entries(usage.models)
|
||||||
|
const exact = entries.find(([name]) => normalizeModelId(name.split("/").pop() ?? name) === target)
|
||||||
|
if (exact) return exact[1].windows
|
||||||
|
const partial = entries.find(([name]) => {
|
||||||
|
const candidate = normalizeModelId(name.split("/").pop() ?? name)
|
||||||
|
return candidate.includes(target) || target.includes(candidate)
|
||||||
|
})
|
||||||
|
return partial?.[1].windows ?? usage.windows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProvider(provider: UsageProvider): Promise<ProviderResult> {
|
||||||
|
const cached = cache.get(provider.id)
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.result
|
||||||
|
const inFlight = pending.get(provider.id)
|
||||||
|
if (inFlight) return inFlight
|
||||||
|
const request = provider.fetchQuota().then((result) => {
|
||||||
|
cache.set(provider.id, { result, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||||
|
return result
|
||||||
|
}).finally(() => pending.delete(provider.id))
|
||||||
|
pending.set(provider.id, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProviderUsage(
|
||||||
|
requestedProviderId: string,
|
||||||
|
options: { modelId?: string } = {},
|
||||||
|
): Promise<ProviderUsageResponse> {
|
||||||
|
const provider = resolveUsageProvider(requestedProviderId)
|
||||||
|
if (!provider) {
|
||||||
|
return {
|
||||||
|
requestedProviderId,
|
||||||
|
providerId: null,
|
||||||
|
providerName: requestedProviderId,
|
||||||
|
modelId: options.modelId,
|
||||||
|
supported: false,
|
||||||
|
configured: false,
|
||||||
|
ok: false,
|
||||||
|
windows: {},
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await fetchProvider(provider)
|
||||||
|
return {
|
||||||
|
requestedProviderId,
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: result.providerName,
|
||||||
|
modelId: options.modelId,
|
||||||
|
supported: true,
|
||||||
|
configured: result.configured,
|
||||||
|
ok: result.ok,
|
||||||
|
windows: selectModelWindows(result, options.modelId),
|
||||||
|
fetchedAt: result.fetchedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearProviderUsageCache(): void {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
34
packages/server/src/usage/shared.test.ts
Normal file
34
packages/server/src/usage/shared.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { getCredential, readOpenCodeAuth, toTimestamp, toUsageWindow } from "./shared"
|
||||||
|
|
||||||
|
test("reads the explicit OpenCode auth file without exposing credentials through the API", () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-usage-"))
|
||||||
|
const authFile = path.join(directory, "auth.json")
|
||||||
|
const previous = process.env.OPENCODE_AUTH_FILE
|
||||||
|
fs.writeFileSync(authFile, JSON.stringify({ openai: { type: "oauth", access: "secret-token" } }))
|
||||||
|
process.env.OPENCODE_AUTH_FILE = authFile
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal((readOpenCodeAuth().openai as Record<string, unknown>).access, "secret-token")
|
||||||
|
assert.equal(getCredential(["openai"], ["access"]), "secret-token")
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.OPENCODE_AUTH_FILE
|
||||||
|
else process.env.OPENCODE_AUTH_FILE = previous
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes percentages and timestamps", () => {
|
||||||
|
assert.deepEqual(toUsageWindow({ usedPercent: 120, resetAt: null }), {
|
||||||
|
usedPercent: 100,
|
||||||
|
remainingPercent: 0,
|
||||||
|
windowSeconds: null,
|
||||||
|
resetAt: null,
|
||||||
|
})
|
||||||
|
assert.equal(toTimestamp(1_700_000_000), 1_700_000_000_000)
|
||||||
|
})
|
||||||
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