From 678517f56c322bdf2e55c8e380d9004ad8a80e65 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 25 Aug 2026 19:29:33 +0900 Subject: [PATCH 01/27] release: v2.33.0-preview.20260825 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7b3d03972eb..ba2b3ad83c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.32.1-preview.20260825", + "version": "2.33.0-preview.20260825", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 809a06ba00340c905dfac4ab588616e638c2fbfd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 27 Aug 2026 21:33:19 +0900 Subject: [PATCH 02/27] release: v2.34.0-preview.20260827 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd0601a9fcd..f13e70a612f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.34.0", + "version": "2.34.0-preview.20260827", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From dc1feaf7fd8f27c9cc416a1bcbd7ecc247962f2e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 00:29:18 +0900 Subject: [PATCH 03/27] release: v2.36.0-preview.20260829 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57f06ad22b3..dc4e2ad765d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.35.0", + "version": "2.36.0-preview.20260829", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 3224168846f4e7b784beffb576f7ecce42308511 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 2 Sep 2026 18:43:29 +0900 Subject: [PATCH 04/27] fix(release): pass the bump job's permissions through the reusable-workflow call (#3262) Both v2.40.0 release dispatches (33615174183 preview, 33615177849 main) died at startup_failure: a workflow_call cannot grant its callee more than the calling job holds, and dev-version-bump.yml's job declares contents+pull- requests write. #3129 wired the call but never dispatched a release, so this is its first live run. The caller job now declares exactly the callee's two permissions; no other job in release.yml gains anything. Co-authored-by: jun (cherry picked from commit 7ce0ba51834740d7b4d5ec4793f6572d84624409) --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 458bb67e0a5..261aece1d18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,14 @@ jobs: bump-dev-version: needs: publish if: ${{ inputs.dry-run != true }} + # A reusable-workflow CALL cannot grant the callee more than the calling job holds, + # and GitHub refuses the whole run at startup when the called workflow's own job + # declares permissions the caller did not pass down ("startup_failure", runs + # 33615174183 / 33615177849 — the first dispatches since #3129 wired this call). + # The callee's job declares exactly these two; nothing else in this file gains them. + permissions: + contents: write + pull-requests: write uses: ./.github/workflows/dev-version-bump.yml with: released-version: v${{ inputs.version }} From 954b99d7bf3395f23ee186d402c43d32824de227 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 8 Sep 2026 17:44:26 +0900 Subject: [PATCH 05/27] release: set preview channel version 2.48.0-preview.20260908 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e2d2f3fe8ea..95794a7f46f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.47.0-preview.20260908", + "version": "2.48.0-preview.20260908", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From d24ff57bc4dd53afbbb1d2c972266e6d89ea5c24 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 8 Sep 2026 17:44:26 +0900 Subject: [PATCH 06/27] release: set main channel version 2.48.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d94d23cab8..6547da65528 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.47.0", + "version": "2.48.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 02044b294b66de3d5799a5ff84e17c3230c8246c Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 19:28:06 +0900 Subject: [PATCH 07/27] chore(release): promote 2.55.0-preview.20260914 to preview Promotes the dev product snapshot 62f02223a0 to the preview train. The 2.55.0 line carries the #4546 cost-guard work: one send budget per logical request with a shared final-recovery reserve, zero-is-zero refusals with a typed error rather than a synthetic 502, compact and the Kiro inner retries admitted against that budget, a finite send ceiling per root workflow with an interactive reserve a fan-out cannot take, and a healthy detour promoted on transient-hold expiry instead of released cold. The previous preview tip 2.54.0-preview.20260914 is already tagged and published and is outranked by v2.54.0, so it could not be re-released; this is a new candidate rather than a re-cut. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9e9f74ae75a..fdcec0ec5d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.55.0", + "version": "2.55.0-preview.20260914", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 58c15b819a1f9bc5611e0387ce5e62b7de82bcf6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 19:47:46 +0900 Subject: [PATCH 08/27] chore(release): promote the verified 2.55.0 product tree to main Same product tree as preview 7bdd1b29b5 / 2.55.0-preview.20260914, which published successfully with its registry smoke green. Only package.json version differs. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fdcec0ec5d3..9e9f74ae75a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.55.0-preview.20260914", + "version": "2.55.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 9bc8acbb8c1fd6a7adfa4f8ed8622ad2b814b62a Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 19:31:52 +0900 Subject: [PATCH 09/27] ci(release): expose Linux bundler diagnostics for stable artifacts --- .github/workflows/release.yml | 4 +++- structure/ops/docs-and-release.md | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e16f4ecbe1f..9a62e7aad67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -351,7 +351,9 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - run: bunx tauri build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + # linuxdeploy suppresses its own stderr at the default verbosity. Keep + # diagnostics on the first attempt; Apple signing commands stay non-verbose. + run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} - name: Rename release assets shell: bash diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index fc183d8f1a6..9fc4dcfa50b 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -520,3 +520,5 @@ Malformed or unreadable records remain unknown. Recovery requires the same compl identity and proven-dead liveness; unknown or transferred ownership never starts another proxy. Direct recovery retains the lease until readiness or its bounded deadline. The normal successful manual-runtime update still prints the existing restart hint. + +Linux release bundling enables Tauri verbosity on the primary attempt so linuxdeploy diagnostics remain visible. macOS signing verbosity and publication/signature gates are unchanged. From 5a7c48c5809d4fdfa626fd68187f1b6de92923ae Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 19:47:05 +0900 Subject: [PATCH 10/27] fix(release): prepare stable platform bundles --- .github/workflows/release.yml | 10 ++++++- desktop/scripts/windows-installer-config.ts | 28 ++++++++++++++++++ structure/ops/docs-and-release.md | 2 ++ .../release-desktop-scripts.test.ts | 29 ++++++++++++++++++- 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 desktop/scripts/windows-installer-config.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a62e7aad67..8bb202733b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -228,6 +228,7 @@ jobs: uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: stable + targets: ${{ runner.os == 'macOS' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Install Linux desktop dependencies if: runner.os == 'Linux' @@ -336,6 +337,13 @@ jobs: echo "$description" | grep -q "flags=.*runtime" echo "$description" | grep -q "Timestamp=" + - name: Prepare Windows installer version + if: runner.os == 'Windows' + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + run: bun desktop/scripts/windows-installer-config.ts "$RELEASE_VERSION" "$RUNNER_TEMP/opencodex-msi.json" + # Release signing is intentionally secret-gated. Developer ID, notarization, # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. @@ -353,7 +361,7 @@ jobs: MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} # linuxdeploy suppresses its own stderr at the default verbosity. Keep # diagnostics on the first attempt; Apple signing commands stay non-verbose. - run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config "${{ runner.os == 'Windows' && format('{0}/opencodex-msi.json', runner.temp) || '{}' }}" - name: Rename release assets shell: bash diff --git a/desktop/scripts/windows-installer-config.ts b/desktop/scripts/windows-installer-config.ts new file mode 100644 index 00000000000..0a5f64c2ce8 --- /dev/null +++ b/desktop/scripts/windows-installer-config.ts @@ -0,0 +1,28 @@ +import { writeFileSync } from "node:fs"; + +/** MSI cannot express SemVer prerelease precedence. Keep public application and + * updater versions intact and override only WiX ProductVersion with the core. + * The pinned Tauri template permits equal-core replacement; manual MSI installs + * therefore do not prevent same-core channel downgrades. */ +export function windowsInstallerVersion(version: string): string { + if (version.length > 128) throw new Error("Public version exceeds the installer metadata bound"); + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version); + if (!match || match[4]?.split(".").some(part => /^\d+$/.test(part) && part.length > 1 && part[0] === "0")) { + throw new Error("A valid public SemVer is required for the Windows installer"); + } + const parts = [match[1]!, match[2]!, match[3]!].map(Number); + if (parts.some((part, index) => !Number.isSafeInteger(part) || part > (index < 2 ? 255 : 65_535))) { + throw new Error("Windows installer version exceeds MSI numeric limits"); + } + return parts.join("."); +} + +export function windowsInstallerConfig(version: string) { + return { bundle: { windows: { wix: { version: windowsInstallerVersion(version) } } } }; +} + +if (import.meta.main) { + const [version, output] = process.argv.slice(2); + if (!version || !output) throw new Error("Usage: windows-installer-config "); + writeFileSync(output, `${JSON.stringify(windowsInstallerConfig(version))}\n`, { mode: 0o600 }); +} diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 9fc4dcfa50b..5495a6728f0 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -522,3 +522,5 @@ Direct recovery retains the lease until readiness or its bounded deadline. The n manual-runtime update still prints the existing restart hint. Linux release bundling enables Tauri verbosity on the primary attempt so linuxdeploy diagnostics remain visible. macOS signing verbosity and publication/signature gates are unchanged. + +Universal macOS release builds install both aarch64-apple-darwin and x86_64-apple-darwin Rust targets. Windows builds consume the private JSON override generated by `desktop/scripts/windows-installer-config.ts`: only WiX ProductVersion uses the validated numeric public version core. Public package/application versions, tags, asset names and updater manifests retain full SemVer. The pinned Tauri MSI template permits equal-core replacement; manual MSI installation does not enforce same-core preview/stable downgrade prevention. diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index b1e865529d4..891e1387f42 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -1,3 +1,4 @@ +import { windowsInstallerConfig, windowsInstallerVersion } from "../../desktop/scripts/windows-installer-config"; import { describe, expect, test } from "bun:test"; import { createHash, generateKeyPairSync, sign as ed25519Sign } from "node:crypto"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -27,6 +28,17 @@ function temporaryDirectory(): string { } describe("desktop release scripts", () => { + test("MSI uses numeric core while public SemVer metadata remains external", () => { + for (const version of ["2.61.0", "2.61.0-preview.20260922", "2.61.0-preview.20260922.1+build.7"]) { + expect(windowsInstallerVersion(version)).toBe("2.61.0"); + expect(windowsInstallerConfig(version)).toEqual({ bundle: { windows: { wix: { version: "2.61.0" } } } }); + } + expect(windowsInstallerVersion("255.255.65535")).toBe("255.255.65535"); + for (const version of ["256.1.0", "1.256.0", "1.1.65536", "2.01.0", "2.1.0-01", "v2.1.0", "2.1", "2.1.0;evil", "999999999999999999.0.0"]) { + expect(() => windowsInstallerVersion(version)).toThrow(); + } + }); + test("renames macOS DMG and updater archive and copies signatures", () => { const root = temporaryDirectory(); try { @@ -405,7 +417,7 @@ describe("widget extension signing", () => { ) as { jobs?: Record; - steps?: Array<{ name?: string; if?: string; run?: string; env?: Record }>; + steps?: Array<{ name?: string; if?: string; run?: string; env?: Record; with?: Record }>; }>; }; const steps = workflow.jobs?.["package-desktop"]?.steps ?? []; @@ -417,6 +429,21 @@ describe("widget extension signing", () => { const indexOfStepRunning = (fragment: string) => steps.findIndex(step => typeof step.run === "string" && step.run.includes(fragment)); + test("release prepares both Mac architectures and wires only the MSI metadata override", () => { + const rust = steps.find(step => step.name === "Setup Rust"); + expect(rust?.with?.targets).toContain("aarch64-apple-darwin,x86_64-apple-darwin"); + expect(rust?.with?.targets).toContain("runner.os == 'macOS'"); + const prepare = steps.find(step => step.name === "Prepare Windows installer version"); + expect(prepare?.if).toBe("runner.os == 'Windows'"); + expect(prepare?.env?.RELEASE_VERSION).toBe("${{ inputs.version }}"); + expect(prepare?.run).toContain('windows-installer-config.ts "$RELEASE_VERSION" "$RUNNER_TEMP/opencodex-msi.json"'); + const build = steps.find(step => step.name === "Build desktop bundles"); + expect(build?.run).toContain("--config"); + expect(build?.run).toContain("format('{0}/opencodex-msi.json', runner.temp)"); + expect(build?.run).toContain("runner.os == 'Windows'"); + expect(indexOfStep("Prepare Windows installer version")).toBeLessThan(indexOfStep("Build desktop bundles")); + }); + test("the release build hands the widget a signing identity and forbids an ad-hoc fallback", () => { const build = steps.find(step => step.name === "Build WidgetKit extension"); expect(build).toBeDefined(); From 5aa92e9548144fcc18ca3d3c986c41fd72c988a0 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 20:13:53 +0900 Subject: [PATCH 11/27] fix(desktop): carry native sidecar packaging repair to main (#5532) --- .github/workflows/release.yml | 14 ++++++++++ desktop/scripts/appimage-patchelf.py | 26 +++++++++++++++++++ desktop/scripts/verify-linux-sidecar.sh | 22 ++++++++++++++++ structure/desktop-shell.md | 9 +++++++ .../release-desktop-scripts.test.ts | 16 ++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 desktop/scripts/appimage-patchelf.py create mode 100644 desktop/scripts/verify-linux-sidecar.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bb202733b0..72a02821694 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,6 +245,10 @@ jobs: run: | bun desktop/scripts/prepare-sidecar.ts --target aarch64-apple-darwin bun desktop/scripts/prepare-sidecar.ts --target x86_64-apple-darwin + lipo -create desktop/src-tauri/binaries/ocx-aarch64-apple-darwin \ + desktop/src-tauri/binaries/ocx-x86_64-apple-darwin \ + -output desktop/src-tauri/binaries/ocx-universal-apple-darwin + lipo -verify_arch arm64 x86_64 desktop/src-tauri/binaries/ocx-universal-apple-darwin - name: Prepare sidecar if: runner.os != 'macOS' @@ -344,6 +348,12 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} run: bun desktop/scripts/windows-installer-config.ts "$RELEASE_VERSION" "$RUNNER_TEMP/opencodex-msi.json" + - name: Preserve the compiled Linux sidecar + if: runner.os == 'Linux' + run: | + chmod +x desktop/scripts/appimage-patchelf.py + echo "PATCHELF=$GITHUB_WORKSPACE/desktop/scripts/appimage-patchelf.py" >> "$GITHUB_ENV" + # Release signing is intentionally secret-gated. Developer ID, notarization, # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. @@ -363,6 +373,10 @@ jobs: # diagnostics on the first attempt; Apple signing commands stay non-verbose. run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config "${{ runner.os == 'Windows' && format('{0}/opencodex-msi.json', runner.temp) || '{}' }}" + - name: Verify the packaged Linux sidecar + if: runner.os == 'Linux' + run: bash desktop/scripts/verify-linux-sidecar.sh + - name: Rename release assets shell: bash env: diff --git a/desktop/scripts/appimage-patchelf.py b/desktop/scripts/appimage-patchelf.py new file mode 100644 index 00000000000..4c7e73cd930 --- /dev/null +++ b/desktop/scripts/appimage-patchelf.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Keep the compiled Bun sidecar intact while linuxdeploy patches the host/libs.""" +import os +from pathlib import Path +import sys + + +def main(args): + root = Path(__file__).resolve().parents[2] + triple = "x86_64-unknown-linux-gnu" + original = root / "desktop/src-tauri/binaries" / f"ocx-{triple}" + sidecar = root / "desktop/src-tauri/target" / triple / "release/bundle/appimage/OpenCodex.AppDir/usr/bin/ocx" + if len(args) == 3 and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] and Path(args[2]).resolve() == sidecar.resolve(): + # linuxdeploy's nested GTK pass runs ldd again after patching. Its + # patchelf rewrite breaks the compiled Bun ELF. This sidecar depends + # only on host glibc libraries; it needs no AppDir library search path. + # Never bless an already-modified binary or a different executable. + if sidecar.is_symlink() or original.read_bytes() != sidecar.read_bytes(): + raise RuntimeError("AppImage sidecar differs from the prepared CLI") + print("Preserving compiled ocx bytes (no AppDir RPATH required)", file=sys.stderr) + return + os.execv("/usr/bin/patchelf", ["/usr/bin/patchelf", *args]) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/desktop/scripts/verify-linux-sidecar.sh b/desktop/scripts/verify-linux-sidecar.sh new file mode 100644 index 00000000000..88c5df2ba34 --- /dev/null +++ b/desktop/scripts/verify-linux-sidecar.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Run only on a Linux packaging runner, against the completed AppImage. +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +bundle="$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage" +original="$root/desktop/src-tauri/binaries/ocx-x86_64-unknown-linux-gnu" +shopt -s nullglob +images=("$bundle"/*.AppImage) +if [ "${#images[@]}" -ne 1 ]; then + echo "Expected exactly one completed AppImage" >&2 + exit 1 +fi +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +cd "$scratch" +"${images[0]}" --appimage-extract > /dev/null +sidecar="$scratch/squashfs-root/usr/bin/ocx" +test ! -L "$sidecar" +cmp "$original" "$sidecar" +sha256sum "$original" "$sidecar" +mkdir "$scratch/home" +timeout 30s env OPENCODEX_HOME="$scratch/home" "$sidecar" --version diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 0bf23cc96fe..e967bf04d55 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -195,6 +195,15 @@ marker, which the GUI detects to identify the shell without using IPC. ## Release packaging and updater +Linux AppImage packaging uses `desktop/scripts/appimage-patchelf.py` to preserve +the compiled Bun CLI when linuxdeploy sets the executable RPATH. Only the exact +AppDir sidecar, still byte-identical to the prepared CLI, is exempt; other ELF +operations use the system patchelf. `desktop/scripts/verify-linux-sidecar.sh` +extracts the completed AppImage, compares its CLI bytes and runs its version command +on the hosted runner before any release asset is collected. +The macOS release combines both prepared CLI architectures with `lipo` into the +universal external binary Tauri expects, and checks that both slices are present. + The release workflow packages the desktop shell as `OpenCodex--macos.dmg`, `OpenCodex--windows-x64.msi`, `OpenCodex--linux-x86_64.AppImage`, and `OpenCodex--linux-amd64.deb`. Each artifact is collected with a `.sha256` file; diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 891e1387f42..ac4d971b2bb 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -433,6 +433,10 @@ describe("widget extension signing", () => { const rust = steps.find(step => step.name === "Setup Rust"); expect(rust?.with?.targets).toContain("aarch64-apple-darwin,x86_64-apple-darwin"); expect(rust?.with?.targets).toContain("runner.os == 'macOS'"); + const sidecars = steps.find(step => step.name === "Prepare macOS sidecars"); + expect(sidecars?.run).toContain("lipo -create desktop/src-tauri/binaries/ocx-aarch64-apple-darwin"); + expect(sidecars?.run).toContain("-output desktop/src-tauri/binaries/ocx-universal-apple-darwin"); + expect(sidecars?.run).toContain("lipo -verify_arch arm64 x86_64"); const prepare = steps.find(step => step.name === "Prepare Windows installer version"); expect(prepare?.if).toBe("runner.os == 'Windows'"); expect(prepare?.env?.RELEASE_VERSION).toBe("${{ inputs.version }}"); @@ -444,6 +448,18 @@ describe("widget extension signing", () => { expect(indexOfStep("Prepare Windows installer version")).toBeLessThan(indexOfStep("Build desktop bundles")); }); + test("Linux verifies the packaged CLI before collecting release assets", () => { + const preserve = steps.find(step => step.name === "Preserve the compiled Linux sidecar"); + const verify = steps.find(step => step.name === "Verify the packaged Linux sidecar"); + expect(preserve?.if).toBe("runner.os == 'Linux'"); + expect(preserve?.run).toContain("PATCHELF=$GITHUB_WORKSPACE/desktop/scripts/appimage-patchelf.py"); + expect(verify?.if).toBe("runner.os == 'Linux'"); + expect(verify?.run).toBe("bash desktop/scripts/verify-linux-sidecar.sh"); + expect(indexOfStep(preserve!.name!)).toBeLessThan(indexOfStep("Build desktop bundles")); + expect(indexOfStep(verify!.name!)).toBeGreaterThan(indexOfStep("Build desktop bundles")); + expect(indexOfStep(verify!.name!)).toBeLessThan(indexOfStep("Rename release assets")); + }); + test("the release build hands the widget a signing identity and forbids an ad-hoc fallback", () => { const build = steps.find(step => step.name === "Build WidgetKit extension"); expect(build).toBeDefined(); From 9314146ae88aefe5b6ce780ef6f4d7a7c460f330 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 20:22:21 +0900 Subject: [PATCH 12/27] chore(ci): refresh main release verification (#5534) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f86a91e6e..753a72c5fcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ on: permissions: contents: read -# Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46). +# Release promotions require fresh push-based CI evidence for the published commit. concurrency: # `push` and `pull_request` want supersession: a newer head on the same ref # makes the older one irrelevant, and cancelling it saves an hour of runners From b007d339b95ad8308632548564e15c8844238f72 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 20:28:20 +0900 Subject: [PATCH 13/27] fix(release): carry lipo argument fix to main (#5537) --- .github/workflows/release.yml | 2 +- tests/ci-workflows/release-desktop-scripts.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72a02821694..5adedd5cac8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -248,7 +248,7 @@ jobs: lipo -create desktop/src-tauri/binaries/ocx-aarch64-apple-darwin \ desktop/src-tauri/binaries/ocx-x86_64-apple-darwin \ -output desktop/src-tauri/binaries/ocx-universal-apple-darwin - lipo -verify_arch arm64 x86_64 desktop/src-tauri/binaries/ocx-universal-apple-darwin + lipo desktop/src-tauri/binaries/ocx-universal-apple-darwin -verify_arch arm64 x86_64 - name: Prepare sidecar if: runner.os != 'macOS' diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index ac4d971b2bb..416895f6845 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -436,7 +436,7 @@ describe("widget extension signing", () => { const sidecars = steps.find(step => step.name === "Prepare macOS sidecars"); expect(sidecars?.run).toContain("lipo -create desktop/src-tauri/binaries/ocx-aarch64-apple-darwin"); expect(sidecars?.run).toContain("-output desktop/src-tauri/binaries/ocx-universal-apple-darwin"); - expect(sidecars?.run).toContain("lipo -verify_arch arm64 x86_64"); + expect(sidecars?.run).toContain("lipo desktop/src-tauri/binaries/ocx-universal-apple-darwin -verify_arch arm64 x86_64"); const prepare = steps.find(step => step.name === "Prepare Windows installer version"); expect(prepare?.if).toBe("runner.os == 'Windows'"); expect(prepare?.env?.RELEASE_VERSION).toBe("${{ inputs.version }}"); From b1ae178ec5b3cb7bdd02cca04da08a6210415db4 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 21:21:46 +0900 Subject: [PATCH 14/27] fix(release): carry checksum and signature repair to main (#5546) --- .github/workflows/release.yml | 4 +- desktop/scripts/verify-release-assets.ts | 66 +++++++++++------ structure/desktop-shell.md | 3 + .../release-desktop-scripts.test.ts | 71 +++++++++++++++++-- .../release-pipeline-contract.test.ts | 3 + tests/fixtures/minisign/LICENSE | 60 ++++++++++++++++ tests/fixtures/minisign/prehashed-vector.json | 6 ++ 7 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/minisign/LICENSE create mode 100644 tests/fixtures/minisign/prehashed-vector.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5adedd5cac8..e62765c5358 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,8 +165,8 @@ jobs: # dist/release, where the artifact download lands these files flat; the # checksum therefore records the bare file name, which sha256sum takes # verbatim from its argument. - if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" - else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip.sha256" + else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz.sha256" fi - name: Upload standalone release diff --git a/desktop/scripts/verify-release-assets.ts b/desktop/scripts/verify-release-assets.ts index 7292be7a684..06461d92c80 100644 --- a/desktop/scripts/verify-release-assets.ts +++ b/desktop/scripts/verify-release-assets.ts @@ -129,22 +129,36 @@ export interface MinisignPublicKey { publicKey: KeyObject; } -function minisignPayload(text: string, expectedBytes: number, what: string): Buffer { - const encoded = text - .split("\n") - .filter(line => line.trim().length > 0 && !line.trimStart().startsWith("untrusted comment:")) - .join("") - .trim(); - const payload = Buffer.from(encoded, "base64"); - if (payload.length !== expectedBytes) { - throw new Error(`Malformed ${what}: expected ${expectedBytes} decoded bytes, got ${payload.length}`); +function decodeBase64(text: string, what: string, expectedBytes?: number): Buffer { + const payload = Buffer.from(text, "base64"); + // Buffer.from is intentionally permissive; release metadata must be canonical. + if (!text || payload.toString("base64") !== text + || (expectedBytes !== undefined && payload.length !== expectedBytes)) { + throw new Error(`Malformed ${what}: invalid base64 or decoded length`); } return payload; } +function decodeBox(text: string, what: string): string { + // Transport whitespace is harmless (the updater manifest also trims it). + // The encoded payload itself must still be canonical and valid UTF-8. + const payload = decodeBase64(text.trim(), what); + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(payload); +} + +function boxLines(text: string): string[] { + // Accept minisign text with LF or CRLF and an optional terminal newline; + // signatures authenticate decoded bytes/comments, not transport line endings. + return text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); +} + /** minisign public key: base64 of algorithm ("Ed") || key id (8) || raw key (32). */ export function parseMinisignPublicKey(text: string): MinisignPublicKey { - const payload = minisignPayload(text, 42, "minisign public key"); + const lines = boxLines(text); + if (lines.length !== 2 || !lines[0]!.startsWith("untrusted comment: ")) { + throw new Error("Malformed minisign public key box"); + } + const payload = decodeBase64(lines[1]!, "minisign public key", 42); const algorithm = payload.subarray(0, 2).toString("utf8"); if (algorithm !== "Ed") { throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`); @@ -166,30 +180,42 @@ export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey { }; const pubkey = conf.plugins?.updater?.pubkey; if (!pubkey) throw new Error(`No plugins.updater.pubkey in ${tauriConfPath}`); - return parseMinisignPublicKey(Buffer.from(pubkey, "base64").toString("utf8")); + return parseMinisignPublicKey(decodeBox(pubkey, "Tauri public key")); } -/** - * minisign signature: base64 of algorithm || key id (8) || signature (64). - * "Ed" is a pure Ed25519 signature over the raw file bytes — the form the Tauri - * bundler emits. "ED" (BLAKE2b-prehashed) or anything else fails loudly rather - * than being silently mis-verified. - */ +/** Tauri CLI 2.11.1 wraps a minisign 0.7.3 prehashed signature box in base64. */ export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void { const signaturePath = `${filePath}.sig`; if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`); - const payload = minisignPayload(readFileSync(signaturePath, "utf8"), 74, `signature ${signaturePath}`); + const lines = boxLines(decodeBox(readFileSync(signaturePath, "utf8"), "Tauri signature")); + const trustedPrefix = "trusted comment: "; + if (lines.length !== 4 || !lines[0]!.startsWith("untrusted comment: ") + || !lines[2]!.startsWith(trustedPrefix)) { + throw new Error(`Malformed signature box in ${signaturePath}`); + } + const payload = decodeBase64(lines[1]!, "signature packet", 74); + const globalSignature = decodeBase64(lines[3]!, "comment signature", 64); const algorithm = payload.subarray(0, 2).toString("utf8"); - if (algorithm !== "Ed") { + if (algorithm !== "ED") { throw new Error(`Unsupported signature algorithm in ${signaturePath}: ${JSON.stringify(algorithm)}`); } const keyId = payload.subarray(2, 10).toString("hex"); if (keyId !== key.keyId) { throw new Error(`Signature ${signaturePath} was made by key ${keyId}, not the pinned updater key ${key.keyId}`); } - if (!ed25519Verify(null, readFileSync(filePath), key.publicKey, payload.subarray(10, 74))) { + const signature = payload.subarray(10, 74); + // ED is ordinary Ed25519 over the BLAKE2b-512 digest, not Ed25519ph. + const digest = createHash("blake2b512").update(readFileSync(filePath)).digest(); + if (!ed25519Verify(null, digest, key.publicKey, signature)) { throw new Error(`Signature verification failed for ${filePath}`); } + // minisign signs the raw signature + trimmed trusted comment, without its + // prefix or line terminator. The original filename may differ after collection. + const trustedComment = lines[2]!.slice(trustedPrefix.length).trim(); + const message = Buffer.concat([signature, Buffer.from(trustedComment, "utf8")]); + if (!ed25519Verify(null, message, key.publicKey, globalSignature)) { + throw new Error(`Comment signature verification failed for ${filePath}`); + } } function parseBackManifest(manifestPath: string, options: VerifyReleaseAssetsOptions): string[] { diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index e967bf04d55..66aa77423f3 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -214,6 +214,9 @@ packaging matrices, verifies every checksum and every updater signature, and wri platforms to have updater signatures. Publication waits for that verification, and the attachment job uploads the verified bundle only after the verification receipt names the same version and commit. +Updater signature verification decodes Tauri’s outer-base64 minisign box, checks the +`ED` signature over the BLAKE2b-512 digest against the pinned key, and verifies the +trusted-comment signature. Missing or malformed fields fail before publication. On macOS, in-app updates download `OpenCodex--macos.app.tar.gz`; the DMG is for the first installation. diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 416895f6845..dad055ce31c 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -530,17 +530,44 @@ describe("release asset verification", () => { function makeMinisignKeypair(keyIdHex: string): { pubkeyText: string; keyId: Buffer; - signPayload: (payload: Buffer) => string; + signPayload: (payload: Buffer, rawBytes?: boolean) => string; } { const { publicKey, privateKey } = generateKeyPairSync("ed25519"); const raw = Buffer.from(publicKey.export({ format: "der", type: "spki" })).subarray(-32); const keyId = Buffer.from(keyIdHex, "hex"); const pubkeyText = `untrusted comment: test public key\n${Buffer.concat([Buffer.from("Ed"), keyId, raw]).toString("base64")}\n`; - const signPayload = (payload: Buffer): string => - `untrusted comment: test signature\n${Buffer.concat([Buffer.from("Ed"), keyId, ed25519Sign(null, payload, privateKey)]).toString("base64")}\n`; + const signPayload = (payload: Buffer, rawBytes = false): string => { + const signed = rawBytes ? payload : createHash("blake2b512").update(payload).digest(); + const signature = ed25519Sign(null, signed, privateKey); + const trusted = "timestamp:1\tfile:original-before-collection.msi"; + const packet = Buffer.concat([Buffer.from("ED"), keyId, signature]).toString("base64"); + const global = ed25519Sign(null, Buffer.concat([signature, Buffer.from(trusted)]), privateKey).toString("base64"); + return Buffer.from(`untrusted comment: test signature\n${packet}\ntrusted comment: ${trusted}\n${global}\n`).toString("base64"); + }; return { pubkeyText, keyId, signPayload }; } + test("accepts the upstream minisign 0.7.3 prehashed vector in Tauri encoding", () => { + const fixture = JSON.parse(readFileSync(repoPath("tests/fixtures/minisign/prehashed-vector.json"), "utf8")) as { + payload: string; publicKeyBase64: string; signatureBox: string; + }; + const dir = temporaryDirectory(); + try { + const asset = join(dir, "renamed-release.bin"); + const key = parseMinisignPublicKey(`untrusted comment: upstream key\n${fixture.publicKeyBase64}\n`); + writeFileSync(asset, fixture.payload); + writeFileSync(`${asset}.sig`, Buffer.from(fixture.signatureBox).toString("base64")); + expect(() => verifyUpdaterSignature(asset, key)).not.toThrow(); + writeFileSync(asset, "tampered"); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/); + writeFileSync(asset, fixture.payload); + writeFileSync(`${asset}.sig`, Buffer.from(fixture.signatureBox.replace("file:test", "file:changed")).toString("base64")); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("derives the expected set from the real release matrices and producer tables", () => { const workflow = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); const { standaloneTargets: workflowStandalone, desktopTargets } = releaseMatrixTargets(workflow); @@ -624,9 +651,43 @@ describe("release asset verification", () => { writeFileSync(`${asset}.sig`, other.signPayload(payload)); expect(() => verifyUpdaterSignature(asset, key)).toThrow(/not the pinned updater key/); - const hashed = `untrusted comment: test\n${Buffer.concat([Buffer.from("ED"), other.keyId, Buffer.alloc(64)]).toString("base64")}\n`; - writeFileSync(`${asset}.sig`, hashed); + const valid = signPayload(payload); + writeFileSync(`${asset}.sig`, signPayload(payload, true)); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/); + const lines = Buffer.from(valid, "base64").toString("utf8").trimEnd().split("\n"); + const encode = (box: string[]) => Buffer.from(`${box.join("\n")}\n`).toString("base64"); + const packet = Buffer.from(lines[1]!, "base64"); + packet[10] = packet[10]! ^ 1; + writeFileSync(`${asset}.sig`, encode([lines[0]!, packet.toString("base64"), lines[2]!, lines[3]!])); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/); + packet[10] = packet[10]! ^ 1; + packet.write("Ed", 0); + writeFileSync(`${asset}.sig`, encode([lines[0]!, packet.toString("base64"), lines[2]!, lines[3]!])); expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Unsupported signature algorithm/); + + writeFileSync(`${asset}.sig`, encode([lines[0]!, lines[1]!, "trusted comment: changed", lines[3]!])); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/); + writeFileSync(`${asset}.sig`, encode([lines[0]!, lines[1]!, lines[2]!, Buffer.alloc(64).toString("base64")])); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/); + const sameIdOtherKey = parseMinisignPublicKey(makeMinisignKeypair("0123456789abcdef").pubkeyText); + writeFileSync(`${asset}.sig`, valid); + expect(() => verifyUpdaterSignature(asset, sameIdOtherKey)).toThrow(/Signature verification failed/); + + for (const malformed of [valid + "!", encode(lines.slice(0, 3)), encode([...lines, "extra"]), + encode([lines[0]!, Buffer.alloc(73).toString("base64"), lines[2]!, lines[3]!]), + encode([lines[0]!, lines[1]!, lines[2]!, Buffer.alloc(65).toString("base64")]), + Buffer.from([0xff]).toString("base64"), + Buffer.from(`\uFEFF${lines.join("\n")}`).toString("base64"), + lines.slice(0, 2).join("\n")]) { + writeFileSync(`${asset}.sig`, malformed); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(); + } + writeFileSync(`${asset}.sig`, encode(["untrusted comment: changed", ...lines.slice(1)])); + expect(() => verifyUpdaterSignature(asset, key)).not.toThrow(); + writeFileSync(`${asset}.sig`, Buffer.from(`${lines.join("\r\n")}\r\n`).toString("base64")); + expect(() => verifyUpdaterSignature(asset, key)).not.toThrow(); + writeFileSync(`${asset}.sig`, ` \n${valid}\n`); + expect(() => verifyUpdaterSignature(asset, key)).not.toThrow(); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index 208b1d962c0..8f474ae4b25 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -93,6 +93,9 @@ describe("release pipeline contract", () => { const output = />\s+"([^"]+)"/.exec(line)?.[1]; expect(output, line).toBeDefined(); expect(output!).not.toContain("/"); + // verifyChecksums binds each record to its own payload by removing only + // the final .sha256 suffix, including the archive extension in the name. + expect(output, line).toBe(`${argument}.sha256`); } // The bare names above only resolve end to end if the step checksums from the directory diff --git a/tests/fixtures/minisign/LICENSE b/tests/fixtures/minisign/LICENSE new file mode 100644 index 00000000000..19ed8ad543a --- /dev/null +++ b/tests/fixtures/minisign/LICENSE @@ -0,0 +1,60 @@ +Copyright (c) 2019-2023 Frank Denis +Copyright (c) 2017 Daniel Rangel + +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. + +-- + +Code in the src/crypto folder is derived from the rust-crypto project: +https://github.com/DaGenix/rust-crypto + +Original ISC license follows: + +Copyright (c) 2006-2009 Graydon Hoare +Copyright (c) 2009-2013 Mozilla Foundation + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/tests/fixtures/minisign/prehashed-vector.json b/tests/fixtures/minisign/prehashed-vector.json new file mode 100644 index 00000000000..44cd225d9b0 --- /dev/null +++ b/tests/fixtures/minisign/prehashed-vector.json @@ -0,0 +1,6 @@ +{ + "source": "https://github.com/jedisct1/rust-minisign/blob/068d25a2fffd3d9da96e43f3844c830d8176d5a0/src/tests.rs#L159-L185", + "payload": "test", + "publicKeyBase64": "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3", + "signatureBox": "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg==" +} From f1b218004e5e77c363decd0f59d84b64b4d3f588 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 22:05:50 +0900 Subject: [PATCH 15/27] fix(release): carry Windows checksum record support to main (#5552) --- desktop/scripts/verify-release-assets.ts | 4 +-- .../release-desktop-scripts.test.ts | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/verify-release-assets.ts b/desktop/scripts/verify-release-assets.ts index 06461d92c80..0c3175791bb 100644 --- a/desktop/scripts/verify-release-assets.ts +++ b/desktop/scripts/verify-release-assets.ts @@ -95,7 +95,7 @@ export function releaseMatrixTargets(workflowText: string): { /** * Every recorded checksum against the bytes on disk, in exactly the producers' - * format (64 hex, two spaces, bare name, one trailing newline). The recorded name + * format (64 hex, a space, text/binary marker, bare name, newline). The recorded name * must equal the checksum file's own name minus the suffix: a foo.sha256 naming * bar would leave foo's bytes unchecked while bar's are checked twice. */ @@ -104,7 +104,7 @@ export function verifyChecksums(dir: string): number { if (checksumFiles.length === 0) throw new Error(`No .sha256 files found in ${dir}`); for (const checksumFile of checksumFiles) { const content = readFileSync(join(dir, checksumFile), "utf8"); - const match = /^([0-9a-f]{64}) (\S+)\n$/.exec(content); + const match = /^([0-9a-f]{64}) [ *](\S+)\r?\n$/.exec(content); if (!match) throw new Error(`Malformed checksum record in ${checksumFile}: ${JSON.stringify(content)}`); const digest = match[1]!; const recorded = match[2]!; diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index dad055ce31c..037b2f65ad3 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -618,6 +618,35 @@ describe("release asset verification", () => { } }); + test("verifies Windows binary checksum records without weakening payload binding", () => { + const dir = temporaryDirectory(); + const name = "ocx-1.0.0-bun-windows-x64.zip"; + const asset = join(dir, name); + const checksum = `${asset}.sha256`; + // Standard sha256sum binary marker observed in release run 35728908862. + const digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + try { + writeFileSync(asset, ""); + for (const newline of ["\n", "\r\n"]) { + writeFileSync(checksum, `${digest} *${name}${newline}`); + expect(verifyChecksums(dir)).toBe(1); + } + writeFileSync(checksum, `${digest} *different.zip\n`); + expect(() => verifyChecksums(dir)).toThrow(/must record its own payload/); + for (const record of [`${digest} ?${name}\n`, `${digest}*${name}\n`, `${digest} *${name}\nextra\n`]) { + writeFileSync(checksum, record); + expect(() => verifyChecksums(dir)).toThrow(/Malformed checksum record/); + } + writeFileSync(checksum, `${digest} *${name}\n`); + writeFileSync(asset, "changed"); + expect(() => verifyChecksums(dir)).toThrow(/Checksum mismatch/); + rmSync(asset); + expect(() => verifyChecksums(dir)).toThrow(/which is missing/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("rejects a tampered payload and a missing payload", () => { const dir = temporaryDirectory(); try { From 2f8216792faad8dbaf3d66700eaaf78867515479 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 23:19:43 +0900 Subject: [PATCH 16/27] fix(release): publish the GitHub release only after its verified assets attach (#5555) (#5558) GitHub freezes a release when it is published, so the attach step's upload came back HTTP 422 "Cannot upload assets to an immutable release". Every release from v2.55.0 to v2.60.0 therefore shipped with zero assets and the desktop updater had nothing to download. Create the release as a draft and flip it to published in attach-release, after the verified bundle is uploaded. Release notes still come from the validated notes file written at creation. --- .github/workflows/release.yml | 19 +++++++++++++++++-- tests/ci-workflows/ci-workflows.test.ts | 9 ++++++++- .../release-pipeline-contract.test.ts | 16 ++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e62765c5358..4c06619bddd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -578,7 +578,19 @@ jobs: # run: source. tests/ci-workflows.test.ts enforces this repo-wide. RELEASE_VERSION: ${{ inputs.version }} run: | - gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber + set -euo pipefail + release_tag="v${RELEASE_VERSION}" + gh release upload "$release_tag" dist/release/* --clobber + + # A published release is immutable: GitHub rejects every later asset upload + # with HTTP 422, which is why v2.55.0 through v2.60.0 shipped with zero + # assets and left the desktop updater without anything to download. The + # release is therefore created as a draft and becomes public here, once the + # verified bundle is attached. The only edit permitted is this flip — the + # notes still come from the validated notes file written at creation. + if [ "$(gh release view "$release_tag" --json isDraft --jq .isDraft)" = "true" ]; then + gh release edit "$release_tag" --draft=false + fi publish: needs: [validate-dispatch, verify-release] @@ -981,6 +993,9 @@ jobs: exit 1 fi else - gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ + # Draft first. Publication freezes a release under GitHub's immutable + # releases, so attach-release attaches the verified bundle to the draft + # and publishes it afterwards. + gh release create "$release_tag" --draft --target "$GITHUB_SHA" --title "$release_tag" \ --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} fi diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 6d3d2841baa..a7212745fbf 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -1033,7 +1033,14 @@ describe("GitHub Actions hardening", () => { expect(releaseNotesBuilder).toContain("release changelog failed coverage validation"); expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/); - expect(workflow).not.toContain("gh release edit"); + // The release is created as a draft and published only after the verified + // bundle is attached, because a published release is immutable and rejects + // every later upload. That draft flip is the one edit allowed: notes still + // come from the validated notes file, never from an edit or a regeneration. + for (const edit of workflow.match(/gh release edit[^\n]*/g) ?? []) { + expect(edit).toContain("--draft=false"); + expect(edit).not.toContain("--notes"); + } expect(workflow).not.toContain("--generate-notes"); const createStep = workflow diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index 8f474ae4b25..d2a90449fab 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -153,6 +153,22 @@ describe("release pipeline contract", () => { expect(upload).toBeGreaterThan(receiptCheck); }); + test("the release is a draft until the verified assets are attached", () => { + // GitHub freezes a release the moment it is published: every later asset upload + // comes back HTTP 422 "Cannot upload assets to an immutable release". Creating + // the release published and uploading afterwards is what left v2.55.0 through + // v2.60.0 with zero assets and the desktop updater with nothing to fetch. + const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + const createStep = releaseText.split("- name: Create GitHub release")[1] ?? ""; + expect(createStep).toMatch(/gh release create "\$release_tag" --draft/); + + const attachRun = (release.jobs?.["attach-release"]?.steps ?? []) + .map(candidate => candidate.run ?? "") + .find(run => run.includes("gh release upload")) ?? ""; + expect(attachRun).toContain("--draft=false"); + expect(attachRun.indexOf("gh release upload")).toBeLessThan(attachRun.indexOf("--draft=false")); + }); + test("a partial publication has a recorded, explicit recovery path", () => { const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); // The only way npm publish is ever skipped: an explicit recovery input, requiring From 96b1406cb63e429cec8d2e3914af4ba99f2e37b9 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 06:30:13 +0900 Subject: [PATCH 17/27] release: prepare 2.63.0 version metadata (#5612) --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b2fbcb56130..33322e5d556 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2541,7 +2541,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.62.0" +version = "2.63.0" dependencies = [ "dbus", "reqwest 0.12.24", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 45b39cd890c..e46937659b0 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.62.0" +version = "2.63.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index ff6232c563c..36df79737fb 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.62.0", + "version": "2.63.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 9c7252d8004..245a9b51a12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.62.0", + "version": "2.63.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From a32227bb979f6faf38cf592a0a702e099095cd79 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 07:24:26 +0900 Subject: [PATCH 18/27] release: prepare 2.69.0 version metadata --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9bdc7f94b53..320a9a2b840 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2551,7 +2551,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.70.0" +version = "2.69.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f2af7b8c59c..ed8f57bf1bb 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.70.0" +version = "2.69.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index da106098a8a..a3790fcbc8c 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.70.0", + "version": "2.69.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index fbe21ba9e8a..82f92b4f3cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.70.0", + "version": "2.69.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 46c07c5e0f7157b19f79a06fbaf79d3e04c8308b Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 09:26:51 +0900 Subject: [PATCH 19/27] fix(release): sign the packaged macOS keyring addons before notarization (#6271) * fix(release): sign the packaged macOS keyring addons before notarization Notarization rejected the 2.73.0 preview app: Resources/keyring/*.node, bundled since #6161, were unsigned or ad-hoc and had no secure timestamp, and Tauri does not sign files under Resources. Sign each darwin addon in place with the Developer ID identity, hardened runtime and timestamp after the certificate import and before tauri build, verify the result, and fail a real release that lacks signing material. * fix(release): match keyring signature fields without a pipe (cherry picked from commit 11782eedc3758d90c037244ab1cf36f17a286ccc) --- .github/workflows/release.yml | 39 +++++++++++++++++++ .../release-desktop-scripts.test.ts | 17 ++++++++ 2 files changed, 56 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08a5e66cd05..764eb0622a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -413,6 +413,45 @@ jobs: echo "$description" | grep -q "flags=.*runtime" echo "$description" | grep -q "Timestamp=" + # Tauri signs the app, its sidecar and the widget it is handed, but nothing under Resources. + # The packaged keyring addons (#6161) are Mach-O code, so Apple notarization rejects the whole + # app unless each carries the Developer ID signature, the hardened runtime and a secure + # timestamp (2.73.0-preview.20260930 was refused for exactly that). Sign them in place, after + # the certificate import and before the bundler copies them. + - name: Sign the packaged keyring addons + if: runner.os == 'macOS' + env: + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + shopt -s nullglob + addons=(desktop/src-tauri/resources/keyring/*.darwin-*.node) + if [ "${#addons[@]}" -eq 0 ]; then + echo "::error::No macOS keyring addon was prepared for the bundle." + exit 1 + fi + if [ "${DESKTOP_SIGNING_CONFIGURED}" != "true" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release must sign the packaged keyring addons." + exit 1 + fi + echo "No signing material; the keyring addons stay as prepared for this non-release build." + exit 0 + fi + for addon in "${addons[@]}"; do + codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY" "$addon" + codesign --verify --strict "$addon" + description="$(codesign -dvvv "$addon" 2>&1)" + echo "$description" + # Here-strings, not pipes: under pipefail a matcher that exits on its first hit can + # SIGPIPE the writer and fail the assertion it was meant to pass. + grep -q "TeamIdentifier=$APPLE_TEAM_ID" <<<"$description" + grep -q "flags=.*runtime" <<<"$description" + grep -q "Timestamp=" <<<"$description" + done + - name: Prepare Windows installer version if: runner.os == 'Windows' shell: bash diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index db141b5e1c2..570e3067504 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -537,6 +537,23 @@ describe("widget extension signing", () => { .toContain("APPLE_CERTIFICATE"); }); + test("the packaged keyring addons are Developer ID signed before the bundler copies them", () => { + // Notarization refused 2.73.0-preview.20260930: Resources/keyring/*.node were ad-hoc or + // unsigned and had no secure timestamp, and Tauri does not sign files under Resources. + const sign = steps.find(step => step.name === "Sign the packaged keyring addons"); + expect(sign?.if).toBe("runner.os == 'macOS'"); + expect(sign?.env?.MACOS_SIGN_IDENTITY).toContain("APPLE_SIGNING_IDENTITY"); + expect(sign?.run).toContain("desktop/src-tauri/resources/keyring/*.darwin-*.node"); + expect(sign?.run).toContain('codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY"'); + expect(sign?.run).toContain('grep -q "TeamIdentifier=$APPLE_TEAM_ID" <<<"$description"'); + expect(sign?.run).toContain('grep -q "Timestamp=" <<<"$description"'); + // A real release never falls back to unsigned addons; only a dry run may. + expect(sign?.run).toContain("A real release must sign the packaged keyring addons."); + expect(indexOfStepRunning("security create-keychain")).toBeLessThan(indexOfStep(sign!.name!)); + expect(indexOfStep("Prepare macOS sidecars")).toBeLessThan(indexOfStep(sign!.name!)); + expect(indexOfStep(sign!.name!)).toBeLessThan(indexOfStep("Build desktop bundles")); + }); + test("the certificate is importable before the widget is signed and is removed afterwards", () => { // codesign resolves an identity through the keychain search list, and Tauri does not build // its own keychain until the bundling step, which is after this one. From de5c672c5d283a681a95e84882a15c3461644f17 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 20:48:56 +0900 Subject: [PATCH 20/27] chore(release): 2.74.0 --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 23e7615e415..74ed32ff04c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.75.0" +version = "2.74.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6aa1932c8f5..81e8e2b51da 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.75.0" +version = "2.74.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 256d52efe12..87ae5a99695 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.75.0", + "version": "2.74.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 138fd7eb09f..1808a150251 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.75.0", + "version": "2.74.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From c1dc8e4e1895276ed4f70240eebe2539fcfddd7c Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 23:21:53 +0900 Subject: [PATCH 21/27] fix(deps): bump proxy-addr, fast-uri, ip-address and source-map-js for new advisories release.yml's dependency audit refused 2.79.0 on a critical advisory published after the candidate was cut: proxy-addr <2.0.8 is vulnerable to IP spoofing via IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h), reached through @modelcontextprotocol/sdk > express. gui/ reports source-map-js <1.2.2 (GHSA-68fv-2mgg-jv7q, vite > postcss). bun audit fix, lockfiles only, every bump inside its declared range: proxy-addr 2.0.7 -> 2.0.8, fast-uri 3.1.7 -> 3.1.8, ip-address 10.4.0 -> 10.7.1, and in gui/ source-map-js 1.2.1 -> 1.2.2. Both audits now report no vulnerabilities at --audit-level=high. --- bun.lock | 6 +++--- gui/bun.lock | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 4c123e4c3c3..10de7ca5cfc 100644 --- a/bun.lock +++ b/bun.lock @@ -191,7 +191,7 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -219,7 +219,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], + "ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -263,7 +263,7 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], diff --git a/gui/bun.lock b/gui/bun.lock index dc41b1bdb9c..2cbae98b06d 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -177,7 +177,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], From 23cc3385ab7fe411b7e1068891bf9df4912b9988 Mon Sep 17 00:00:00 2001 From: hyuntae park Date: Wed, 7 Oct 2026 02:18:05 +0900 Subject: [PATCH 22/27] feat(claude): support Windows Desktop first-party picker --- .../src/content/docs/fr/guides/claude-code.md | 16 +- .../src/content/docs/guides/claude-code.md | 16 +- .../src/content/docs/ja/guides/claude-code.md | 14 +- .../src/content/docs/ko/guides/claude-code.md | 14 +- .../src/content/docs/ru/guides/claude-code.md | 12 +- .../src/content/docs/tr/guides/claude-code.md | 12 +- .../content/docs/zh-cn/guides/claude-code.md | 14 +- .../content/docs/zh-tw/guides/claude-code.md | 14 +- gui/src/i18n/de.ts | 4 +- gui/src/i18n/en.ts | 4 +- gui/src/i18n/fr.ts | 4 +- gui/src/i18n/ja.ts | 4 +- gui/src/i18n/ko.ts | 4 +- gui/src/i18n/pt.ts | 4 +- gui/src/i18n/ru.ts | 4 +- gui/src/i18n/tr.ts | 4 +- gui/src/i18n/vi.ts | 4 +- gui/src/i18n/zh-TW.ts | 4 +- gui/src/i18n/zh.ts | 4 +- gui/tests/claude-desktop-picker.test.tsx | 2 +- scripts/test-layout/layout.json | 2 +- .../ocx/references/01_surface_integrations.md | 4 +- src/claude/desktop-picker-platform.ts | 4 + src/claude/desktop-picker.ts | 15 +- src/claude/intercept/picker-ca-startup.ts | 3 +- src/claude/intercept/picker-runtime.ts | 11 +- src/claude/intercept/picker-trust-windows.ts | 320 ++++++++++++++ src/claude/intercept/picker-trust.ts | 16 + src/claude/intercept/runtime.ts | 7 +- src/cli/capabilities-base.ts | 4 +- .../claude-desktop-picker-routes.ts | 2 +- .../management/native-integration-routes.ts | 2 +- structure/clients/claude-desktop.md | 32 +- structure/runtime.md | 2 +- .../claude-desktop-picker-routes.test.ts | 2 +- .../claude-picker-runtime.test.ts | 4 +- .../claude-picker-startup.test.ts | 4 +- .../claude-picker-trust-windows.test.ts | 239 ++++++++++ .../claude-picker-windows.test.ts | 412 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- .../claude-intercept-integration.test.ts | 18 +- 41 files changed, 1133 insertions(+), 131 deletions(-) create mode 100644 src/claude/desktop-picker-platform.ts create mode 100644 src/claude/intercept/picker-trust-windows.ts create mode 100644 tests/claude-integration/claude-picker-trust-windows.test.ts create mode 100644 tests/claude-integration/claude-picker-windows.test.ts diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 0de2c391c4f..4b824bf6e72 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -168,24 +168,24 @@ unknown signifie qu’opencodex ne peut pas déterminer si les réglages pointen ### Mode picker : modèles opencodex dans le sélecteur Code first-party -Le mode picker fait partie du mode first-party. Sur macOS, il est activé par défaut lorsque first-party +Le mode picker fait partie du mode first-party. Sur macOS et Windows, il est activé par défaut lorsque first-party est sélectionné, sauf si `claudeCode.intercept.picker: false` est défini. Il modifie le sélecteur de modèles de l'onglet Code de Desktop first-party pour y afficher les modèles opencodex disponibles par leur nom. Lors de la première activation, macOS peut demander l'autorisation d'une autorité de certification -locale dans le trousseau de connexion. Cette autorité est limitée à `claude.ai` et à ses sous-domaines. +locale dans le trousseau de connexion, et Windows dans le magasin racine de l'utilisateur actuel. Cette autorité est limitée à `claude.ai` et à ses sous-domaines. Son identité de signature exportable est protégée par le magasin d'identifiants du système et réutilisée lors des redémarrages ordinaires. Aucune clé de signature picker en clair n'est enregistrée dans le répertoire de configuration OpenCodex. La validation complète de l'autorité contrainte et la vérification -de la confiance du système restent obligatoires. Avec la même identité approuvée et un magasin accessible, -un redémarrage n'ajoute ni ne supprime de réglages de confiance des certificats. La restauration au démarrage -n'installe jamais la confiance : si elle manque, est révoquée ou inconnue, le picker reste en attente. -Exécutez explicitement `ocx claude desktop picker on` ou `ocx claude desktop picker trust` pour l'accorder. +de la confiance du système restent obligatoires, avec sur Windows la correspondance exacte de l'empreinte et une feuille vérifiée. Avec la même identité approuvée et un magasin accessible, +un redémarrage n'ajoute ni ne supprime de réglages de confiance des certificats macOS. La restauration au démarrage +ou la vérification d'état n'installe jamais la confiance : si elle manque, est révoquée ou inconnue, le picker reste en attente. +Exécutez explicitement `ocx claude desktop picker on` ou `ocx claude desktop picker trust` pour l'accorder, y compris la confirmation Windows si nécessaire. Une migration unique depuis une ancienne identité peut demander votre consentement pour supprimer sa confiance. Si ce nettoyage échoue, le picker reste indisponible et le profil appliqué utilise un relais sans déchiffrement jusqu'à sa réussite. macOS peut aussi demander de déverrouiller le trousseau ou -d'autoriser l'accès d'une application aux identifiants, y compris après un redémarrage ou une mise à jour. -Le picker reste non pris en charge sous Windows et Linux : aucun travail de CA, de magasin d'identifiants +d'autoriser l'accès d'une application aux identifiants, y compris après un redémarrage ou une mise à jour. Sur Windows, `on` ou `trust` explicite peut afficher une confirmation d'ajout de la racine. +Le picker reste non pris en charge sous Linux : aucun travail de CA, de magasin d'identifiants ou de proxy picker n'y démarre. L'interception Claude principale reste disponible ; ses fichiers CA locaux sont protégés par des vérifications du propriétaire, des liens symboliques, des permissions et des ACL Windows. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 945a0c56834..7676d89d1a9 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -240,7 +240,7 @@ Claude Desktop first-party routes its Code tab and subagents through OpenCodex. When a Windows system proxy is on, Claude Desktop hands it to the Code tab as `HTTPS_PROXY`, and that value takes precedence over the OpenCodex proxy in `~/.claude/settings.json`. The Code tab then goes around OpenCodex and routed models fail there, while the standalone CLI keeps working. -Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge, +Once picker mode is active, Desktop uses its app-owned fixed egress proxy instead of the system proxy. Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge, `system_proxy_bypass`), then fully quit and reopen Claude Desktop. `ocx doctor` reports this under "Claude Desktop first-party vs Windows system proxy". It cannot evaluate a PAC script or automatic proxy detection (WPAD, "Automatically detect settings"), so it reports those as unknown; @@ -271,17 +271,17 @@ hub, so `ocx claude desktop apply` there uses the gateway profile. ### Picker mode: opencodex models in the first-party Code-tab picker -Picker mode is part of first-party mode. On macOS it is on by default when first-party is selected, +Picker mode is part of first-party mode. On macOS and Windows it is on by default when first-party is selected, unless `claudeCode.intercept.picker: false` is set. It changes the first-party Desktop Code-tab picker so it lists available opencodex models by name. The first time it is enabled, macOS may ask you to -trust a local certificate authority in the login keychain. That authority is constrained to `claude.ai` +trust a local certificate authority in the login keychain, and Windows may ask you to trust it in the current-user Root store. That authority is constrained to `claude.ai` and its subdomains. OpenCodex protects its exportable signing identity in the OS credential store and reuses the same validated certificate and key across normal restarts. No plaintext picker signing key is stored in the OpenCodex config directory. The full constrained CA validation and OS trust -verification still apply. With the same approved identity and an available credential store, restarting -OpenCodex does not add or remove Certificate Trust Settings. Startup restore never installs trust: +verification still apply, including the exact fingerprint with a verified leaf on Windows. With the same approved identity and an available credential store, restarting +OpenCodex does not add or remove macOS Certificate Trust Settings. Restart and status never install trust: if trust is missing, revoked or unknown, the picker stays pending. Run `ocx claude desktop picker on` -or `ocx claude desktop picker trust` explicitly to grant trust. +or `ocx claude desktop picker trust` explicitly to grant trust, including the Windows confirmation when needed. Startup also attempts to remove a legacy on-disk picker signing key before checking whether interception is enabled. Cleanup is best-effort and does not enable interception or block startup. @@ -289,9 +289,9 @@ One-time migration from an older picker identity may require consent to remove i If cleanup cannot finish, the picker stays unavailable and the applied profile uses a blind relay until cleanup succeeds; `ocx claude desktop picker status` reports that state. macOS may separately ask you to unlock the keychain or approve an application's access to stored credentials. These native -access prompts can still occur on restart or upgrade. +access prompts can still occur on restart or upgrade. On Windows, explicit `on` or `trust` may show a confirmation to add the root. -Picker mode remains unsupported on Windows and Linux: OpenCodex starts no picker CA, credential-store +Picker mode remains unsupported on Linux: OpenCodex starts no picker CA, credential-store or proxy work there. The main Claude intercept remains available with ownership, symlink, file-permission and Windows ACL checks protecting its local CA files. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index d208366d07f..8083ec89824 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -145,21 +145,21 @@ unknown は設定がまだ opencodex のプロキシを指すか判断できな ### Picker モード: 1P の Code タブで opencodex モデルを表示する -Picker モードは 1P モードの一部です。macOS で 1P を選ぶとデフォルトで有効になりますが、 +Picker モードは 1P モードの一部です。macOS と Windows で 1P を選ぶとデフォルトで有効になりますが、 `claudeCode.intercept.picker: false` を設定した場合は無効です。1P の Desktop の Code タブにある モデルピッカーを書き換え、利用できる opencodex モデルを名前付きで表示します。初回の有効化時は、 -macOS がログインキーチェーン内のローカル証明書認証局を信頼するよう求めることがあります。この認証局の +macOS がログインキーチェーン内、Windows が現在のユーザーのルートストアでローカル証明書認証局を信頼するよう求めることがあります。この認証局の 制約は `claude.ai` とそのサブドメインに限られます。エクスポート可能な署名用 ID は OS の資格情報ストアで 保護され、通常の再起動でも同じ証明書と鍵を再利用します。OpenCodex の設定ディレクトリには Picker の -署名鍵を平文で保存しません。制約付き CA の完全な検証と OS の信頼確認は引き続き必要です。 -承認済みの同じ ID と利用可能な資格情報ストアがあれば、再起動で証明書の信頼設定を追加・削除しません。 -起動時の復元は信頼をインストールしません。信頼が未設定、取り消し済み、不明の場合は保留となり、 -`ocx claude desktop picker on` または `ocx claude desktop picker trust` を明示的に実行して信頼を付与します。 +署名鍵を平文で保存しません。制約付き CA の完全な検証と OS の信頼確認は引き続き必要です(Windows では正確なフィンガープリントと検証済みリーフで確認します)。 +承認済みの同じ ID と利用可能な資格情報ストアがあれば、再起動で macOS の証明書の信頼設定を追加・削除しません。 +起動時の復元や状態確認は信頼をインストールしません。信頼が未設定、取り消し済み、不明の場合は保留となり、 +`ocx claude desktop picker on` または `ocx claude desktop picker trust` を明示的に実行して信頼を付与します(Windows では確認が表示されることがあります)。 旧 ID からの一度限りの移行では、以前の信頼を削除するために同意が必要になる場合があります。 削除が完了するまでは Picker は利用できず、適用済みプロファイルは復号しない中継を使います。 キーチェーンのロック解除やアプリの資格情報へのアクセス許可は macOS 独自の動作で、再起動や更新時にも -表示される場合があります。Windows と Linux は Picker 非対応で、Picker の CA、資格情報ストア、 +表示される場合があります。Windows では `on` や `trust` の明示的な実行時にルート追加の確認が表示されることがあります。Linux は Picker 非対応で、Picker の CA、資格情報ストア、 プロキシの処理を開始しません。通常の Claude インターセプトは利用でき、ローカル CA ファイルには 所有者、シンボリックリンク、ファイル権限、Windows ACL の検査が適用されます。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index b545d4ca0e0..ff2e8105f95 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -168,21 +168,21 @@ unknown은 설정이 아직 opencodex 프록시를 가리키는지 판단할 수 ### Picker 모드: 1P Code 탭에 opencodex 모델 표시하기 -Picker 모드는 1P 모드의 일부예요. macOS에서 1P를 선택하면 기본으로 켜지지만, +Picker 모드는 1P 모드의 일부예요. macOS와 Windows에서 1P를 선택하면 기본으로 켜지지만, `claudeCode.intercept.picker: false`를 설정하면 꺼져요. 1P Desktop의 Code 탭 모델 선택기를 바꿔서 -사용 가능한 opencodex 모델을 이름으로 보여줘요. 처음 켤 때 macOS 로그인 키체인에서 로컬 인증 기관을 +사용 가능한 opencodex 모델을 이름으로 보여줘요. 처음 켤 때 macOS 로그인 키체인이나 Windows 현재 사용자 Root 저장소에서 로컬 인증 기관을 신뢰하라는 메시지가 표시될 수 있어요. 이 인증 기관은 `claude.ai`와 그 하위 도메인으로 제한돼요. 내보낼 수 있는 서명용 인증서와 키는 OS 자격 증명 저장소로 보호하며, 일반적인 재시작에서는 같은 것을 재사용해요. OpenCodex 설정 디렉터리에 Picker 서명 키를 평문으로 저장하지 않아요. 제한된 CA의 전체 검증과 OS 신뢰 확인은 -계속 적용돼요. 승인한 인증서와 키가 같고 자격 증명 저장소를 사용할 수 있다면 재시작 시 인증서 신뢰 설정을 -추가하거나 삭제하지 않아요. 시작 시 복원은 신뢰를 설치하지 않아요. 신뢰가 없거나 철회됐거나 확인할 수 없다면 +계속 적용돼요(Windows에서는 정확한 지문과 확인된 리프로 검증해요). 승인한 인증서와 키가 같고 자격 증명 저장소를 사용할 수 있다면 재시작 시 macOS 인증서 신뢰 설정을 +추가하거나 삭제하지 않아요. 시작 시 복원이나 상태 확인은 신뢰를 설치하지 않아요. 신뢰가 없거나 철회됐거나 확인할 수 없다면 Picker는 대기 상태로 남아요. `ocx claude desktop picker on` 또는 `ocx claude desktop picker trust`를 직접 실행해 -신뢰를 부여하면 돼요. +신뢰를 부여하면 돼요(Windows에서는 확인 창이 나올 수 있어요). 이전 인증서에서 한 번 마이그레이션할 때는 기존 신뢰를 제거하기 위한 동의가 필요할 수 있어요. 정리가 끝나지 않으면 Picker는 사용할 수 없고 적용된 프로필은 복호화하지 않는 중계로 동작해요. 키체인 잠금 해제나 앱의 -자격 증명 접근 허용은 별도의 macOS 동작이며, 재시작이나 업데이트 때도 메시지가 나올 수 있어요. -Windows와 Linux에서는 Picker를 지원하지 않으며 Picker CA, 자격 증명 저장소, 프록시 작업을 시작하지 않아요. +자격 증명 접근 허용은 별도의 macOS 동작이며, 재시작이나 업데이트 때도 메시지가 나올 수 있어요. Windows에서는 `on`이나 `trust`를 직접 실행할 때 루트 추가 확인이 나올 수 있어요. +Linux에서는 Picker를 지원하지 않으며 Picker CA, 자격 증명 저장소, 프록시 작업을 시작하지 않아요. 기본 Claude 인터셉트는 계속 사용할 수 있고, 로컬 CA 파일에 소유자, 심볼릭 링크, 파일 권한, Windows ACL 검사를 적용해요. Picker 모드가 켜져 있는 동안 Claude Desktop의 네트워크는 OpenCodex를 거쳐요. OpenCodex가 중단되면 diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index d96d99cd490..760d28fc1b6 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -153,22 +153,22 @@ unknown означает, что opencodex не может определить, ### Режим picker: модели opencodex в селекторе Code first-party -Режим picker входит в режим first-party. В macOS он включён по умолчанию при выборе first-party, +Режим picker входит в режим first-party. В macOS и Windows он включён по умолчанию при выборе first-party, если не задано `claudeCode.intercept.picker: false`. Он изменяет селектор моделей во вкладке Code first-party Desktop и показывает доступные модели opencodex по именам. При первом включении macOS может -попросить доверить локальному центру сертификации в связке ключей для входа. Этот центр ограничен +попросить доверить локальному центру сертификации в связке ключей для входа, а Windows — в хранилище корней текущего пользователя. Этот центр ограничен `claude.ai` и его поддоменами. Экспортируемая идентичность подписи защищена хранилищем учётных данных ОС и повторно используется при обычных перезапусках. Ключ подписи picker не хранится открытым текстом в -каталоге конфигурации OpenCodex. Полная проверка ограниченного CA и доверия ОС сохраняется. Если одобренная +каталоге конфигурации OpenCodex. Полная проверка ограниченного CA и доверия ОС сохраняется, в Windows — по точному отпечатку с проверенным листом. Если одобренная идентичность не изменилась и хранилище доступно, перезапуск не добавляет и не удаляет настройки доверия -сертификатам. Восстановление при запуске никогда не устанавливает доверие: если оно отсутствует, отозвано +сертификатам macOS. Восстановление при запуске и проверка состояния никогда не устанавливают доверие: если оно отсутствует, отозвано или неизвестно, picker остаётся в ожидании. Для предоставления доверия явно выполните -`ocx claude desktop picker on` или `ocx claude desktop picker trust`. +`ocx claude desktop picker on` или `ocx claude desktop picker trust`, включая подтверждение Windows при необходимости. При однократной миграции со старой идентичности может потребоваться согласие на удаление прежнего доверия. Пока очистка не завершена, picker недоступен, а применённый профиль использует туннель без расшифровки. macOS отдельно может попросить разблокировать связку ключей или разрешить приложению доступ к учётным -данным, в том числе после перезапуска или обновления. Windows и Linux по-прежнему не поддерживают picker: +данным, в том числе после перезапуска или обновления. В Windows явные `on` или `trust` могут запросить подтверждение добавления корня. Linux по-прежнему не поддерживает picker: работа с его CA, хранилищем и прокси не запускается. Основной перехват Claude остаётся доступен; для его локальных файлов CA проверяются владелец, символические ссылки, права доступа и ACL Windows. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 04e4d331fb2..a7430a8072e 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -190,22 +190,22 @@ unknown, ayarların hâlâ opencodex vekiline işaret edip etmediğinin belirlen ### Picker modu: first-party Code sekmesinde opencodex modelleri -Picker modu first-party modunun bir parçasıdır. macOS'ta first-party seçildiğinde varsayılan olarak +Picker modu first-party modunun bir parçasıdır. macOS ve Windows'ta first-party seçildiğinde varsayılan olarak açıktır; `claudeCode.intercept.picker: false` ayarlanırsa kapalı kalır. First-party Desktop'ın Code sekmesindeki model seçiciyi değiştirerek kullanılabilir opencodex modellerini adlarıyla listeler. -İlk etkinleştirmede macOS, giriş anahtar zincirinde yerel bir sertifika yetkilisine güvenmenizi isteyebilir. +İlk etkinleştirmede macOS, giriş anahtar zincirinde, Windows ise geçerli kullanıcının kök deposunda yerel bir sertifika yetkilisine güvenmenizi isteyebilir. Bu yetkili `claude.ai` ve alt alan adlarıyla sınırlıdır. Dışa aktarılabilir imzalama kimliği işletim sisteminin kimlik bilgisi deposunda korunur ve normal yeniden başlatmalarda aynı sertifika ve anahtar yeniden kullanılır. OpenCodex yapılandırma dizininde düz metin picker imza anahtarı saklanmaz. Kısıtlı CA'nın tam doğrulaması ve -sistemdeki güven denetimi devam eder. Onaylanmış kimlik aynıysa ve depo erişilebilirse yeniden başlatma, -sertifika güven ayarlarını eklemez veya kaldırmaz. Başlangıçta geri yükleme hiçbir zaman güven yüklemez: +sistemdeki güven denetimi devam eder (Windows'ta tam parmak izi ve doğrulanmış yaprak ile). Onaylanmış kimlik aynıysa ve depo erişilebilirse yeniden başlatma, +macOS sertifika güven ayarlarını eklemez veya kaldırmaz. Başlangıçta geri yükleme ve durum denetimi hiçbir zaman güven yüklemez: güven eksik, iptal edilmiş veya bilinmiyorsa picker beklemede kalır. Güven vermek için açıkça -`ocx claude desktop picker on` veya `ocx claude desktop picker trust` komutunu çalıştırın. +`ocx claude desktop picker on` veya `ocx claude desktop picker trust` komutunu çalıştırın, gerekiyorsa Windows onayıyla birlikte. Eski bir kimlikten bir defalık geçişte önceki güveni kaldırmak için onay gerekebilir. Temizlik tamamlanana kadar picker kullanılamaz ve uygulanmış profil şifre çözmeyen bir aktarma kullanır. macOS ayrıca anahtar zincirinin kilidini açmanızı veya uygulamanın kimlik bilgilerine erişimini onaylamanızı isteyebilir; -bu istemler yeniden başlatma veya güncelleme sonrasında da çıkabilir. Windows ve Linux'ta picker desteklenmez: +bu istemler yeniden başlatma veya güncelleme sonrasında da çıkabilir. Windows'ta açık `on` veya `trust` kök ekleme onayını gösterebilir. Linux'ta picker desteklenmez: picker CA, kimlik bilgisi deposu veya proxy işlemleri başlatılmaz. Ana Claude yakalama işlevi kullanılabilir; yerel CA dosyaları için sahiplik, sembolik bağlantı, dosya izinleri ve Windows ACL denetimleri uygulanır. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 5fbf851d5d2..9cd0439f289 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -138,18 +138,18 @@ unknown 表示 opencodex 无法确定设置是否仍指向自己的代理。外 ### Picker 模式:在第一方 Code 标签页中显示 opencodex 模型 -Picker 模式是第一方模式的一部分。在 macOS 上选择第一方时默认开启;设置 +Picker 模式是第一方模式的一部分。在 macOS 和 Windows 上选择第一方时默认开启;设置 `claudeCode.intercept.picker: false` 后会保持关闭。它会修改第一方 Desktop 的 Code 标签页模型选择器, -按名称列出可用的 opencodex 模型。首次开启时,macOS 可能会要求你在登录钥匙串中信任本地证书颁发机构。 +按名称列出可用的 opencodex 模型。首次开启时,macOS 可能会要求你在登录钥匙串中、Windows 可能会要求你在当前用户根存储中信任本地证书颁发机构。 该颁发机构限制为 `claude.ai` 及其子域名。可导出的签名身份由 OS 凭据存储保护,普通重启会复用同一证书和密钥。 -OpenCodex 配置目录中不保存明文 Picker 签名密钥。受限 CA 的完整验证和 OS 信任检查仍然适用。 -已批准的身份不变且凭据存储可用时,重启不会添加或删除证书信任设置。启动恢复绝不会安装信任: +OpenCodex 配置目录中不保存明文 Picker 签名密钥。受限 CA 的完整验证和 OS 信任检查仍然适用(Windows 按精确指纹与已验证叶子验证)。 +已批准的身份不变且凭据存储可用时,重启不会添加或删除 macOS 证书信任设置。启动恢复与状态检查绝不会安装信任: 若信任缺失、被撤销或无法确认,Picker 会保持待处理状态。请明确运行 `ocx claude desktop picker on` -或 `ocx claude desktop picker trust` 来授予信任。 +或 `ocx claude desktop picker trust` 来授予信任(Windows 需要时含确认)。 从旧身份进行一次性迁移时,清除原有信任可能需要同意。清理未完成时,Picker 不可用,已应用的配置档案 -会使用不解密的中继。macOS 还可能单独要求解锁钥匙串或批准应用访问凭据,重启或升级时也可能出现这些提示。 -Windows 和 Linux 仍不支持 Picker,不会启动 Picker CA、凭据存储或代理工作。主要 Claude 拦截功能仍可用, +会使用不解密的中继。macOS 还可能单独要求解锁钥匙串或批准应用访问凭据,重启或升级时也可能出现这些提示。Windows 上显式 `on` 或 `trust` 可能显示添加根的确认。 +Linux 仍不支持 Picker,不会启动 Picker CA、凭据存储或代理工作。主要 Claude 拦截功能仍可用, 其本地 CA 文件受到所有者、符号链接、文件权限及 Windows ACL 检查保护。 Picker 模式开启期间,Claude Desktop 通过 OpenCodex 访问网络。如果 OpenCodex 停止,Desktop 会处于离线状态, diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index ceac4790726..e205003c2f8 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -145,18 +145,18 @@ unknown 表示 opencodex 無法確定設定是否仍指向自己的代理。外 ### Picker 模式:在第一方 Code 分頁顯示 opencodex 模型 -Picker 模式是第一方模式的一部分。在 macOS 上選擇第一方時預設開啟;設定 +Picker 模式是第一方模式的一部分。在 macOS 和 Windows 上選擇第一方時預設開啟;設定 `claudeCode.intercept.picker: false` 後會保持關閉。它會修改第一方 Desktop 的 Code 分頁模型選擇器, -依名稱列出可用的 opencodex 模型。首次開啟時,macOS 可能會要求你在登入鑰匙圈中信任本機憑證授權單位。 +依名稱列出可用的 opencodex 模型。首次開啟時,macOS 可能會要求你在登入鑰匙圈中、Windows 可能會要求你在目前使用者根存放區中信任本機憑證授權單位。 該授權單位限制為 `claude.ai` 及其子網域。可匯出的簽章身分由 OS 認證資料儲存區保護,一般重新啟動會重用相同的憑證與金鑰。 -OpenCodex 設定目錄不會儲存明文 Picker 簽章金鑰。受限 CA 的完整驗證與 OS 信任檢查仍然適用。 -已核准的身分不變且認證資料儲存區可用時,重新啟動不會新增或移除憑證信任設定。啟動復原絕不會安裝信任: +OpenCodex 設定目錄不會儲存明文 Picker 簽章金鑰。受限 CA 的完整驗證與 OS 信任檢查仍然適用(Windows 按精確指紋與已驗證葉片驗證)。 +已核准的身分不變且認證資料儲存區可用時,重新啟動不會新增或移除 macOS 憑證信任設定。啟動復原與狀態檢查絕不會安裝信任: 若信任缺失、遭撤銷或無法確認,Picker 會維持待處理狀態。請明確執行 `ocx claude desktop picker on` -或 `ocx claude desktop picker trust` 來授予信任。 +或 `ocx claude desktop picker trust` 來授予信任(Windows 需要時含確認)。 從舊身分進行一次性移轉時,清除原有信任可能需要同意。清理未完成時,Picker 無法使用,已套用的設定檔 -會使用不解密的中繼。macOS 也可能另外要求解鎖鑰匙圈或核准應用程式存取憑證,重新啟動或升級時也可能出現這些提示。 -Windows 與 Linux 仍不支援 Picker,不會啟動 Picker CA、認證資料儲存區或代理作業。主要 Claude 攔截功能仍可用, +會使用不解密的中繼。macOS 也可能另外要求解鎖鑰匙圈或核准應用程式存取憑證,重新啟動或升級時也可能出現這些提示。Windows 上明確執行 `on` 或 `trust` 可能顯示新增根的確認。 +Linux 仍不支援 Picker,不會啟動 Picker CA、認證資料儲存區或代理作業。主要 Claude 攔截功能仍可用, 其本機 CA 檔案受到擁有者、符號連結、檔案權限及 Windows ACL 檢查保護。 Picker 模式開啟期間,Claude Desktop 會透過 OpenCodex 存取網路。如果 OpenCodex 停止,Desktop 會離線, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index e3be8ba278d..2be984e06a0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3317,8 +3317,8 @@ export const de: Record = { "claudeDesktop.picker.toggle": "Claude-Desktop-Auswahl aktivieren", "claudeDesktop.picker.state.active": "Auswahl ist aktiv", "claudeDesktop.picker.state.restart": "Claude Desktop vollständig beenden und erneut öffnen, um das Auswahlprofil anzuwenden.", - "claudeDesktop.picker.state.trustPending": "Warten auf den Schlüsselbundschritt", - "claudeDesktop.picker.state.trustDeclined": "Schlüsselbundvertrauen wurde abgelehnt", + "claudeDesktop.picker.state.trustPending": "Warten auf den Zertifikatvertrauensschritt", + "claudeDesktop.picker.state.trustDeclined": "Zertifikatvertrauen wurde abgelehnt", "claudeDesktop.picker.state.proxyUnavailable": "Auswahlproxy läuft nicht", "claudeDesktop.picker.state.unsupported": "Auswahlmodus wird auf diesem Betriebssystem nicht unterstützt", "claudeDesktop.picker.state.notFirstParty": "Die Auswahl ist nur im First-Party-Modus verfügbar", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1dce105005e..ee3a0f79c9b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3431,8 +3431,8 @@ export const en = { "claudeDesktop.picker.toggle": "Enable Claude Desktop picker", "claudeDesktop.picker.state.active": "Picker is active", "claudeDesktop.picker.state.restart": "Fully quit and reopen Claude Desktop to finish applying the picker profile.", - "claudeDesktop.picker.state.trustPending": "Waiting for the keychain step", - "claudeDesktop.picker.state.trustDeclined": "Keychain trust was declined", + "claudeDesktop.picker.state.trustPending": "Waiting for certificate trust", + "claudeDesktop.picker.state.trustDeclined": "Certificate trust was declined", "claudeDesktop.picker.state.proxyUnavailable": "Picker proxy is not running", "claudeDesktop.picker.state.unsupported": "Picker mode is unsupported on this OS", "claudeDesktop.picker.state.notFirstParty": "Picker is available only in first-party mode", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1295c428bb3..3e661ba2e74 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3344,8 +3344,8 @@ export const fr: Record = { "claudeDesktop.picker.toggle": "Activer le sélecteur Claude Desktop", "claudeDesktop.picker.state.active": "Sélecteur actif", "claudeDesktop.picker.state.restart": "Quittez complètement Claude Desktop, puis rouvrez-le pour appliquer le profil du sélecteur.", - "claudeDesktop.picker.state.trustPending": "En attente de l’étape du trousseau", - "claudeDesktop.picker.state.trustDeclined": "La confiance du trousseau a été refusée", + "claudeDesktop.picker.state.trustPending": "En attente de l’étape de confiance du certificat", + "claudeDesktop.picker.state.trustDeclined": "La confiance du certificat a été refusée", "claudeDesktop.picker.state.proxyUnavailable": "Le proxy du sélecteur n’est pas démarré", "claudeDesktop.picker.state.unsupported": "Le mode sélecteur n’est pas pris en charge sur ce système", "claudeDesktop.picker.state.notFirstParty": "Le sélecteur est disponible uniquement en mode first-party", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f134f328e33..de0563d96f6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3033,8 +3033,8 @@ export const ja: Record = { "claudeDesktop.picker.toggle": "Claude Desktop ピッカーを有効化", "claudeDesktop.picker.state.active": "ピッカーは有効です", "claudeDesktop.picker.state.restart": "ピッカープロファイルの適用を完了するには、Claude Desktop を完全に終了して再起動してください。", - "claudeDesktop.picker.state.trustPending": "キーチェーンの手順を待っています", - "claudeDesktop.picker.state.trustDeclined": "キーチェーンの信頼が拒否されました", + "claudeDesktop.picker.state.trustPending": "証明書の信頼手順を待っています", + "claudeDesktop.picker.state.trustDeclined": "証明書の信頼が拒否されました", "claudeDesktop.picker.state.proxyUnavailable": "ピッカープロキシが起動していません", "claudeDesktop.picker.state.unsupported": "この OS ではピッカーモードを利用できません", "claudeDesktop.picker.state.notFirstParty": "ピッカーはファーストパーティーモードでのみ利用できます", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index fc2ecd9da3a..cdebd0cff3c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3364,8 +3364,8 @@ export const ko: Record = { "claudeDesktop.picker.toggle": "Claude Desktop 선택기 켜기", "claudeDesktop.picker.state.active": "선택기가 활성화됨", "claudeDesktop.picker.state.restart": "선택기 프로필 적용을 마치려면 Claude Desktop을 완전히 종료한 뒤 다시 열어주세요.", - "claudeDesktop.picker.state.trustPending": "키체인 단계 대기 중", - "claudeDesktop.picker.state.trustDeclined": "키체인 신뢰가 거부됨", + "claudeDesktop.picker.state.trustPending": "인증서 신뢰 단계 대기 중", + "claudeDesktop.picker.state.trustDeclined": "인증서 신뢰가 거부됨", "claudeDesktop.picker.state.proxyUnavailable": "선택기 프록시가 실행 중이 아님", "claudeDesktop.picker.state.unsupported": "이 운영체제에서는 선택기 모드를 지원하지 않음", "claudeDesktop.picker.state.notFirstParty": "선택기는 퍼스트파티 모드에서만 사용할 수 있음", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index ed9e0e4d5b2..228faf7a3c3 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3387,8 +3387,8 @@ export const pt: Record = { "claudeDesktop.picker.toggle": "Ativar o seletor do Claude Desktop", "claudeDesktop.picker.state.active": "O seletor está ativo", "claudeDesktop.picker.state.restart": "Feche completamente e reabra o Claude Desktop para concluir a aplicação do perfil do seletor.", - "claudeDesktop.picker.state.trustPending": "Aguardando a etapa do chaveiro", - "claudeDesktop.picker.state.trustDeclined": "A confiança no chaveiro foi recusada", + "claudeDesktop.picker.state.trustPending": "Aguardando a etapa de confiança do certificado", + "claudeDesktop.picker.state.trustDeclined": "A confiança no certificado foi recusada", "claudeDesktop.picker.state.proxyUnavailable": "O proxy do seletor não está em execução", "claudeDesktop.picker.state.unsupported": "O modo seletor não é compatível com este sistema operacional", "claudeDesktop.picker.state.notFirstParty": "O seletor só está disponível no modo de primeira parte", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 301e460bf8b..8a2867cee43 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3123,8 +3123,8 @@ export const ru: Record = { "claudeDesktop.picker.toggle": "Включить выбор Claude Desktop", "claudeDesktop.picker.state.active": "Выбор активен", "claudeDesktop.picker.state.restart": "Полностью закройте и снова откройте Claude Desktop, чтобы применить профиль выбора.", - "claudeDesktop.picker.state.trustPending": "Ожидание шага связки ключей", - "claudeDesktop.picker.state.trustDeclined": "Доверие связке ключей отклонено", + "claudeDesktop.picker.state.trustPending": "Ожидание подтверждения доверия сертификату", + "claudeDesktop.picker.state.trustDeclined": "Доверие сертификату отклонено", "claudeDesktop.picker.state.proxyUnavailable": "Прокси выбора не запущен", "claudeDesktop.picker.state.unsupported": "Режим выбора не поддерживается в этой ОС", "claudeDesktop.picker.state.notFirstParty": "Выбор доступен только в режиме first-party", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3a73605cf8b..da8b1c33d9e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3367,8 +3367,8 @@ export const tr: Record = { "claudeDesktop.picker.toggle": "Claude Desktop seçiciyi etkinleştir", "claudeDesktop.picker.state.active": "Seçici etkin", "claudeDesktop.picker.state.restart": "Seçici profilini uygulamak için Claude Desktop’tan tamamen çıkıp yeniden açın.", - "claudeDesktop.picker.state.trustPending": "Anahtar zinciri adımı bekleniyor", - "claudeDesktop.picker.state.trustDeclined": "Anahtar zinciri güveni reddedildi", + "claudeDesktop.picker.state.trustPending": "Sertifika güven adımı bekleniyor", + "claudeDesktop.picker.state.trustDeclined": "Sertifika güveni reddedildi", "claudeDesktop.picker.state.proxyUnavailable": "Seçici proxy’si çalışmıyor", "claudeDesktop.picker.state.unsupported": "Seçici modu bu işletim sisteminde desteklenmiyor", "claudeDesktop.picker.state.notFirstParty": "Seçici yalnızca first-party modunda kullanılabilir", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 77677c884fb..3d8f356ec0b 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3362,8 +3362,8 @@ export const vi: Record = { "claudeDesktop.picker.toggle": "Bật bộ chọn Claude Desktop", "claudeDesktop.picker.state.active": "Bộ chọn đang hoạt động", "claudeDesktop.picker.state.restart": "Thoát hoàn toàn rồi mở lại Claude Desktop để hoàn tất áp dụng hồ sơ bộ chọn.", - "claudeDesktop.picker.state.trustPending": "Đang chờ bước trong chuỗi khóa", - "claudeDesktop.picker.state.trustDeclined": "Đã từ chối tin cậy chuỗi khóa", + "claudeDesktop.picker.state.trustPending": "Đang chờ bước tin cậy chứng chỉ", + "claudeDesktop.picker.state.trustDeclined": "Đã từ chối tin cậy chứng chỉ", "claudeDesktop.picker.state.proxyUnavailable": "Proxy bộ chọn chưa chạy", "claudeDesktop.picker.state.unsupported": "Hệ điều hành này không hỗ trợ chế độ bộ chọn", "claudeDesktop.picker.state.notFirstParty": "Bộ chọn chỉ khả dụng trong chế độ first-party", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index dbbd6bb1cd3..26906194058 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3334,8 +3334,8 @@ export const zhTW: Record = { "claudeDesktop.picker.toggle": "啟用 Claude Desktop 選擇器", "claudeDesktop.picker.state.active": "選擇器已啟用", "claudeDesktop.picker.state.restart": "請完整結束並重新開啟 Claude Desktop,完成選擇器設定套用。", - "claudeDesktop.picker.state.trustPending": "正在等待鑰匙圈步驟", - "claudeDesktop.picker.state.trustDeclined": "鑰匙圈信任遭拒", + "claudeDesktop.picker.state.trustPending": "正在等待憑證信任步驟", + "claudeDesktop.picker.state.trustDeclined": "憑證信任遭拒", "claudeDesktop.picker.state.proxyUnavailable": "選擇器代理伺服器未執行", "claudeDesktop.picker.state.unsupported": "此作業系統不支援選擇器模式", "claudeDesktop.picker.state.notFirstParty": "選擇器僅適用於第一方模式", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 50a7c291ad1..a218592a44e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3337,8 +3337,8 @@ export const zh: Record = { "claudeDesktop.picker.toggle": "启用 Claude Desktop 选择器", "claudeDesktop.picker.state.active": "选择器已启用", "claudeDesktop.picker.state.restart": "请完全退出并重新打开 Claude Desktop,以完成选择器配置的应用。", - "claudeDesktop.picker.state.trustPending": "正在等待钥匙串步骤", - "claudeDesktop.picker.state.trustDeclined": "钥匙串信任已被拒绝", + "claudeDesktop.picker.state.trustPending": "正在等待证书信任步骤", + "claudeDesktop.picker.state.trustDeclined": "证书信任已被拒绝", "claudeDesktop.picker.state.proxyUnavailable": "选择器代理未运行", "claudeDesktop.picker.state.unsupported": "此操作系统不支持选择器模式", "claudeDesktop.picker.state.notFirstParty": "选择器仅在第一方模式下可用", diff --git a/gui/tests/claude-desktop-picker.test.tsx b/gui/tests/claude-desktop-picker.test.tsx index f4ed7cd4caf..7c6d17f180e 100644 --- a/gui/tests/claude-desktop-picker.test.tsx +++ b/gui/tests/claude-desktop-picker.test.tsx @@ -98,7 +98,7 @@ test("sends the persisted toggle and renders a reported proxy refusal", async () expect(container.querySelector(".notice-err")).toBeNull(); response = { status: 200, body: { ok: true, picker: { ...basePicker, reason: "trust_pending", hint: "ocx claude desktop picker trust" } } }; await act(async () => { (container.querySelector("[role=switch]") as HTMLButtonElement).click(); }); - expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Waiting for the keychain step"); + expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Waiting for certificate trust"); expect(container.querySelector(".claude-picker-state code")?.textContent).toBe("ocx claude desktop picker trust"); }); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d8947b9008f..6ee8b882e0c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -378,7 +378,7 @@ "claude-picker-recovery.test.ts": "claude-integration", "claude-picker-runtime.test.ts": "claude-integration", "claude-picker-startup.test.ts": "claude-integration", - "claude-picker-trust.test.ts": "claude-integration", + "claude-picker-trust.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-integration", "claude-picker-trust-windows.test.ts": "claude-integration", "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-estimate-projection.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_surface_integrations.md b/skills/ocx/references/01_surface_integrations.md index b8688d3a5c0..1c2857521b8 100644 --- a/skills/ocx/references/01_surface_integrations.md +++ b/skills/ocx/references/01_surface_integrations.md @@ -153,7 +153,7 @@ State-changing: yes. JSON mode: `none`. -- Needs a running proxy, first-party mode and macOS. The first time, macOS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal. +- Needs a running proxy, first-party mode and macOS or Windows. The first time, the OS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal. - Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards. ### `ocx claude desktop picker off` @@ -176,7 +176,7 @@ JSON mode: `none`. Usage: `ocx claude desktop picker trust` -Run the macOS keychain step for picker mode in this terminal, then ask the server to finish enabling it. +Run the OS certificate trust step for picker mode in this terminal, then ask the server to finish enabling it. State-changing: yes. diff --git a/src/claude/desktop-picker-platform.ts b/src/claude/desktop-picker-platform.ts new file mode 100644 index 00000000000..4d3b8ea50b2 --- /dev/null +++ b/src/claude/desktop-picker-platform.ts @@ -0,0 +1,4 @@ +/** Platforms with a Desktop egress profile and an OS trust adapter for the picker. */ +export function supportsDesktopPicker(platform: NodeJS.Platform = process.platform): boolean { + return platform === "darwin" || platform === "win32"; +} diff --git a/src/claude/desktop-picker.ts b/src/claude/desktop-picker.ts index 5a364523dd8..7cd9e3dba97 100644 --- a/src/claude/desktop-picker.ts +++ b/src/claude/desktop-picker.ts @@ -23,6 +23,7 @@ import { type DesktopPickerProfileOptions, } from "./desktop-picker-profile"; import { resolveClaudeDesktopMode, observeClaudeDesktopMode } from "./desktop-first-party"; +import { supportsDesktopPicker } from "./desktop-picker-platform"; /** Desktop no longer selects the picker profile, so removing the CA's trust cannot strand it. */ function profileReleased(profile: DesktopPickerProfileInspection): boolean { @@ -91,7 +92,7 @@ function profileOptions(deps: DesktopPickerControllerDeps): DesktopPickerProfile } function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, proxyBound: boolean): DesktopPickerReason { - if (platform !== "darwin") return "unsupported_platform"; + if (!supportsDesktopPicker(platform)) return "unsupported_platform"; if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "not_first_party"; if (!claudeDesktopIntegrationEnabled(config)) return "integration_off"; if (config.claudeCode?.intercept?.picker === false) return "disabled"; @@ -101,11 +102,11 @@ function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, pro function emptyStatus(config: OcxConfig, platform: NodeJS.Platform, reason: DesktopPickerReason): DesktopPickerStatus { return { - desired: platform === "darwin" + desired: supportsDesktopPicker(platform) && claudeDesktopIntegrationEnabled(config) && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) === "first-party" && config.claudeCode?.intercept?.picker !== false, - supported: platform === "darwin", + supported: supportsDesktopPicker(platform), trust: "unknown", profile: "absent", listenerReady: false, @@ -142,7 +143,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) const trust = trustOverride ?? runtime.trust; const effective = runtime.effective && profileCurrent && trust === "trusted"; let reason: DesktopPickerReason; - if (platform !== "darwin") reason = "unsupported_platform"; + if (!supportsDesktopPicker(platform)) reason = "unsupported_platform"; else if (!runtime.desired) reason = statusReasonForConfig(deps.readConfig(), platform, deps.proxyPort() !== null); else if (deps.proxyPort() === null) reason = "proxy_unavailable"; else if (trust !== "trusted") reason = "trust_pending"; @@ -178,7 +179,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) async function untrustCurrentCa(): Promise { const caPath = pickerCaCertPath(deps.configDir); - if (platform !== "darwin" || !existsSync(caPath)) return true; + if (!supportsDesktopPicker(platform) || !existsSync(caPath)) return true; try { const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1; return (await untrustPickerCa(caPath, sha1, deps.security, platform)).ok; @@ -207,7 +208,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) return withTrustFailure({ ...runtimeStatus(observedTrust), reason }, trustFailed); }; const check = (config: OcxConfig, includePreference: boolean): DesktopPickerReason | null => { - if (platform !== "darwin") return "unsupported_platform"; + if (!supportsDesktopPicker(platform)) return "unsupported_platform"; if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "mode_not_committed"; if (!claudeDesktopIntegrationEnabled(config)) return "integration_off"; if (includePreference && config.claudeCode?.intercept?.picker === false) return "disabled"; @@ -336,7 +337,7 @@ export async function removeDesktopPickerArtifacts(options: { configDir?: string return { ok: false, residual }; } const caPath = pickerCaCertPath(configDir); - if (platform === "darwin" && existsSync(caPath)) { + if (supportsDesktopPicker(platform) && existsSync(caPath)) { try { const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1; if (!(await untrustPickerCa(caPath, sha1, options.security, platform)).ok) residual.push("trust"); diff --git a/src/claude/intercept/picker-ca-startup.ts b/src/claude/intercept/picker-ca-startup.ts index 010032439d5..63d1a9f292b 100644 --- a/src/claude/intercept/picker-ca-startup.ts +++ b/src/claude/intercept/picker-ca-startup.ts @@ -2,12 +2,13 @@ import { ensurePickerCa, type PickerCa } from "./picker-ca"; import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup"; import type { PickerCaStore } from "./picker-ca-store"; import type { SecurityRunner } from "./picker-trust"; +import { supportsDesktopPicker } from "../desktop-picker-platform"; /** Internal activation seam: finish legacy cleanup before a persistent issuer is usable. */ export async function preparePersistentPickerAuthority(options: { configDir: string; store?: PickerCaStore; security?: SecurityRunner; platform: NodeJS.Platform; }): Promise { - if (options.platform !== "darwin") throw new Error("picker_ca_unsupported_platform"); + if (!supportsDesktopPicker(options.platform)) throw new Error("picker_ca_unsupported_platform"); const drain = () => drainPendingPickerCaUntrust(options.configDir, options.security, options.platform); if (!await drain()) throw new Error("picker_ca_pending_untrust"); const ca = ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store }); diff --git a/src/claude/intercept/picker-runtime.ts b/src/claude/intercept/picker-runtime.ts index 4fbb72be313..7b4f56310e3 100644 --- a/src/claude/intercept/picker-runtime.ts +++ b/src/claude/intercept/picker-runtime.ts @@ -7,15 +7,16 @@ * `intercept`. Everything else, and claude.ai whenever any condition fails, is a blind tunnel. * * The decision is cached by `refresh()` and only read by `selectTunnel`, so a CONNECT never - * waits on the keychain or the catalog. Arming needs all of: macOS, the persisted resolved mode is + * waits on the OS trust store or the catalog. Arming needs all of: a supported platform, the persisted resolved mode is * first-party (observation-aware, so a pre-field first-party install counts), Desktop intent on, * `claudeCode.intercept.picker !== false`, the disarm latch clear, the picker listener up and the - * current picker CA trusted in the login keychain. `disarm()` stops terminating immediately and + * current picker CA trusted in the OS root store. `disarm()` stops terminating immediately and * latches; only the picker controller's owner-only `rearm()` clears the latch. */ import { join } from "node:path"; import type { OcxConfig } from "../../types"; import type { ClaudeDesktopMode } from "../desktop-first-party"; +import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { TunnelDecision } from "./connect-proxy"; import type { PemKeyPair } from "./local-ca"; import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca"; @@ -108,7 +109,7 @@ export function pickerDesired( mode: ClaudeDesktopMode, platform: NodeJS.Platform = process.platform, ): boolean { - return platform === "darwin" + return supportsDesktopPicker(platform) && config.clientIntegrations?.["claude-desktop"] !== false && mode === "first-party" && config.claudeCode?.intercept?.picker !== false; @@ -225,7 +226,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker desired = pickerDesired(fresh, mode, platform); if (!desired || latched) { armed = false; - reason = platform !== "darwin" ? "unsupported_platform" : latched ? "disarmed" : "not_desired"; + reason = !supportsDesktopPicker(platform) ? "unsupported_platform" : latched ? "disarmed" : "not_desired"; closeListener(); return; } @@ -312,7 +313,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker const current = snapshot?.current() ?? null; return { desired, - supported: platform === "darwin", + supported: supportsDesktopPicker(platform), trust, listenerReady: listener !== null, effective: armed && listener !== null && !latched && !stopped, diff --git a/src/claude/intercept/picker-trust-windows.ts b/src/claude/intercept/picker-trust-windows.ts new file mode 100644 index 00000000000..a5e03fb8317 --- /dev/null +++ b/src/claude/intercept/picker-trust-windows.ts @@ -0,0 +1,320 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; +import { + acceptsPickerAuthority, + PICKER_CA_COMMON_NAME, + PICKER_HOST, + pickerCaFingerprints, +} from "./picker-ca"; +import type { PickerTrustState, SecurityResult } from "./picker-trust"; + +/** powershell.exe argv result; alias so the two trust seams never drift apart. */ +export type WindowsTrustResult = SecurityResult; +/** Per-script options; fakes may ignore them, so single-arg fakes stay portable. */ +export interface WindowsTrustRunnerOptions { timeoutMs?: number } + +/** + * powershell.exe argv runner. A SecurityRunner satisfies this shape so macOS-style + * fakes stay portable. The caller in picker-trust.ts only forwards an explicitly + * injected runner and otherwise leaves this undefined for the Windows default. + */ +export type WindowsTrustRunner = ( + args: readonly string[], + options?: WindowsTrustRunnerOptions, +) => Promise; + +/** Read-only argv prefix: no profile, no logo, no interaction. Used for inspection. */ +export const WINDOWS_TRUST_POWERSHELL_PREFIX = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"] as const; +/** Mutating argv prefix: the OS confirmation dialog must be able to appear. */ +export const WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX = ["-NoLogo", "-NoProfile", "-Command"] as const; + +/** Read-only inspection budget: a hung store lookup must never stall status checks. */ +export const WINDOWS_TRUST_INSPECT_TIMEOUT_MS = 10_000; +/** Interactive import budget: Root installs raise the OS confirmation dialog. */ +export const WINDOWS_TRUST_IMPORT_TIMEOUT_MS = 120_000; +/** + * Removal budget stays bounded: predecessor cleanup also runs in headless migration + * and startup paths where an OS confirmation dialog cannot be answered. A timed-out or + * declined removal reports failure and is retried on the next explicit activation. + */ +export const WINDOWS_TRUST_UNTRUST_TIMEOUT_MS = 30_000; + +const OUTPUT_LIMIT = 64 * 1024; +const TRUST_MARKER = "OCX_PICKER_TRUST="; +const SHA1_PATTERN = /^[0-9A-F]{40}$/; +const BASE64_PATTERN = /^[A-Za-z0-9+/=]*$/; + +/** Uppercase hex without separators, or null. Trust matches on this, never on the CN. */ +export function normalizePickerTrustSha1(value: string): string | null { + const normalized = value.replace(/[:\s]/g, "").toUpperCase(); + return SHA1_PATTERN.test(normalized) ? normalized : null; +} + +/** + * Copy the environment minus every PSModulePath entry (case-insensitive). + * The trust scripts are .NET-only so module autoload is pure cost and shadowing + * risk: a PSModulePath entry can shadow resolution and stall process start on + * hosts with a cold module cache. Nothing here needs a module. + */ +export function sanitizeWindowsPowerShellEnv(env: NodeJS.ProcessEnv = process.env): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (key.toLowerCase() === "psmodulepath") continue; + out[key] = value; + } + return out; +} + +/** + * Production runner: trusted System32 powershell.exe, hidden window, sanitized + * module path, bounded runtime. A timeout kills the child and reports code null + * so callers map it to unknown/declined instead of hanging periodic status. + */ +export const defaultWindowsPowerShellRunner: WindowsTrustRunner = async (args, options) => { + const timeoutMs = options?.timeoutMs ?? WINDOWS_TRUST_INSPECT_TIMEOUT_MS; + let exe: string; + try { + exe = resolveTrustedWindowsPowerShellExe(); + } catch (error) { + return { code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) }; + } + let child: { kill(): void } | undefined; + let timer: ReturnType | undefined; + try { + const proc = Bun.spawn([exe, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + env: sanitizeWindowsPowerShellEnv(), + }); + child = proc; + const pending = (async () => { + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { out, err, code }; + })(); + const settled = await new Promise<{ out: string; err: string; code: number } | null>(resolve => { + timer = setTimeout(() => { + try { child?.kill(); } catch { /* already gone */ } + resolve(null); + }, timeoutMs); + pending.then(resolve, () => resolve(null)); + }); + if (timer !== undefined) clearTimeout(timer); + if (settled === null) { + return { code: null, stdout: "", stderr: `powershell trust script timed out after ${timeoutMs}ms` }; + } + return { code: settled.code, stdout: settled.out.slice(-OUTPUT_LIMIT), stderr: settled.err.slice(-OUTPUT_LIMIT) }; + } catch (error) { + if (timer !== undefined) clearTimeout(timer); + try { child?.kill(); } catch { /* already gone */ } + return { code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) }; + } +}; + +function base64Utf8(value: string): string { + return Buffer.from(value, "utf8").toString("base64"); +} + +/** A filesystem path carried as data, never as PowerShell syntax. */ +function encodedPathExpression(path: string): string { + const encoded = base64Utf8(path); + if (!BASE64_PATTERN.test(encoded)) throw new Error("path encoding failed"); + return `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`; +} + +/** Interpolated constants must not carry a quote or wildcard that changes the script. */ +function psLiteral(value: string, label: string): string { + if (value.includes("'") || /[*?[\]]/.test(value)) throw new Error(`unsafe ${label} for script literal`); + return `'${value}'`; +} + +function markerOf(stdout: string): string | null { + const line = stdout + .split(/\r?\n/) + .map(entry => entry.trim()) + .find(entry => entry.startsWith(TRUST_MARKER)); + return line === undefined ? null : line.slice(TRUST_MARKER.length); +} + +function currentUserRootStore(): string { + return "New-Object Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser')"; +} + +function subjectLikePattern(): string { + if (PICKER_CA_COMMON_NAME.length === 0 || /['*?[\]]/.test(PICKER_CA_COMMON_NAME)) { + throw new Error("unsafe picker CA common name for script literal"); + } + return `'*CN=${PICKER_CA_COMMON_NAME}*'`; +} + +/** + * Inspect script: exact SHA-1 lookup in CurrentUser Root, persisted-leaf chain build + * against that root, plus DNS-name and validity checks. Emits exactly one marker + * line and exits 0 (trusted), 1 (untrusted) or 2 (error). Read-only: trust is never + * added or removed here; installation happens only on explicit activation via trust. + */ +export function buildWindowsTrustInspectScript(leafPath: string, wantSha1: string): string { + const want = normalizePickerTrustSha1(wantSha1); + if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters"); + if (leafPath.length === 0) throw new Error("picker trust leaf path must not be empty"); + const host = psLiteral(PICKER_HOST, "picker host"); + return [ + "$ErrorActionPreference='Stop'", + `$leafFile=${encodedPathExpression(leafPath)}`, + `$want=${psLiteral(want, "picker CA fingerprint")}`, + `$store=${currentUserRootStore()}`, + "$store.Open('ReadOnly')", + "try {", + "$found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want })", + `if($found.Count -eq 0) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$ca=$found[0]", + `if($ca.Subject -notlike ${subjectLikePattern()}) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$leaf=New-Object Security.Cryptography.X509Certificates.X509Certificate2($leafFile)", + "$dns=$leaf.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::DnsName,$false)", + `if($dns -ne ${host}) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$now=Get-Date", + `if($leaf.NotBefore -gt $now -or $leaf.NotAfter -lt $now) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + `if($leaf.Issuer -ne $ca.Subject) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$chain=New-Object Security.Cryptography.X509Certificates.X509Chain", + "$chain.ChainPolicy.RevocationMode=[Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck", + "$chain.ChainPolicy.VerificationFlags=[Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority", + "$chain.ChainPolicy.ExtraStore.Add($ca) | Out-Null", + "$built=$chain.Build($leaf)", + "$root=$null", + "if($chain.ChainElements.Count -gt 0) { $root=$chain.ChainElements[$chain.ChainElements.Count-1].Certificate }", + `if($built -and ($null -ne $root) -and ($root.Thumbprint -eq $want)) { Write-Output '${TRUST_MARKER}TRUSTED'; exit 0 }`, + `Write-Output '${TRUST_MARKER}UNTRUSTED'`, + "exit 1", + `} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 } finally { $store.Close() }`, + ].join(";"); +} + +/** + * Install script: imports the snapshot file into CurrentUser Root after re-checking + * its thumbprint inside the script. Adding to Root raises the OS confirmation + * dialog and that user approval is the install gate; the powershell.exe process + * itself stays hidden and only the OS dialog is ever visible. + */ +export function buildWindowsTrustInstallScript(caPath: string, wantSha1: string): string { + const want = normalizePickerTrustSha1(wantSha1); + if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters"); + if (caPath.length === 0) throw new Error("picker trust CA path must not be empty"); + return [ + "$ErrorActionPreference='Stop'", + `$caFile=${encodedPathExpression(caPath)}`, + `$want=${psLiteral(want, "picker CA fingerprint")}`, + "$cert=New-Object Security.Cryptography.X509Certificates.X509Certificate2($caFile)", + `if($cert.Thumbprint -ne $want) { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`, + `$store=${currentUserRootStore()}`, + "$store.Open('ReadWrite')", + "try {", + "$found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want })", + "if($found.Count -eq 0) { $store.Add($cert) }", + `Write-Output '${TRUST_MARKER}INSTALLED'`, + "exit 0", + `} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 } finally { $store.Close() }`, + ].join(";"); +} + +/** + * Remove script: deletes every Root entry with the exact thumbprint, then re-checks. + * Removal from Root raises OS confirmation like an install, so this runs on the + * interactive prefix with a bounded 30s budget: predecessor cleanup also runs in headless + * removal may prompt, mirroring the macOS keychain prompt on migration cleanup. + */ +export function buildWindowsTrustRemoveScript(wantSha1: string): string { + const want = normalizePickerTrustSha1(wantSha1); + if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters"); + return [ + "$ErrorActionPreference='Stop'", + `$want=${psLiteral(want, "picker CA fingerprint")}`, + "try {", + `$store=${currentUserRootStore()}`, + "$store.Open('ReadWrite')", + "try { $found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want }); foreach($c in $found) { $store.Remove($c) } } finally { $store.Close() }", + `$check=${currentUserRootStore()}`, + "$check.Open('ReadOnly')", + "try { $left=@($check.Certificates | Where-Object { $_.Thumbprint -eq $want });", + `if($left.Count -eq 0) { Write-Output '${TRUST_MARKER}REMOVED'; exit 0 }`, + `Write-Output '${TRUST_MARKER}ERROR'; exit 1 } finally { $check.Close() }`, + `} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`, + ].join(";"); +} + +function powershellArgs(script: string, interactive: boolean): string[] { + const prefix = interactive ? WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX : WINDOWS_TRUST_POWERSHELL_PREFIX; + return [...prefix, script]; +} + +export async function inspectPickerTrustWindows( + leafPath: string, + caSha1: string, + run?: WindowsTrustRunner, +): Promise { + if (normalizePickerTrustSha1(caSha1) === null || leafPath.length === 0) return "untrusted"; + const exec = run ?? defaultWindowsPowerShellRunner; + try { + const result = await exec(powershellArgs(buildWindowsTrustInspectScript(leafPath, caSha1), false), { + timeoutMs: WINDOWS_TRUST_INSPECT_TIMEOUT_MS, + }); + if (result.code === 0 && markerOf(result.stdout) === "TRUSTED") return "trusted"; + if (result.code === 1 && markerOf(result.stdout) === "UNTRUSTED") return "untrusted"; + return "unknown"; + } catch { + return "unknown"; + } +} + +export async function trustPickerCaWindows( + caPath: string, + run?: WindowsTrustRunner, + cert?: { pem?: string }, +): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> { + // The caller gates this on explicit activation; status and restart paths never call it. + // The installed bytes are always a private snapshot of the authority authorized here, + // never the shared ca.pem a same-user process could swap mid-flight. + const exec = run ?? defaultWindowsPowerShellRunner; + let privateDir: string | undefined; + try { + let pem = cert?.pem; + if (pem === undefined) pem = readFileSync(caPath, "utf8"); + if (!acceptsPickerAuthority(pem)) return { ok: false, reason: "declined_or_failed" }; + const { sha1 } = pickerCaFingerprints(pem); + privateDir = mkdtempSync(join(tmpdir(), "ocx-picker-ca-win-")); + const snapshot = join(privateDir, "ca.pem"); + writeFileSync(snapshot, pem, { mode: 0o600 }); + const result = await exec(powershellArgs(buildWindowsTrustInstallScript(snapshot, sha1), true), { + timeoutMs: WINDOWS_TRUST_IMPORT_TIMEOUT_MS, + }); + return result.code === 0 && markerOf(result.stdout) === "INSTALLED" + ? { ok: true } + : { ok: false, reason: "declined_or_failed" }; + } catch { + return { ok: false, reason: "declined_or_failed" }; + } finally { + if (privateDir !== undefined) rmSync(privateDir, { recursive: true, force: true }); + } +} + +export async function untrustPickerCaWindows( + fingerprintSha1: string, + run?: WindowsTrustRunner, +): Promise<{ ok: boolean }> { + const exec = run ?? defaultWindowsPowerShellRunner; + if (normalizePickerTrustSha1(fingerprintSha1) === null) return { ok: false }; + try { + const result = await exec(powershellArgs(buildWindowsTrustRemoveScript(fingerprintSha1), true), { + timeoutMs: WINDOWS_TRUST_UNTRUST_TIMEOUT_MS, + }); + return result.code === 0 && markerOf(result.stdout) === "REMOVED" ? { ok: true } : { ok: false }; + } catch { + return { ok: false }; + } +} diff --git a/src/claude/intercept/picker-trust.ts b/src/claude/intercept/picker-trust.ts index 48f4711ba0e..e407e90d6cf 100644 --- a/src/claude/intercept/picker-trust.ts +++ b/src/claude/intercept/picker-trust.ts @@ -2,6 +2,11 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "./picker-ca"; +import { + inspectPickerTrustWindows, + trustPickerCaWindows, + untrustPickerCaWindows, +} from "./picker-trust-windows"; /** Trust is scoped to the current root fingerprint and a verified persisted leaf. */ export type PickerTrustState = "trusted" | "untrusted" | "unsupported" | "unknown"; @@ -61,6 +66,11 @@ export async function inspectPickerTrust( run: SecurityRunner = defaultSecurityRunner, platform: NodeJS.Platform = process.platform, ): Promise { + if (platform === "win32") { + // The macOS default runner is never passed down: only an explicitly injected + // runner crosses over, otherwise the Windows default powershell.exe runner runs. + return inspectPickerTrustWindows(leafPath, caSha1, run === defaultSecurityRunner ? undefined : run); + } if (platform !== "darwin") return "unsupported"; const keychain = loginKeychainPath(); try { @@ -86,6 +96,9 @@ export async function trustPickerCa( platform: NodeJS.Platform = process.platform, cert?: { pem?: string }, ): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> { + if (platform === "win32") { + return trustPickerCaWindows(caPath, run === defaultSecurityRunner ? undefined : run, cert); + } if (platform !== "darwin") return { ok: false, reason: "unsupported" }; // `cert.pem` is the caller-verified certificate bytes: the shared on-disk ca.pem is writable by // same-user processes and could be swapped between fingerprint inspection and this install, so @@ -117,6 +130,9 @@ export async function untrustPickerCa( run: SecurityRunner = defaultSecurityRunner, platform: NodeJS.Platform = process.platform, ): Promise<{ ok: boolean }> { + if (platform === "win32") { + return untrustPickerCaWindows(fingerprintSha1, run === defaultSecurityRunner ? undefined : run); + } if (platform !== "darwin") return { ok: false }; const keychain = loginKeychainPath(); // Listed means the current picker CA is in the login keychain; unlisted means nothing to remove, diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 7f0d3692243..b0d562df939 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -1,4 +1,5 @@ import type { Server } from "bun"; +import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { OcxConfig } from "../../types"; import { getConfigDir } from "../../config/paths"; import type { DesktopPickerController } from "../desktop-picker"; @@ -28,7 +29,7 @@ import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./setti * proxy. Desktop also hands that proxy to the Claude Code processes it spawns, so the choice is per * client: Claude Code (no User-Agent on CONNECT) trusts only the intercept CA and gets the * api.anthropic.com intercept and nothing else; the app itself (a browser User-Agent) trusts only - * the login keychain and never meets the api.anthropic.com intercept, and only its claude.ai + * the OS root store and never meets the api.anthropic.com intercept, and only its claude.ai * tunnels may be terminated by the picker runtime (src/claude/intercept/picker-runtime.ts). */ @@ -144,7 +145,7 @@ export interface StartClaudeInterceptOptions { createPicker?: (options: CreatePickerRuntimeOptions) => PickerRuntime; /** Test seam: bind real CONNECT handlers on kernel-assigned ports without probe-and-release races. */ startProxy?: typeof startConnectProxy; - /** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */ + /** Test seams: the platform trust runner and platform for the picker runtime and controller. */ pickerCaStore?: PickerCaStore; pickerSecurity?: SecurityRunner; pickerPlatform?: NodeJS.Platform; @@ -211,7 +212,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio let pickerReason: ClaudeInterceptState["pickerReason"] = null; let pickerFailurePort: number | undefined; try { - if (options.loadPickerRoutes && (options.pickerPlatform ?? process.platform) === "darwin") { + if (options.loadPickerRoutes && supportsDesktopPicker(options.pickerPlatform ?? process.platform)) { // Remove any legacy plaintext key before inspecting or activating picker state. discardPickerCaKey(configDir); const { inspectDesktopPickerProfile } = await import("../desktop-picker-profile"); diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 1f0140457c3..e48f6a8f074 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -634,7 +634,7 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [], mutates: true, json: "none", - details: ["Needs a running proxy, first-party mode and macOS. The first time, macOS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal.","Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards."], + details: ["Needs a running proxy, first-party mode and macOS or Windows. The first time, the OS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal.","Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards."], usage: "ocx claude desktop picker on", }, { @@ -649,7 +649,7 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["claude","desktop","picker","trust"], - summary: "Run the macOS keychain step for picker mode in this terminal, then ask the server to finish enabling it.", + summary: "Run the OS certificate trust step for picker mode in this terminal, then ask the server to finish enabling it.", routes: [{"method":"GET","path":"/api/claude-desktop/picker"},{"method":"PUT","path":"/api/claude-desktop/picker"}], flags: [], mutates: true, diff --git a/src/server/management/claude-desktop-picker-routes.ts b/src/server/management/claude-desktop-picker-routes.ts index 337746e8494..59b7306767c 100644 --- a/src/server/management/claude-desktop-picker-routes.ts +++ b/src/server/management/claude-desktop-picker-routes.ts @@ -44,7 +44,7 @@ export function pickerPreferenceOn(config: Pick): boole } const PICKER_BODY_KEYS = new Set(["enabled", "persist", "trustedLocally", "callerAddedTrust"]); -/** Enable outcomes that are progress, not refusal: on, waiting for a Desktop restart, or for the keychain step. */ +/** Enable outcomes that are progress, not refusal: on, waiting for a Desktop restart, or for certificate trust. */ const ENABLE_ACCEPTED = new Set(["active", "restart_required", "trust_pending"]); interface PickerRequestBody { enabled: boolean; persist: boolean; trustedLocally?: boolean; callerAddedTrust?: boolean } diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 9079441d769..f2a0b13f494 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -665,7 +665,7 @@ function pickerStateNote(picker: DesktopPickerStatus): string { return "Picker mode is on: fully quit and reopen Claude Desktop to see OpenCodex models in the Code tab."; } if (picker.reason === "trust_pending" || picker.reason === "trust_declined") { - return `Picker mode is waiting for the keychain step: run ${picker.hint ?? "ocx claude desktop picker trust"}.`; + return `Picker mode is waiting for certificate trust: run ${picker.hint ?? "ocx claude desktop picker trust"}.`; } return `Picker mode is off (${picker.reason}).`; } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index bab6350ed3d..dbe116a99b2 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -117,7 +117,7 @@ and the auto-detect (WPAD) flag in `Connections\DefaultConnectionSettings`, and a bypass, no covering proxy, or an undecidable PAC script or WPAD. A failed registry read is reported as unreadable, never as an absent value, and a stale settings env never earns an `ok`. It never prints the proxy value and never records a doctor failure, because the CLI and other clients -still route. +still route. When picker mode is armed, Desktop uses its app-owned fixed egress proxy instead of this system-proxy path; the manual bypass observation above remains for picker-off or picker-pending installs. Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; `ocx claude config set --first-party on|off` and the Claude Code page switch control the CLI intent; `ocx ensure` refreshes a stale or absent env while it is on. @@ -182,14 +182,14 @@ Existing Claude consumers omit this option and retain blind forwarding; it enabl Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo. The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. -On macOS, when the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also +On macOS and Windows, when the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also wires Claude Desktop picker mode: a second loopback CONNECT proxy on the dedicated picker proxy port (`getClaudeInterceptState()?.pickerProxyPort`), used as Desktop's pinned egress proxy. Desktop also hands that proxy to the Claude Code processes it spawns, and the two trust different CAs, so the tunnel is chosen per client from the CONNECT head: a tunnel without a browser User-Agent (Claude Code, trusting only the intercept CA) gets the `api.anthropic.com` intercept and every other target blind, never the picker; a tunnel with Chromium's `Mozilla/` User-Agent (the app, trusting only the -login keychain) is asked of the picker runtime (`src/claude/intercept/picker-runtime.ts`), which +login keychain on macOS or the current-user Root store on Windows) is asked of the picker runtime (`src/claude/intercept/picker-runtime.ts`), which blind-tunnels every target except `claude.ai:443`. Production always uses the configured adjacent ports. Lifecycle tests inject only the CONNECT factory and bind the real handlers on kernel-assigned ports; this preserves request handling while @@ -200,9 +200,9 @@ any local process already reaches (the `api.anthropic.com` intercept is on the C too; the `claude.ai` relay verifies upstream and adds no credential) and breaks only its own TLS, because each terminator presents a certificate only its intended client trusts. `claude.ai:443` is intercepted only while the runtime's cached -decision is armed: macOS, persisted resolved Desktop mode first-party, Desktop intent on, +decision is armed: macOS or Windows, persisted resolved Desktop mode first-party, Desktop intent on, `claudeCode.intercept.picker !== false`, no disarm latch, listener up, and the current picker CA -trusted in the login keychain (`picker-trust.ts`). A loopback TCP front in `picker-listener.ts` +trusted in the OS store (`picker-trust.ts`): the login keychain on macOS, the current-user Root store on Windows by exact fingerprint with a verified leaf. A loopback TCP front in `picker-listener.ts` reads ClientHello ALPN through `src/claude/intercept/client-hello.ts`, reassembling across TCP splits and up to 16 TLS records within 64 KiB of wire bytes and a 10-second deadline. It splices the untouched connection to an HTTP/2 server when the client offers `h2`, or to the native @@ -219,11 +219,11 @@ name constraints permitting only `claude.ai` and excluding every IPv4 and IPv6 a signing identity is protected by the OS credential store and scoped to the canonical config directory; normal restarts reuse the same validated certificate and key. No plaintext picker signing key is stored in that directory; public certificates and non-secret identity metadata remain under -`/claude-picker/`. Every unbound intercept startup attempt makes a best-effort cleanup of legacy `ca.key` before eligibility checks, including client role, disabled routing/interception, and ephemeral public ports; cleanup failures do not block startup. See the [runtime lifecycle contract](../runtime.md#claude-intercept-pair). Windows and Linux skip picker CA, credential-store and proxy construction entirely; the main intercept pair remains available. +`/claude-picker/`. Every unbound intercept startup attempt makes a best-effort cleanup of legacy `ca.key` before eligibility checks, including client role, disabled routing/interception, and ephemeral public ports; cleanup failures do not block startup. See the [runtime lifecycle contract](../runtime.md#claude-intercept-pair). Linux skips picker CA, credential-store and proxy construction entirely; the main intercept pair remains available. On restart the lifecycle keeps the applied profile and restores through the controller with `allowTrustPrompt: false`. An unchanged approved identity with an available credential store needs -no Certificate Trust Settings add/remove operation. Missing, revoked or unknown trust leaves the -picker pending; restore never installs trust. Explicit `on` or `trust` completes the trust step. +no macOS Certificate Trust Settings add/remove operation. Missing, revoked or unknown trust leaves the +picker pending; restart and status never install trust. Explicit `on`, `trust`, or first-party apply completes the trust step, including the Windows current-user Root confirmation when needed. Legacy predecessor cleanup may still require consent during migration. Native keychain unlock and application-access dialogs are controlled by macOS; restart or upgrade does not guarantee their absence. `picker-ca-store.ts` owns the versioned OS credential service, canonical-config identity namespace, @@ -234,13 +234,13 @@ predecessor before writing the credential, verifies readback, then commits metad it removes the journal last. Recovery requires matching journal/store identity; missing initialized credentials, unavailable storage or inconsistent metadata fail closed without publishing a replacement. Gateway/off startup does not read or initialize an OS picker credential unless an applied picker -profile needs recovery. Its dormant macOS runtime/controller remains available for later explicit +profile needs recovery. Its dormant macOS or Windows runtime/controller remains available for later explicit activation, which uses the same persistent authority path. -Trust is added without a policy string: Chromium +On macOS trust is added without a policy string: Chromium skips host-scoped trust settings, so `inspectPickerTrust` treats a current CA whose exported user trust settings carry `kSecTrustSettingsPolicyString` as untrusted and an explicit trust step replaces it; an -export it cannot read makes trust `unknown`, which never arms. A rotated-out picker certificate is -removed from the login keychain as its replacement is published, and a failed removal stops the +export it cannot read makes trust `unknown`, which never arms. On Windows trust is the current-user Root store entry matching the exact fingerprint with a verified leaf. A rotated-out picker certificate is +removed from the login keychain on macOS or the current-user Root store on Windows as its replacement is published, and a failed removal stops the picker arming. Publication of `ca.pem` and `ca-owner.json` happens only inside the `ca.lock.sqlite` lock (`picker-ca.ts`): lock acquisition is reported separately from the callback, so a busy lock publishes nothing, and a missing or mismatched owner record for our own @@ -303,11 +303,11 @@ enable attempt added trust and a later check or profile write fails, it removes an earlier successful picker profile keeps the trust it needs. The owned profile helpers in `src/claude/desktop-picker-profile.ts` use the standard row `opencodex-picker`, whose file contains only `egressProxyUrl`. The previous Desktop selection is stored in -`/claude-picker/profile-state.json`, never in Desktop's `_meta.json`. +`/claude-picker/profile-state.json`, never in Desktop's `_meta.json`. On Windows the same row is written through the existing `%LOCALAPPDATA%` configLibrary writer. -The local controls are `ocx claude desktop picker on|off|status|trust`. With a live server, `on`, -`off`, and transition cleanup use the controller; `trust` performs the operator's local keychain -step and then reports the result to the server. Before installing the root, the CLI independently +The local controls are `ocx claude desktop picker on|off|status|trust`; CLI help, toggle messages and GUI trust states use OS-neutral certificate-trust wording. With a live server, `on`, +`off`, and transition cleanup use the controller; `trust` performs the operator's local OS trust +step (login keychain on macOS, current-user Root confirmation on Windows) and then reports the result to the server. Before installing the root, the CLI independently requires the picker common name on a self-signed CA and the exact critical `claude.ai`-only DNS and all-IP exclusion constraints, plus the full minted extension profile — critical `CA:TRUE` basicConstraints, a `keyCertSign|cRLSign`-only keyUsage, a non-critical subjectKeyIdentifier, and diff --git a/structure/runtime.md b/structure/runtime.md index e33e717b2b0..8b2516d1d60 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -252,7 +252,7 @@ still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.c listener, relays every other CONNECT target blind, and refuses unauthenticated clients, plain proxied HTTP, and loopback targets. The per-install proxy token is stored owner-only under `/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it. The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream. One relayed path is extended: for the standalone CLI's picker catalog, the listener's `cliCatalog` hook (`src/claude/intercept/cli-catalog.ts`) merges opencodex rows into `GET /api/organizations//model_selector/cc` (or `/api/model_selector/cc`), touching only the `cc` surface and leaving Desktop's `ccd`/`code` surfaces to picker mode, and into the fallback `additional_model_options` of `GET /api/claude_cli/bootstrap`. Eligibility: the `cc` catalog is rewritten only for a CLI-classified `User-Agent` with `claudeCode.cliFirstParty` on, because Claude Code caches it on disk for every process and a Desktop-spawned process must not seed rows a plain `claude` cannot route; the bootstrap request carries no entrypoint and follows CLI intent alone. Rows come from `buildCliPickerModels` (`src/claude/intercept/picker-models.ts`), which uses Desktop 3P registry aliases because the CLI offers only Claude-shaped ids (an `ocx-claude-*` row renders "Update Claude Code"), and advertises only an alias the registry decodes back to the same route. `src/claude/intercept/cli-picker.ts` supplies them lazily on the first eligible request, persists the snapshot at `/claude-intercept/cli-picker-models.json`, re-checks each persisted row against the live registry (its alias must still decode to its recorded route; a cold registry after a restart is awaited first, and a snapshot whose rows still do not decode, for example after a provider change, is rebuilt within the same bound), and bounds a cold build at 2.5 s while awaiting the shared registry build in `src/claude/desktop-3p-startup.ts` that startup also uses (a failed or empty on-demand build is not retried for 30 s). Both CLI catalogs obey the shared [picker rewrite bounds](clients/claude-desktop.md#picker-catalog-rewrite-bounds), preflighting row growth before copying and keeping the final JSON within 16 MiB. The rewrite fails open: a provider error, non-2xx status, unparsable body, or no added row relays the upstream bytes unchanged; a body over the 16 MiB cap is cut off while streaming and answered 502 rather than buffered. Claude Code trusts its cached `/cache/model-catalog/*-cc.json` for about an hour, so `writeSettings` in `src/claude/intercept/settings.ts` and every successful `reconcileClaudeFirstPartySettings` drop those files (leaving Desktop's `*-ccd.json`); running sessions keep their list until restarted. The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). The lifecycle records bind failure, and stop joins both sockets. Its serialized `ensure()` joins pending startup, reuses the live config and dispatch wrapper, and can start a previously unbound pair. `stop()` refuses future ensure calls and awaits an in-flight start. Local management applies and `ocx claude intercept start` (`POST /api/claude-intercept/start`) use this seam through `src/server/index/optional-listeners.ts`; hub-management ingress and data-plane credentials cannot start interception. Status reports `interceptReason` (with `interceptFailurePort` for a CONNECT bind failure) and optional `pickerReason` (with `pickerFailurePort` when the Desktop picker relay port is occupied), distinguishing disabled routing, client role, ephemeral public ports, occupied CONNECT ports and other failures. A changed configured port never rebinds a running pair: apply reports `port_mismatch` with both ports. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required. The lifecycle makes a best-effort legacy picker-key cleanup attempt before checking client role, disabled routing/interception, or an ephemeral public port. Each new unbound lifecycle `ensure()` attempt retries cleanup; management requests rejected before that seam do not invoke it. Cleanup failures do not change the eligibility result or block startup. Direct `startClaudeIntercept` callers retain the same cleanup defense. Mock-only regression coverage lives in `tests/claude-integration/claude-intercept-cleanup.test.ts`. -This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). The macOS picker reuses an exportable signing identity protected by the OS credential store across normal restarts, without a plaintext key in the config directory. Restore verifies the constrained CA and current OS trust without installing trust; explicit picker `on` or `trust` grants missing or revoked trust. Legacy migration cleanup may still require consent, and native keychain unlock or application-access prompts are separate. Windows and Linux start no picker CA, credential-store or proxy work; the main intercept pair and best-effort legacy-key cleanup remain available. +This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). The macOS and Windows picker reuses an exportable signing identity protected by the OS credential store across normal restarts, without a plaintext key in the config directory. Restore verifies the constrained CA and current OS trust without installing trust; explicit picker `on` or `trust` completes the missing trust step, including the Windows confirmation when needed. Legacy migration cleanup may still require consent, and native macOS keychain unlock or application-access prompts are separate. Linux starts no picker CA, credential-store or proxy work; the main intercept pair and best-effort legacy-key cleanup remain available. `src/claude/intercept/client-class.ts` classifies each request by its Claude Code `User-Agent` entrypoint: `claude-desktop`, `claude-desktop-3p`, and `local-agent` are Desktop; other well-formed `claude-cli/ (external, )` values are CLI; absent or malformed values are unknown. Only a client with its own first-party intent enabled (Desktop mode or `claudeCode.cliFirstParty`) enters the router for Messages paths; other paths use the configured upstream relay. diff --git a/tests/claude-integration/claude-desktop-picker-routes.test.ts b/tests/claude-integration/claude-desktop-picker-routes.test.ts index fd60e6f2b0f..3db427d6b29 100644 --- a/tests/claude-integration/claude-desktop-picker-routes.test.ts +++ b/tests/claude-integration/claude-desktop-picker-routes.test.ts @@ -234,7 +234,7 @@ describe("without a running picker controller", () => { // Off macOS the offline status says why first: picker mode is macOS-only. expect(status.body.picker).toMatchObject({ effective: false, - reason: process.platform === "darwin" ? "proxy_unavailable" : "unsupported_platform", + reason: process.platform === "darwin" || process.platform === "win32" ? "proxy_unavailable" : "unsupported_platform", }); const on = await put({ enabled: true, persist: true }); expect(on.status).toBe(503); diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index 75c90ae72b4..0bf6303053f 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -102,11 +102,11 @@ afterEach(async () => { }); describe("pickerDesired", () => { - test("needs macOS, first-party, Desktop intent and no explicit picker off", () => { + test("needs a supported platform, first-party, Desktop intent and no explicit picker off", () => { expect(pickerDesired(firstParty(), "first-party", "darwin")).toBe(true); expect(pickerDesired(firstParty(), "gateway", "darwin")).toBe(false); expect(pickerDesired(firstParty(), "first-party", "linux")).toBe(false); - expect(pickerDesired(firstParty(), "first-party", "win32")).toBe(false); + expect(pickerDesired(firstParty(), "first-party", "win32")).toBe(true); expect(pickerDesired(firstParty({ claudeCode: { desktopMode: "first-party", intercept: { picker: false } } }), "first-party", "darwin")).toBe(false); expect(pickerDesired(firstParty({ claudeCode: { desktopMode: "first-party", intercept: { picker: true } } }), "first-party", "darwin")).toBe(true); }); diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index 9603173247e..923f76811d9 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -41,8 +41,8 @@ function setup() { // blocks the loop, so each multi-child test's own bound covers all of its children. const PICKER_CHILD_TIMEOUT_MS = watchdogMs(10_000); -test("Windows and Linux skip all picker effects while still discarding legacy key", async () => { - for (const platform of ["linux", "win32"] as const) { +test("Linux skips all picker effects while still discarding legacy key", async () => { + for (const platform of ["linux"] as const) { const { root, config } = setup(); config.claudeCode!.desktopMode = "first-party"; mkdirSync(join(root, "claude-picker")); diff --git a/tests/claude-integration/claude-picker-trust-windows.test.ts b/tests/claude-integration/claude-picker-trust-windows.test.ts new file mode 100644 index 00000000000..a86ba8fb41c --- /dev/null +++ b/tests/claude-integration/claude-picker-trust-windows.test.ts @@ -0,0 +1,239 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; +import { + PICKER_CA_COMMON_NAME, + PICKER_HOST, + pickerCaFingerprints, +} from "../../src/claude/intercept/picker-ca"; +import { + inspectPickerTrust, + trustPickerCa, + untrustPickerCa, +} from "../../src/claude/intercept/picker-trust"; +import { + buildWindowsTrustInspectScript, + buildWindowsTrustInstallScript, + buildWindowsTrustRemoveScript, + defaultWindowsPowerShellRunner, + inspectPickerTrustWindows, + normalizePickerTrustSha1, + sanitizeWindowsPowerShellEnv, + trustPickerCaWindows, + untrustPickerCaWindows, + WINDOWS_TRUST_IMPORT_TIMEOUT_MS, + WINDOWS_TRUST_INSPECT_TIMEOUT_MS, + WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX, + WINDOWS_TRUST_POWERSHELL_PREFIX, + WINDOWS_TRUST_UNTRUST_TIMEOUT_MS, + type WindowsTrustResult, + type WindowsTrustRunner, +} from "../../src/claude/intercept/picker-trust-windows"; + +// Mutations use fake runners; the native parser case only parses scripts, never executing them. +const SHA1 = "AB".repeat(20); +const LEAF = "C:\\ocx-test\\leaf.pem"; + +function result(stdout: string, code: number | null): WindowsTrustResult { + return { code, stdout, stderr: "" }; +} + +interface Captured { + run: WindowsTrustRunner; + argv: string[][]; + timeouts: Array; +} + +function capture(...results: WindowsTrustResult[]): Captured { + const argv: string[][] = []; + const timeouts: Array = []; + const run: WindowsTrustRunner = async (args, options) => { + argv.push([...args]); + timeouts.push(options?.timeoutMs); + return results.shift() ?? result("", 2); + }; + return { run, argv, timeouts }; +} + +function scriptOf(call: string[], prefix: readonly string[]): string { + expect(call.slice(0, -1)).toEqual([...prefix]); + const script = call.at(-1) ?? ""; + expect(script.length).toBeGreaterThan(0); + return script; +} + +function decodedPaths(script: string): string[] { + const found: string[] = []; + const pattern = /FromBase64String\(\x27([^\x27]+)\x27\)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(script)) !== null) { + found.push(Buffer.from(match[1]!, "base64").toString("utf8")); + } + return found; +} + +function assertScriptShape(script: string, embeddedPaths: string[]): void { + expect(script).toContain("X509Store('Root','CurrentUser')"); + expect(script).not.toContain("LocalMachine"); + expect(script).not.toContain("Import-Certificate"); + expect(decodedPaths(script)).toEqual(embeddedPaths); +} + +test.skipIf(process.platform !== "win32")("generated trust scripts parse in Windows PowerShell without executing store changes", async () => { + const path = "C:\\한글 'quoted'\\$certificate.pem"; + const scripts = [buildWindowsTrustInspectScript(path, SHA1), buildWindowsTrustInstallScript(path, SHA1), buildWindowsTrustRemoveScript(SHA1)]; + const literals = scripts.map(script => `'${Buffer.from(script).toString("base64")}'`).join(","); + const parser = `$sources=@(${literals});foreach($source in $sources){$tokens=$null;$errors=$null;`+ + "$script=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($source));"+ + "[System.Management.Automation.Language.Parser]::ParseInput($script,[ref]$tokens,[ref]$errors) | Out-Null;"+ + "if($errors.Count -gt 0){exit 1}};exit 0"; + const parsed = await defaultWindowsPowerShellRunner([...WINDOWS_TRUST_POWERSHELL_PREFIX, parser]); + expect(parsed.code).toBe(0); +}, 30_000); + +test("windows inspection trusts only an exact thumbprint match with a chain marker", async () => { + const captured = capture(result("OCX_PICKER_TRUST=TRUSTED\n", 0)); + expect(await inspectPickerTrustWindows(LEAF, SHA1, captured.run)).toBe("trusted"); + expect(captured.argv).toHaveLength(1); + expect(captured.timeouts).toEqual([WINDOWS_TRUST_INSPECT_TIMEOUT_MS]); + const script = scriptOf(captured.argv[0]!, WINDOWS_TRUST_POWERSHELL_PREFIX); + assertScriptShape(script, [LEAF]); + expect(script).toContain(SHA1); + expect(script).toContain(PICKER_HOST); + expect(script).toContain("Thumbprint -eq"); +}); + +test("windows inspection is a single read-only call that never installs", async () => { + const captured = capture(result("OCX_PICKER_TRUST=UNTRUSTED\n", 1)); + expect(await inspectPickerTrustWindows(LEAF, SHA1, captured.run)).toBe("untrusted"); + expect(captured.argv).toHaveLength(1); + const script = scriptOf(captured.argv[0]!, WINDOWS_TRUST_POWERSHELL_PREFIX); + expect(script).not.toContain("ReadWrite"); + expect(script).toContain("ReadOnly"); +}); + +test("windows inspection maps errors and mismatched markers to unknown", async () => { + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("OCX_PICKER_TRUST=ERROR\n", 2)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("OCX_PICKER_TRUST=TRUSTED\n", 1)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("OCX_PICKER_TRUST=UNTRUSTED\n", 0)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("noise\n", 0)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, async () => { throw new Error("spawn failed"); })).toBe("unknown"); +}); + +test("windows inspection rejects malformed fingerprints without spawning", async () => { + const run: WindowsTrustRunner = async () => { throw new Error("runner must stay idle"); }; + for (const bad of ["", "xyz", "AB".repeat(19), `${SHA1}ZZ`, "A".repeat(41)]) { + expect(await inspectPickerTrustWindows(LEAF, bad, run)).toBe("untrusted"); + } + expect(normalizePickerTrustSha1("ab:".repeat(20).slice(0, -1))).toBe(SHA1); + expect(normalizePickerTrustSha1("nope")).toBeNull(); +}); + +test("powershell environment drops module paths case-insensitively", () => { + const env = sanitizeWindowsPowerShellEnv({ + PSModulePath: "C:\\mods", + psmodulepath: "C:\\mods2", + PSMODULEPATH: "C:\\mods3", + Path: "C:\\bin", + } as NodeJS.ProcessEnv); + expect(env).toEqual({ Path: "C:\\bin" }); +}); + +function mintPickerCa(commonName: string): string { + return createCertificateAuthority({ commonName, permittedDnsNames: [PICKER_HOST] }).certPem; +} + +test("windows trust installs a private snapshot of the authorized bytes", async () => { + const pem = mintPickerCa(PICKER_CA_COMMON_NAME); + const { sha1 } = pickerCaFingerprints(pem); + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-shared-")); + const shared = join(dir, "ca.pem"); + writeFileSync(shared, pem); + let script = ""; + let installedPath = ""; + let installedContent = ""; + const timeouts: Array = []; + const run: WindowsTrustRunner = async (args, options) => { + timeouts.push(options?.timeoutMs); + script = scriptOf(args, WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX); + const [snapshot] = decodedPaths(script); + installedPath = snapshot ?? ""; + installedContent = readFileSync(installedPath, "utf8"); + return result("OCX_PICKER_TRUST=INSTALLED\n", 0); + }; + try { + expect(await trustPickerCaWindows(shared, run, { pem })).toEqual({ ok: true }); + expect(timeouts).toEqual([WINDOWS_TRUST_IMPORT_TIMEOUT_MS]); + expect(installedPath).not.toBe(shared); + expect(installedContent).toBe(pem); + expect(script).toContain(sha1); + assertScriptShape(script, [installedPath]); + expect(existsSync(installedPath)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("windows trust falls back to the ca file when no snapshot bytes are given", async () => { + const pem = mintPickerCa(PICKER_CA_COMMON_NAME); + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-fallback-")); + const caPath = join(dir, "ca.pem"); + writeFileSync(caPath, pem); + try { + const run: WindowsTrustRunner = async () => result("OCX_PICKER_TRUST=INSTALLED\n", 0); + expect(await trustPickerCaWindows(caPath, run)).toEqual({ ok: true }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("windows trust refuses foreign authorities and malformed input without spawning", async () => { + const run: WindowsTrustRunner = async () => { throw new Error("runner must stay idle"); }; + expect(await trustPickerCaWindows(LEAF, run, { pem: mintPickerCa("unrelated root") })) + .toEqual({ ok: false, reason: "declined_or_failed" }); + expect(await trustPickerCaWindows(LEAF, run, { pem: "not a certificate" })) + .toEqual({ ok: false, reason: "declined_or_failed" }); + expect(await trustPickerCaWindows("C:\\definitely\\missing\\ca.pem", run)) + .toEqual({ ok: false, reason: "declined_or_failed" }); + expect(await trustPickerCaWindows(LEAF, async () => result("", 1), { pem: mintPickerCa(PICKER_CA_COMMON_NAME) })) + .toEqual({ ok: false, reason: "declined_or_failed" }); +}); + +test("windows untrust removes by exact thumbprint and reports failures", async () => { + const removed = capture(result("OCX_PICKER_TRUST=REMOVED\n", 0)); + expect(await untrustPickerCaWindows(SHA1, removed.run)).toEqual({ ok: true }); + expect(removed.timeouts).toEqual([WINDOWS_TRUST_UNTRUST_TIMEOUT_MS]); + const script = scriptOf(removed.argv[0]!, WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX); + assertScriptShape(script, []); + expect(script).toContain(SHA1); + expect(script).toContain("Thumbprint -eq"); + expect(await untrustPickerCaWindows(SHA1, capture(result("OCX_PICKER_TRUST=ERROR\n", 1)).run)) + .toEqual({ ok: false }); + const idle: WindowsTrustRunner = async () => { throw new Error("runner must stay idle"); }; + expect(await untrustPickerCaWindows("garbage", idle)).toEqual({ ok: false }); +}); + +test("generated scripts carry no module dependency and reject bad inputs", () => { + assertScriptShape(buildWindowsTrustInspectScript(LEAF, SHA1), [LEAF]); + assertScriptShape(buildWindowsTrustInstallScript(LEAF, SHA1), [LEAF]); + assertScriptShape(buildWindowsTrustRemoveScript(SHA1), []); + expect(() => buildWindowsTrustInspectScript("", SHA1)).toThrow(); + expect(() => buildWindowsTrustInspectScript(LEAF, "bad")).toThrow(); + expect(() => buildWindowsTrustInstallScript(LEAF, "bad")).toThrow(); + expect(() => buildWindowsTrustRemoveScript("bad")).toThrow(); +}); + +test("the shared entry point dispatches win32 to the windows adapter", async () => { + const trusted = capture(result("OCX_PICKER_TRUST=TRUSTED\n", 0)); + expect(await inspectPickerTrust(LEAF, SHA1, trusted.run, "win32")).toBe("trusted"); + expect(trusted.argv[0]!.slice(0, 4)).toEqual([...WINDOWS_TRUST_POWERSHELL_PREFIX]); + const installed = capture(result("OCX_PICKER_TRUST=INSTALLED\n", 0)); + expect(await trustPickerCa(LEAF, installed.run, "win32", { pem: mintPickerCa(PICKER_CA_COMMON_NAME) })) + .toEqual({ ok: true }); + expect(installed.argv[0]!.slice(0, -1)).toEqual([...WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX]); + const removed = capture(result("OCX_PICKER_TRUST=REMOVED\n", 0)); + expect(await untrustPickerCa(LEAF, SHA1, removed.run, "win32")).toEqual({ ok: true }); + expect(removed.argv[0]!.slice(0, -1)).toEqual([...WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX]); +}); diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts new file mode 100644 index 00000000000..fff9351872f --- /dev/null +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -0,0 +1,412 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer as createHttpsServer } from "node:https"; +import { connect } from "node:net"; +import { connect as tlsConnect, type TLSSocket } from "node:tls"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { supportsDesktopPicker } from "../../src/claude/desktop-picker-platform"; +import { inspectDesktopPickerProfile } from "../../src/claude/desktop-picker-profile"; +import { createPickerRuntime, pickerDesired } from "../../src/claude/intercept/picker-runtime"; +import { startPickerListener } from "../../src/claude/intercept/picker-listener"; +import { claudeInterceptCaCertPath, createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; +import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; +import { isBrowserConnect } from "../../src/claude/intercept/connect-proxy"; +import { pickerCaCertPath, pickerCaFingerprints } from "../../src/claude/intercept/picker-ca"; +import { + getClaudePickerController, + getClaudePickerRuntime, + startClaudeIntercept, +} from "../../src/claude/intercept/runtime"; +import type { SecurityResult, SecurityRunner } from "../../src/claude/intercept/picker-trust"; +import { filePickerCaStore, memoryPickerCaStore } from "../helpers/picker-ca-store"; +import type { OcxConfig } from "../../src/types"; + +const roots: string[] = []; +const handles: Array>> = []; +const priorHome = process.env.OPENCODEX_HOME; +const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; +const priorClaude = process.env.CLAUDE_CONFIG_DIR; + +afterEach(async () => { + for (const handle of handles.splice(0)) await handle?.stop(); + for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = priorHome; + if (priorDesktop === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = priorDesktop; + if (priorClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = priorClaude; +}); + +const routes = async () => ({ nativeSlugs: [], routedModels: [] }); + +function setup(firstParty = true) { + const root = mkdtempSync(join(tmpdir(), "ocx-picker-win-")); + roots.push(root); + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); + const config = { + port: 10100, + providers: {}, + defaultProvider: "openai", + claudeCode: { desktopMode: firstParty ? "first-party" : "gateway", intercept: { port: 10200 } }, + } as OcxConfig; + saveConfig(config); + return { root, config }; +} + +// Synthetic Windows trust matching src/claude/intercept/picker-trust-windows.ts. +// The runner receives powershell argv (exe resolved by the default runner, so +// argv begins with -NoLogo): inspect uses the read-only prefix plus a script +// built by buildWindowsTrustInspectScript (X509Store CurrentUser Root exact +// thumbprint lookup, OCX_PICKER_TRUST=TRUSTED with exit 0 or UNTRUSTED with +// exit 1); install uses the interactive prefix plus $store.Add with +// OCX_PICKER_TRUST=INSTALLED; removal uses $store.Remove with +// OCX_PICKER_TRUST=REMOVED. The fake answers exactly that format and never +// spawns a process or touches a real store. +function windowsTrust(state: { trusted: boolean }) { + const calls: string[][] = []; + const mutations: string[] = []; + const run: SecurityRunner = async (args: readonly string[]) => { + calls.push([...args]); + if (args[0] !== "-NoLogo") throw new Error("win32 trust argv must begin with -NoLogo, got: " + String(args[0])); + if (!args.includes("-Command")) throw new Error("win32 trust argv must include -Command"); + const script = String(args[args.length - 1] ?? ""); + if (!script.includes("OCX_PICKER_TRUST=")) throw new Error("win32 trust script must emit OCX_PICKER_TRUST="); + if (!script.includes("X509Store")) throw new Error("win32 trust script must use X509Store"); + if (script.includes("$store.Add")) { + state.trusted = true; + mutations.push("add"); + return { code: 0, stdout: "OCX_PICKER_TRUST=INSTALLED\n", stderr: "" } as SecurityResult; + } + if (script.includes("$store.Remove")) { + state.trusted = false; + mutations.push("remove"); + return { code: 0, stdout: "OCX_PICKER_TRUST=REMOVED\n", stderr: "" } as SecurityResult; + } + if (state.trusted) { + return { code: 0, stdout: "OCX_PICKER_TRUST=TRUSTED\n", stderr: "" } as SecurityResult; + } + return { code: 1, stdout: "OCX_PICKER_TRUST=UNTRUSTED\n", stderr: "" } as SecurityResult; + }; + return { run, calls, mutations, state }; +} + +test("win32 is a supported picker platform with first-party desired", () => { + expect(supportsDesktopPicker("win32")).toBe(true); + expect(supportsDesktopPicker("darwin")).toBe(true); + expect(supportsDesktopPicker("linux")).toBe(false); + const firstParty = { claudeCode: { desktopMode: "first-party" } } as OcxConfig; + expect(pickerDesired(firstParty, "first-party", "win32")).toBe(true); + expect(pickerDesired(firstParty, "first-party", "linux")).toBe(false); + expect(pickerDesired(firstParty, "gateway", "win32")).toBe(false); +}); + +test("Windows startup wires the picker proxy and controller while discarding legacy key", async () => { + const { root, config } = setup(true); + mkdirSync(join(root, "claude-picker"), { recursive: true }); + writeFileSync(join(root, "claude-picker", "ca.key"), "legacy-test-key"); + const trust = windowsTrust({ trusted: true }); + const fake = memoryPickerCaStore(); + let binds = 0; + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => { binds++; return startConnectProxy(0, options); }, + }); + handles.push(handle); + expect(existsSync(join(root, "claude-picker", "ca.key"))).toBe(false); + expect(binds).toBe(2); + expect(handle?.pickerProxyPort).not.toBeNull(); + expect(getClaudePickerController()).not.toBeNull(); + expect(getClaudePickerRuntime()).not.toBeNull(); + const runtime = getClaudePickerRuntime()!; + expect(runtime.status()).toMatchObject({ supported: true, desired: true }); + for (const call of trust.calls) { + expect(call[0]).toBe("-NoLogo"); + expect(call).toContain("-Command"); + expect(String(call[call.length - 1])).toContain("OCX_PICKER_TRUST="); + } +}, 30_000); + +test("explicit enable on Windows activates the picker profile once trusted", async () => { + const { root, config } = setup(true); + const trust = windowsTrust({ trusted: false }); + const fake = memoryPickerCaStore(); + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(handle); + const controller = getClaudePickerController()!; + const enabled = await controller.enable({ persist: false, context: "server" }); + expect(enabled.supported).toBe(true); + expect(enabled.trust).toBe("trusted"); + expect(enabled.profile).toBe("applied"); + expect(enabled.effective).toBe(true); + expect(trust.mutations).toContain("add"); + const inspected = inspectDesktopPickerProfile({ configDir: root, platform: "win32" }); + expect(inspected.kind).toBe("applied"); + if (inspected.kind === "applied") { + expect(inspected.proxyUrl).toBe("http://127.0.0.1:" + String(handle?.pickerProxyPort)); + } +}, 30_000); + +test("Windows reload retains fingerprint and issues no trust mutations", async () => { + const { root, config } = setup(true); + const storePath = join(root, "test-only-store"); + const trust = windowsTrust({ trusted: false }); + const first = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: filePickerCaStore(storePath), + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(first); + await getClaudePickerController()!.enable({ persist: false, context: "server" }); + const fingerprint = pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha256; + await first.stop(); + handles.splice(0, handles.length); + trust.mutations.splice(0, trust.mutations.length); + trust.calls.splice(0, trust.calls.length); + const second = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: filePickerCaStore(storePath), + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(second); + expect(pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha256).toBe(fingerprint); + expect(trust.mutations).toEqual([]); + const status = await getClaudePickerController()!.status(); + expect(status.supported).toBe(true); +}, 30_000); + +test("Windows off removes picker trust and profile but keeps unrelated entries", async () => { + const { root, config } = setup(true); + const desktop = join(root, "desktop"); + mkdirSync(desktop, { recursive: true }); + const foreignId = "foreign-personal"; + writeFileSync(join(desktop, foreignId + ".json"), '{"foreign":true}\n'); + writeFileSync(join(desktop, "_meta.json"), JSON.stringify({ appliedId: foreignId, entries: [{ id: foreignId, name: "Personal" }] }, null, 2) + "\n"); + const trust = windowsTrust({ trusted: false }); + const fake = memoryPickerCaStore(); + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(handle); + const controller = getClaudePickerController()!; + await controller.enable({ persist: false, context: "server" }); + expect(trust.state.trusted).toBe(true); + const off = await controller.disable({ persist: false }); + expect(trust.state.trusted).toBe(false); + expect(off.effective).toBe(false); + const meta = JSON.parse(readFileSync(join(desktop, "_meta.json"), "utf8")) as { appliedId?: string; entries: Array<{ id: string; name: string }> }; + expect(meta.entries.some(e => e.id === foreignId)).toBe(true); + expect(meta.entries.some(e => e.name === "opencodex-picker")).toBe(false); + expect(existsSync(join(desktop, foreignId + ".json"))).toBe(true); + expect(inspectDesktopPickerProfile({ configDir: root, platform: "win32" }).kind).not.toBe("applied"); +}, 30_000); + +test("Windows CONNECT chooser separates browser claude.ai from CLI api.anthropic.com", async () => { + const { root, config } = setup(true); + const trust = windowsTrust({ trusted: true }); + const fake = memoryPickerCaStore(); + let pickerSelect: ((host: string, port: number, req: { userAgent: string | null }) => unknown) | null = null; + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => { + if (options.selectTunnel) pickerSelect = options.selectTunnel as never; + return startConnectProxy(0, options); + }, + }); + handles.push(handle); + expect(pickerSelect).not.toBeNull(); + const browser = { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }; + const cli = { userAgent: null }; + expect(isBrowserConnect(browser)).toBe(true); + expect(isBrowserConnect(cli)).toBe(false); + const pick = pickerSelect!; + expect(await pick("claude.ai", 443, browser)).toMatchObject({ kind: "intercept" }); + expect(await pick("claude.ai", 443, cli)).toMatchObject({ kind: "blind" }); + expect(await pick("api.anthropic.com", 443, cli)).toMatchObject({ kind: "intercept" }); + const browserApi = await pick("api.anthropic.com", 443, browser) as unknown; + expect(browserApi === null || (browserApi as { kind: string }).kind === "blind").toBe(true); +}, 30_000); + +function tlsViaPicker(port: number, authority: string, servername: string, caPem: string, userAgent: string | null): Promise { + return new Promise((resolve, reject) => { + const plain = connect({ host: "127.0.0.1", port }, () => { + const ua = userAgent ? "User-Agent: " + userAgent + "\r\n" : ""; + plain.write("CONNECT " + authority + " HTTP/1.1\r\nHost: " + authority + "\r\n" + ua + "\r\n"); + }); + let head = ""; + const timer = setTimeout(() => { plain.destroy(); reject(new Error("CONNECT timed out")); }, 10_000); + const onData = (chunk: Buffer) => { + head += chunk.toString("latin1"); + if (!head.includes("\r\n\r\n")) return; + const status = Number(/HTTP\/1\.1 (\d{3})/.exec(head)?.[1]); + if (status !== 200) { + clearTimeout(timer); + plain.destroy(); + reject(new Error("CONNECT refused with " + status)); + return; + } + clearTimeout(timer); + plain.off("data", onData); + plain.removeAllListeners("error"); + const tls = tlsConnect({ socket: plain, servername, ca: caPem, rejectUnauthorized: true }); + tls.once("secureConnect", () => resolve(tls)); + tls.once("error", reject); + }; + plain.on("data", onData); + plain.once("error", err => { clearTimeout(timer); reject(err); }); + }); +} + +function readHttp(socket: TLSSocket, payload: string): Promise<{ status: number; body: Buffer }> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const timer = setTimeout(() => { socket.destroy(); reject(new Error("HTTP exchange timed out")); }, 10_000); + socket.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.once("close", () => { + clearTimeout(timer); + const raw = Buffer.concat(chunks).toString("binary"); + const split = raw.indexOf("\r\n\r\n"); + if (split < 0) { reject(new Error("HTTP response without headers")); return; } + const status = Number(/HTTP\/1\.1 (\d{3})/.exec(raw)?.[1]); + resolve({ status, body: Buffer.from(raw.slice(split + 4), "binary") }); + }); + socket.once("error", err => { clearTimeout(timer); reject(err); }); + socket.write(payload); + }); +} + +async function httpsGet(socket: TLSSocket, host: string, path: string): Promise<{ status: number; body: Buffer }> { + return readHttp(socket, "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"); +} + +async function httpsPost(socket: TLSSocket, host: string, path: string, json: string): Promise<{ status: number; body: Buffer }> { + return readHttp(socket, + "POST " + path + " HTTP/1.1\r\nHost: " + host + "\r\nContent-Type: application/json\r\nContent-Length: " + String(Buffer.byteLength(json)) + "\r\nConnection: close\r\n\r\n" + json); +} + +test("Windows picker carries real CONNECT+TLS end to end for browser bootstrap and CLI messages", async () => { + const { root, config } = setup(true); + const upstreamCa = createLocalInterceptCa(); + const upstreamLeaf = issueLocalInterceptLeaf(upstreamCa, ["claude.ai"]); + const nativeBootstrap = { model_selector_config: [{ id: "code", models: [{ id: "claude-native", name: "Native", section: "main" }] }] }; + const upstreamSockets = new Set(); + const upstream = createHttpsServer({ cert: upstreamLeaf.certPem, key: upstreamLeaf.keyPem }, (req, res) => { + if (req.method === "GET" && (req.url === "/api/bootstrap" || req.url?.startsWith("/api/bootstrap?"))) { + const body = Buffer.from(JSON.stringify(nativeBootstrap)); + res.writeHead(200, { "Content-Type": "application/json", "Content-Length": String(body.length) }); + res.end(body); + return; + } + if (req.method === "GET" && req.url === "/v1/other") { + const body = Buffer.from(JSON.stringify({ native: "passthrough" })); + res.writeHead(200, { "Content-Type": "application/json", "Content-Length": String(body.length) }); + res.end(body); + return; + } + res.writeHead(404, { "Content-Length": "0" }); + res.end(); + }); + upstream.on("secureConnection", socket => { + upstreamSockets.add(socket); + socket.once("close", () => upstreamSockets.delete(socket)); + }); + await new Promise(resolve => upstream.listen(0, "127.0.0.1", () => resolve())); + const upstreamPort = (upstream.address() as { port: number }).port; + const trust = windowsTrust({ trusted: true }); + const fake = memoryPickerCaStore(); + const routed = async () => ({ nativeSlugs: [], routedModels: [{ provider: "xai", id: "grok-test", contextWindow: 256_000 }] }); + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async req => { + const url = new URL(req.url); + if (url.pathname === "/v1/messages") return Response.json({ dispatched: true }); + return new Response("unexpected", { status: 500 }); + }, + loadPickerRoutes: routed, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + createPicker: options => createPickerRuntime({ + ...options, + readConfig: () => config, + resolveMode: () => "first-party", + startListener: listenerOptions => startPickerListener({ + ...listenerOptions, + upstream: { host: "127.0.0.1", port: upstreamPort, servername: "claude.ai", ca: upstreamCa.certPem }, + }), + }), + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(handle); + try { + await getClaudePickerController()!.enable({ persist: false, context: "server" }); + const pickerPort = handle?.pickerProxyPort!; + expect(pickerPort).toBeGreaterThan(0); + const browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + const pickerCa = readFileSync(pickerCaCertPath(root), "utf8"); + const bootstrapTls = await tlsViaPicker(pickerPort, "claude.ai:443", "claude.ai", pickerCa, browserUA); + try { + const bootstrap = await httpsGet(bootstrapTls, "claude.ai", "/api/bootstrap"); + expect(bootstrap.status).toBe(200); + const catalog = JSON.parse(bootstrap.body.toString("utf8")) as { model_selector_config: Array<{ models: Array<{ id: string }> }> }; + const ids = catalog.model_selector_config.flatMap(surface => surface.models.map(entry => entry.id)); + expect(ids).toContain("claude-native"); + expect(ids).toContain("ocx-claude-xai--grok-test"); + } finally { + bootstrapTls.destroy(); + } + const otherTls = await tlsViaPicker(pickerPort, "claude.ai:443", "claude.ai", pickerCa, browserUA); + try { + const other = await httpsGet(otherTls, "claude.ai", "/v1/other"); + expect(other.status).toBe(200); + expect(other.body.toString("utf8")).toBe(JSON.stringify({ native: "passthrough" })); + } finally { + otherTls.destroy(); + } + const mainCa = readFileSync(claudeInterceptCaCertPath(root), "utf8"); + const cli = await tlsViaPicker(pickerPort, "api.anthropic.com:443", "api.anthropic.com", mainCa, null); + try { + const posted = await httpsPost(cli, "api.anthropic.com", "/v1/messages", JSON.stringify({ model: "claude-x" })); + expect(posted.status).toBe(200); + expect((JSON.parse(posted.body.toString("utf8")) as { dispatched: boolean }).dispatched).toBe(true); + } finally { + cli.destroy(); + } + } finally { + for (const socket of upstreamSockets) socket.destroy(); + await new Promise(resolve => upstream.close(() => resolve())); + } +}, 60_000); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8a385a65ee0..8146b6234cd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "claude-picker-trust-windows.test.ts": "claude-integration", "messages-native-scope.test.ts": "claude-integration", "cli-log-view-filter.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", @@ -254,7 +255,7 @@ "claude-picker-listener.test.ts": "claude-integration", "claude-picker-models.test.ts": "claude-integration", "claude-picker-recovery.test.ts": "claude-integration", "claude-picker-runtime.test.ts": "claude-integration", "claude-picker-startup.test.ts": "claude-integration", - "claude-picker-trust.test.ts": "claude-integration", "claude-desktop-remote-hub.test.ts": "claude-integration", + "claude-picker-trust.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-integration", "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-estimate-projection.test.ts": "claude-integration", "claude-first-party-agent-sync.test.ts": "claude-integration", diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts index c5cece69dfc..1cf92fed9e3 100644 --- a/tests/server/claude-intercept-integration.test.ts +++ b/tests/server/claude-intercept-integration.test.ts @@ -50,6 +50,12 @@ afterEach(() => { // because an unknown one is relayed to the real Anthropic host instead of the configured upstream. const CLI_UA = { "user-agent": "claude-cli/2.1.282 (external, cli)" }; +// These cases exercise local request routing. Installed service ownership is covered +// separately; querying the real Windows service manager can outlast the server watchdog. +function startInterceptServer(port: number) { + return startServer(port, { inspectNativeCodexOwnership: () => ({ ownership: "owned", reason: "isolated test home" }) }); +} + async function waitForIntercept(): Promise>> { for (let i = 0; i < 100; i++) { const state = getClaudeInterceptState(); @@ -84,7 +90,7 @@ test("Messages through CONNECT reach the router; other paths relay to the config intercept: { port: interceptPort }, }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { const state = await waitForIntercept(); expect(state.proxyPort).toBe(interceptPort); @@ -163,7 +169,7 @@ test("a first-party binding routes a picker id on the intercept only; the public intercept: { port: interceptPort, modelMap: { "claude-sonnet-4-6": "bindtarget/fake-model" } }, }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { const state = await waitForIntercept(); const ca = readFileSync(state.caCertPath, "utf8"); @@ -216,7 +222,7 @@ test("an owned legacy unauthenticated env is migrated on start; nothing else is }, claudeCode: { intercept: { port: interceptPort } }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { const state = await waitForIntercept(); const token = readClaudeInterceptProxyToken(testDir); @@ -238,7 +244,7 @@ test("an ephemeral public port starts no proxy unless intercept.port is explicit }, }; saveConfig({ ...base, port: 10100 } as unknown as OcxConfig); - const implicit = startServer(0); + const implicit = startInterceptServer(0); try { await Bun.sleep(100); expect(getClaudeInterceptState()).toBeNull(); @@ -248,7 +254,7 @@ test("an ephemeral public port starts no proxy unless intercept.port is explicit const proxyPort = await findAvailablePort(0, "127.0.0.1"); saveConfig({ ...base, port: 10100, claudeCode: { intercept: { port: proxyPort } } } as unknown as OcxConfig); - const explicit = startServer(0); + const explicit = startInterceptServer(0); try { const state = await waitForIntercept(); expect(state.proxyPort).toBe(proxyPort); @@ -268,7 +274,7 @@ test("intercept.enabled=false starts no proxy", async () => { }, claudeCode: { intercept: { enabled: false } }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { await Bun.sleep(100); expect(getClaudeInterceptState()).toBeNull(); From 6b625e87a9237353db501f32b2942ffb266c478d Mon Sep 17 00:00:00 2001 From: hyuntae park Date: Wed, 7 Oct 2026 14:15:57 +0900 Subject: [PATCH 23/27] fix(claude): stop Windows trust process on output errors --- src/claude/intercept/picker-trust-windows.ts | 6 +- structure/clients/claude-desktop.md | 2 +- structure/runtime.md | 2 +- .../claude-picker-trust-windows.test.ts | 79 ++++++++++++++++++- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/claude/intercept/picker-trust-windows.ts b/src/claude/intercept/picker-trust-windows.ts index a5e03fb8317..17c911d4fcf 100644 --- a/src/claude/intercept/picker-trust-windows.ts +++ b/src/claude/intercept/picker-trust-windows.ts @@ -69,7 +69,7 @@ export function sanitizeWindowsPowerShellEnv(env: NodeJS.ProcessEnv = process.en /** * Production runner: trusted System32 powershell.exe, hidden window, sanitized - * module path, bounded runtime. A timeout kills the child and reports code null + * module path, bounded runtime. Timeout and output-read errors kill the child and report code null * so callers map it to unknown/declined instead of hanging periodic status. */ export const defaultWindowsPowerShellRunner: WindowsTrustRunner = async (args, options) => { @@ -99,12 +99,12 @@ export const defaultWindowsPowerShellRunner: WindowsTrustRunner = async (args, o ]); return { out, err, code }; })(); - const settled = await new Promise<{ out: string; err: string; code: number } | null>(resolve => { + const settled = await new Promise<{ out: string; err: string; code: number } | null>((resolve, reject) => { timer = setTimeout(() => { try { child?.kill(); } catch { /* already gone */ } resolve(null); }, timeoutMs); - pending.then(resolve, () => resolve(null)); + pending.then(resolve, reject); }); if (timer !== undefined) clearTimeout(timer); if (settled === null) { diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index dbe116a99b2..d0f66a3ffd0 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -239,7 +239,7 @@ activation, which uses the same persistent authority path. On macOS trust is added without a policy string: Chromium skips host-scoped trust settings, so `inspectPickerTrust` treats a current CA whose exported user trust settings carry `kSecTrustSettingsPolicyString` as untrusted and an explicit trust step replaces it; an -export it cannot read makes trust `unknown`, which never arms. On Windows trust is the current-user Root store entry matching the exact fingerprint with a verified leaf. A rotated-out picker certificate is +export it cannot read makes trust `unknown`, which never arms. On Windows trust is the current-user Root store entry matching the exact fingerprint with a verified leaf. The PowerShell runner terminates its child on timeout or output-read failure and preserves the failure category. A rotated-out picker certificate is removed from the login keychain on macOS or the current-user Root store on Windows as its replacement is published, and a failed removal stops the picker arming. Publication of `ca.pem` and `ca-owner.json` happens only inside the `ca.lock.sqlite` lock (`picker-ca.ts`): lock acquisition is reported separately from the diff --git a/structure/runtime.md b/structure/runtime.md index 8b2516d1d60..52724097946 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -252,7 +252,7 @@ still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.c listener, relays every other CONNECT target blind, and refuses unauthenticated clients, plain proxied HTTP, and loopback targets. The per-install proxy token is stored owner-only under `/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it. The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream. One relayed path is extended: for the standalone CLI's picker catalog, the listener's `cliCatalog` hook (`src/claude/intercept/cli-catalog.ts`) merges opencodex rows into `GET /api/organizations//model_selector/cc` (or `/api/model_selector/cc`), touching only the `cc` surface and leaving Desktop's `ccd`/`code` surfaces to picker mode, and into the fallback `additional_model_options` of `GET /api/claude_cli/bootstrap`. Eligibility: the `cc` catalog is rewritten only for a CLI-classified `User-Agent` with `claudeCode.cliFirstParty` on, because Claude Code caches it on disk for every process and a Desktop-spawned process must not seed rows a plain `claude` cannot route; the bootstrap request carries no entrypoint and follows CLI intent alone. Rows come from `buildCliPickerModels` (`src/claude/intercept/picker-models.ts`), which uses Desktop 3P registry aliases because the CLI offers only Claude-shaped ids (an `ocx-claude-*` row renders "Update Claude Code"), and advertises only an alias the registry decodes back to the same route. `src/claude/intercept/cli-picker.ts` supplies them lazily on the first eligible request, persists the snapshot at `/claude-intercept/cli-picker-models.json`, re-checks each persisted row against the live registry (its alias must still decode to its recorded route; a cold registry after a restart is awaited first, and a snapshot whose rows still do not decode, for example after a provider change, is rebuilt within the same bound), and bounds a cold build at 2.5 s while awaiting the shared registry build in `src/claude/desktop-3p-startup.ts` that startup also uses (a failed or empty on-demand build is not retried for 30 s). Both CLI catalogs obey the shared [picker rewrite bounds](clients/claude-desktop.md#picker-catalog-rewrite-bounds), preflighting row growth before copying and keeping the final JSON within 16 MiB. The rewrite fails open: a provider error, non-2xx status, unparsable body, or no added row relays the upstream bytes unchanged; a body over the 16 MiB cap is cut off while streaming and answered 502 rather than buffered. Claude Code trusts its cached `/cache/model-catalog/*-cc.json` for about an hour, so `writeSettings` in `src/claude/intercept/settings.ts` and every successful `reconcileClaudeFirstPartySettings` drop those files (leaving Desktop's `*-ccd.json`); running sessions keep their list until restarted. The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). The lifecycle records bind failure, and stop joins both sockets. Its serialized `ensure()` joins pending startup, reuses the live config and dispatch wrapper, and can start a previously unbound pair. `stop()` refuses future ensure calls and awaits an in-flight start. Local management applies and `ocx claude intercept start` (`POST /api/claude-intercept/start`) use this seam through `src/server/index/optional-listeners.ts`; hub-management ingress and data-plane credentials cannot start interception. Status reports `interceptReason` (with `interceptFailurePort` for a CONNECT bind failure) and optional `pickerReason` (with `pickerFailurePort` when the Desktop picker relay port is occupied), distinguishing disabled routing, client role, ephemeral public ports, occupied CONNECT ports and other failures. A changed configured port never rebinds a running pair: apply reports `port_mismatch` with both ports. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required. The lifecycle makes a best-effort legacy picker-key cleanup attempt before checking client role, disabled routing/interception, or an ephemeral public port. Each new unbound lifecycle `ensure()` attempt retries cleanup; management requests rejected before that seam do not invoke it. Cleanup failures do not change the eligibility result or block startup. Direct `startClaudeIntercept` callers retain the same cleanup defense. Mock-only regression coverage lives in `tests/claude-integration/claude-intercept-cleanup.test.ts`. -This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). The macOS and Windows picker reuses an exportable signing identity protected by the OS credential store across normal restarts, without a plaintext key in the config directory. Restore verifies the constrained CA and current OS trust without installing trust; explicit picker `on` or `trust` completes the missing trust step, including the Windows confirmation when needed. Legacy migration cleanup may still require consent, and native macOS keychain unlock or application-access prompts are separate. Linux starts no picker CA, credential-store or proxy work; the main intercept pair and best-effort legacy-key cleanup remain available. +This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). The macOS and Windows picker reuses an exportable signing identity protected by the OS credential store across normal restarts, without a plaintext key in the config directory. Restore verifies the constrained CA and current OS trust without installing trust; explicit picker `on` or `trust` completes the missing trust step, including the Windows confirmation when needed. Windows trust subprocesses are terminated on timeout or output-read failure; those failures remain distinguishable. Legacy migration cleanup may still require consent, and native macOS keychain unlock or application-access prompts are separate. Linux starts no picker CA, credential-store or proxy work; the main intercept pair and best-effort legacy-key cleanup remain available. `src/claude/intercept/client-class.ts` classifies each request by its Claude Code `User-Agent` entrypoint: `claude-desktop`, `claude-desktop-3p`, and `local-agent` are Desktop; other well-formed `claude-cli/ (external, )` values are CLI; absent or malformed values are unknown. Only a client with its own first-party intent enabled (Desktop mode or `claudeCode.cliFirstParty`) enters the router for Messages paths; other paths use the configured upstream relay. diff --git a/tests/claude-integration/claude-picker-trust-windows.test.ts b/tests/claude-integration/claude-picker-trust-windows.test.ts index a86ba8fb41c..d55081219f1 100644 --- a/tests/claude-integration/claude-picker-trust-windows.test.ts +++ b/tests/claude-integration/claude-picker-trust-windows.test.ts @@ -1,7 +1,8 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; import { PICKER_CA_COMMON_NAME, @@ -237,3 +238,79 @@ test("the shared entry point dispatches win32 to the windows adapter", async () expect(await untrustPickerCa(LEAF, SHA1, removed.run, "win32")).toEqual({ ok: true }); expect(removed.argv[0]!.slice(0, -1)).toEqual([...WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX]); }); + +const FAKE_TRUSTED_POWERSHELL = + "C:\\trusted-system32\\WindowsPowerShell\\v1.0\\powershell.exe"; + +test("default runner kills the child and surfaces stdout read failures instead of timing out", async () => { + setTrustedWindowsElevationExecutablesForTests({ powershell: FAKE_TRUSTED_POWERSHELL }); + const stdout = new ReadableStream({ + start(controller) { + controller.error(new Error("boom-stdout")); + }, + }); + const stderr = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + let resolveExited!: (code: number) => void; + const exited = new Promise(resolve => { + resolveExited = resolve; + }); + let kills = 0; + const spawn = spyOn(Bun, "spawn").mockReturnValue({ + stdout, + stderr, + exited, + kill() { + kills += 1; + resolveExited(1); + }, + } as unknown as ReturnType); + try { + const started = Date.now(); + const outcome = await defaultWindowsPowerShellRunner([...WINDOWS_TRUST_POWERSHELL_PREFIX, "exit 0"], { + timeoutMs: 5_000, + }); + expect(kills).toBe(1); + expect(outcome.code).toBeNull(); + expect(outcome.stderr).toContain("boom-stdout"); + expect(outcome.stderr).not.toContain("timed out"); + expect(Date.now() - started).toBeLessThan(5_000); + } finally { + spawn.mockRestore(); + setTrustedWindowsElevationExecutablesForTests(null); + } +}); + +test("default runner kills a hung child and reports a timeout", async () => { + setTrustedWindowsElevationExecutablesForTests({ powershell: FAKE_TRUSTED_POWERSHELL }); + let resolveExited!: (code: number) => void; + const exited = new Promise(resolve => { + resolveExited = resolve; + }); + const stdout = new ReadableStream({}); + const stderr = new ReadableStream({}); + let kills = 0; + const spawn = spyOn(Bun, "spawn").mockReturnValue({ + stdout, + stderr, + exited, + kill() { + kills += 1; + resolveExited(1); + }, + } as unknown as ReturnType); + try { + const outcome = await defaultWindowsPowerShellRunner([...WINDOWS_TRUST_POWERSHELL_PREFIX, "exit 0"], { + timeoutMs: 20, + }); + expect(kills).toBe(1); + expect(outcome.code).toBeNull(); + expect(outcome.stderr).toContain("timed out after 20ms"); + } finally { + spawn.mockRestore(); + setTrustedWindowsElevationExecutablesForTests(null); + } +}); From 0cd21698a86aee7c4830bdcac55e872af8440be4 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 7 Oct 2026 18:14:01 +0900 Subject: [PATCH 24/27] fix(deps): bump @modelcontextprotocol/sdk to 1.31.0 for GHSA-6qxp-vccf-f47h --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 10de7ca5cfc..46893143a37 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ "@hono/node-server": ["@hono/node-server@2.1.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], From ea83808329d147784e0d5b09cdda05868585fec2 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 05:33:36 +0900 Subject: [PATCH 25/27] test(claude): drain Windows picker fixture hardening before removal --- structure/clients/claude-desktop.md | 11 ++- .../claude-picker-windows.test.ts | 99 ++++++++++++++++++- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 5d474b0fdfa..d1be6d74a05 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -120,7 +120,11 @@ and the auto-detect (WPAD) flag in `Connections\DefaultConnectionSettings`, and a bypass, no covering proxy, or an undecidable PAC script or WPAD. A failed registry read is reported as unreadable, never as an absent value, and a stale settings env never earns an `ok`. It never prints the proxy value and never records a doctor failure, because the CLI and other clients -still route. When picker mode is armed, Desktop uses its app-owned fixed egress proxy instead of this system-proxy path; the manual bypass observation above remains for picker-off or picker-pending installs. +still route. An applied, selected picker profile uses Desktop's app-owned fixed egress proxy +while that proxy is available, including when missing trust leaves the picker pending and +`claude.ai` tunnels relay blindly. Missing trust preserves the profile and its selection. +The system-proxy observation applies to picker-off installs and pending installs with no +applied picker profile. Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; `ocx claude config set --first-party on|off` and the Claude Code page switch control the CLI intent; `ocx ensure` refreshes a stale or absent env while it is on. @@ -185,6 +189,11 @@ Existing Claude consumers omit this option and retain blind forwarding; it enabl Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo. The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. +Gateway remains the default Desktop mode. On macOS and Windows, picker intent defaults on +only within the chosen first-party mode: either an explicit Desktop integration `false` or +`claudeCode.intercept.picker: false` disables it. Default intent does not install certificate +trust; startup and status only inspect trust, and explicit activation owns installation. + On macOS and Windows, when the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also wires Claude Desktop picker mode: a second loopback CONNECT proxy on the dedicated picker proxy port (`getClaudeInterceptState()?.pickerProxyPort`), used as Desktop's pinned egress proxy. Desktop diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index fff9351872f..00fe3d9cc7d 100644 --- a/tests/claude-integration/claude-picker-windows.test.ts +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -1,11 +1,14 @@ import { afterEach, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { createServer as createHttpsServer } from "node:https"; import { connect } from "node:net"; import { connect as tlsConnect, type TLSSocket } from "node:tls"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { flushConfigDirHardeningAndReaps, hardenConfigDir } from "../../src/config/paths"; +import { resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, setPlatformForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { supportsDesktopPicker } from "../../src/claude/desktop-picker-platform"; import { inspectDesktopPickerProfile } from "../../src/claude/desktop-picker-profile"; import { createPickerRuntime, pickerDesired } from "../../src/claude/intercept/picker-runtime"; @@ -29,13 +32,26 @@ const priorHome = process.env.OPENCODEX_HOME; const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; const priorClaude = process.env.CLAUDE_CONFIG_DIR; -afterEach(async () => { - for (const handle of handles.splice(0)) await handle?.stop(); - for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); +// Config reads may leave an ACL child holding a root open after intercept stop. +// Drain hardening and reaps before retrying removal; retain failures and restore env. +async function cleanupPickerWindows(remove: (dir: string) => void = removeTreeWithRetry): Promise { + const failures: unknown[] = []; + for (const handle of handles.splice(0)) { + try { await handle?.stop(); } catch (error) { failures.push(error); } + } + for (const dir of roots.splice(0)) { + try { + await flushConfigDirHardeningAndReaps(dir); + remove(dir); + } catch (error) { failures.push(error); } + } if (priorHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = priorHome; if (priorDesktop === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = priorDesktop; if (priorClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = priorClaude; -}); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, "picker-windows cleanup failed"); +} +afterEach(() => cleanupPickerWindows()); const routes = async () => ({ nativeSlugs: [], routedModels: [] }); @@ -55,6 +71,79 @@ function setup(firstParty = true) { return { root, config }; } +test("Windows fixture cleanup waits for pending config-directory ACL hardening", async () => { + let release!: () => void; + let started!: () => void; + const blocked = new Promise(resolve => { release = resolve; }); + const entered = new Promise(resolve => { started = resolve; }); + const { root } = setup(); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setAsyncIcaclsRunnerForTests(async () => { + started(); + await blocked; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + let cleanup: Promise | undefined; + try { + hardenConfigDir(); + await entered; + cleanup = cleanupPickerWindows(); + await Promise.resolve(); + expect(existsSync(root)).toBe(true); + release(); + await cleanup; + expect(existsSync(root)).toBe(false); + expect(process.env.OPENCODEX_HOME).toBe(priorHome); + expect(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR).toBe(priorDesktop); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(priorClaude); + } finally { + release(); + try { + await flushConfigDirHardeningAndReaps(root); + await cleanup; + } finally { + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + } + } +}); + +test("Windows fixture cleanup attempts every stop and root and preserves both failures", async () => { + const first = setup().root, second = setup().root; + let laterStops = 0; + const attempted: string[] = []; + const stopError = new Error("stop failed"); + const removeError = new Error("EBUSY: resource busy or locked"); + type Handle = Awaited>; + handles.push({ stop: async () => { throw stopError; } } as unknown as Handle); + handles.push({ stop: async () => { laterStops++; } } as unknown as Handle); + try { + let failure: unknown; + try { + await cleanupPickerWindows(dir => { + attempted.push(dir); + if (dir === first) throw removeError; + removeTreeWithRetry(dir); + }); + } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([stopError, removeError]); + expect(laterStops).toBe(1); + expect(attempted).toEqual([first, second]); + expect(existsSync(second)).toBe(false); + expect(process.env.OPENCODEX_HOME).toBe(priorHome); + expect(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR).toBe(priorDesktop); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(priorClaude); + } finally { + await flushConfigDirHardeningAndReaps(first); + await flushConfigDirHardeningAndReaps(second); + removeTreeWithRetry(first); + removeTreeWithRetry(second); + } +}); + // Synthetic Windows trust matching src/claude/intercept/picker-trust-windows.ts. // The runner receives powershell argv (exe resolved by the default runner, so // argv begins with -NoLogo): inspect uses the read-only prefix plus a script From f747a7666c887b29107ff80bc26d6c6f66e50d2d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 05:51:43 +0900 Subject: [PATCH 26/27] fix(claude): scope Windows trust cleanup and picker diagnostics --- .../src/content/docs/guides/claude-code.md | 7 +- src/claude/desktop-system-proxy.ts | 54 +++++++- src/claude/intercept/picker-trust-windows.ts | 22 ++- src/claude/intercept/picker-trust.ts | 5 +- src/cli/doctor.ts | 2 +- structure/clients/claude-desktop.md | 2 + .../claude-desktop-system-proxy.test.ts | 127 ++++++++++++++++-- .../claude-picker-trust-windows.test.ts | 28 +++- 8 files changed, 224 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 14b0c49dfd2..8387f8a4499 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -260,10 +260,13 @@ entries, is kept. Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels. With the CLI switch on, the standalone `claude` also lists routed models in `/model`; see [Model picker in the CLI](#model-picker-in-the-cli). :::note[Windows system proxy (Clash, v2rayN, corporate proxies)] -When a Windows system proxy is on, Claude Desktop hands it to the Code tab as `HTTPS_PROXY`, and +For picker-off installs or pending installs with no applied picker profile, a Windows system proxy +is handed to the Code tab as `HTTPS_PROXY`, and that value takes precedence over the OpenCodex proxy in `~/.claude/settings.json`. The Code tab then goes around OpenCodex and routed models fail there, while the standalone CLI keeps working. -Once picker mode is active, Desktop uses its app-owned fixed egress proxy instead of the system proxy. Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge, +An applied selected picker profile uses Desktop's fixed egress proxy while it is available, even when +missing trust leaves the picker pending and `claude.ai` relays blindly. System-proxy bypass advice +applies when there is no selected available picker profile. Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge, `system_proxy_bypass`), then fully quit and reopen Claude Desktop. `ocx doctor` reports this under "Claude Desktop first-party vs Windows system proxy". It cannot evaluate a PAC script or automatic proxy detection (WPAD, "Automatically detect settings"), so it reports those as unknown; diff --git a/src/claude/desktop-system-proxy.ts b/src/claude/desktop-system-proxy.ts index 85933c1250b..aec5110d201 100644 --- a/src/claude/desktop-system-proxy.ts +++ b/src/claude/desktop-system-proxy.ts @@ -1,4 +1,6 @@ import type { OcxConfig } from "../types"; +import { connect } from "node:net"; +import { directLocalHttpFetch } from "../server/direct-local-http"; import { readWindowsProxyBypassRegistry, readWindowsSystemProxy, @@ -8,6 +10,7 @@ import { } from "../lib/windows-system-proxy"; import { inspectDesktopFirstParty, observeClaudeDesktopMode } from "./desktop-first-party"; import { desktopFirstPartyDesired } from "./first-party-settings"; +import { inspectDesktopPickerProfile, type DesktopPickerProfileInspection } from "./desktop-picker-profile"; /** * Whether the Windows system proxy silently bypasses Desktop first-party mode. @@ -17,7 +20,8 @@ import { desktopFirstPartyDesired } from "./first-party-settings"; * as `HTTPS_PROXY`/`HTTP_PROXY`. That inherited value takes precedence over the `env` block * OpenCodex writes into the user's `~/.claude/settings.json` (only Claude Code managed settings * override it), so Code-tab traffic goes to the system proxy and never reaches the intercept. - * Routed subagent models then fail, and nothing else reports why. A system proxy such as Clash or + * A selected picker profile with an available fixed egress relay takes precedence instead. + * Otherwise routed subagent models fail. A system proxy such as Clash or * v2rayN, with no bypass for the API host, is the common case. * * Observe-only: this reads the registry and never changes it. A PAC script and WPAD automatic @@ -123,6 +127,39 @@ export interface DesktopSystemProxyDeps { firstPartyState?: () => DesktopFirstPartySettingsState; readSystemProxy?: () => WindowsSystemProxyResult; readBypass?: () => WindowsProxyBypassValues | null; + inspectPickerProfile?: () => DesktopPickerProfileInspection; + pickerRelayAvailable?: (proxyUrl: string) => boolean | Promise; +} + +/** Observe the selected endpoint even in a separate doctor process or a blind-only recovery relay. */ +async function livePickerRelayAvailable(proxyUrl: string): Promise { + const port = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/.exec(proxyUrl)?.[1]; + if (!port || Number(port) > 65535) return false; + try { + // A listening TCP socket alone is not evidence of an OpenCodex CONNECT relay. + const response = await directLocalHttpFetch(proxyUrl, { signal: AbortSignal.timeout(1_000) }); + if (response.status !== 405 || response.headers.get("allow") !== "CONNECT" + || !(await response.text()).startsWith("OpenCodex Claude intercept proxy accepts only CONNECT tunnels.")) return false; + // The main intercept proxy has the same HTTP response but requires credentials the Desktop + // profile cannot send. A loopback CONNECT is denied before dialing or choosing any tunnel; + // 403 proves unauthenticated admission, whereas the credential-bearing proxy returns 407. + return await new Promise(resolve => { + const socket = connect({ host: "127.0.0.1", port: Number(port) }); + const finish = (available: boolean) => { clearTimeout(timer); socket.destroy(); resolve(available); }; + const timer = setTimeout(() => finish(false), 1_000); + let head = ""; + socket.once("error", () => finish(false)); + socket.once("close", () => finish(false)); + socket.once("connect", () => socket.write("CONNECT 127.0.0.1:1 HTTP/1.1\r\nHost: 127.0.0.1:1\r\n\r\n")); + socket.on("data", bytes => { + head += bytes.toString("latin1"); + if (head.includes("\r\n\r\n")) finish(/^HTTP\/1\.[01] 403 Forbidden\r\n/.test(head)); + else if (head.length > 8 * 1024) finish(false); + }); + }); + } catch { // no-excuse-ok: catch -- failed relay probes retain the registry diagnostic. + return false; + } } function liveFirstPartyState( @@ -137,15 +174,24 @@ function liveFirstPartyState( } } -/** Reads the live state. Registry reads run only on Windows with a Desktop first-party env. */ -export function collectDesktopSystemProxy( +/** Registry reads run only on Windows with a Desktop first-party env and no verified picker relay. */ +export async function collectDesktopSystemProxy( config: Pick, deps: DesktopSystemProxyDeps = {}, -): DesktopSystemProxyAssessment { +): Promise { const platform = deps.platform ?? process.platform; if (platform !== "win32") return { kind: "not-applicable" }; const firstParty = (deps.firstPartyState ?? (() => liveFirstPartyState(config)))(); if (firstParty === "off") return { kind: "not-applicable" }; + if (firstParty === "applied" && config.claudeCode?.intercept?.picker !== false) { + try { + const profile = (deps.inspectPickerProfile ?? (() => inspectDesktopPickerProfile({ platform })))(); + if (profile.kind === "applied" && await (deps.pickerRelayAvailable ?? livePickerRelayAvailable)(profile.proxyUrl)) { + return { kind: "not-applicable" }; + } + } catch { // no-excuse-ok: catch -- missing profile/relay evidence must not hide a registry conflict. + } + } return assessDesktopSystemProxy({ platform, firstParty, diff --git a/src/claude/intercept/picker-trust-windows.ts b/src/claude/intercept/picker-trust-windows.ts index 17c911d4fcf..e108103b567 100644 --- a/src/claude/intercept/picker-trust-windows.ts +++ b/src/claude/intercept/picker-trust-windows.ts @@ -1,3 +1,4 @@ +import { X509Certificate } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -224,7 +225,8 @@ export function buildWindowsTrustInstallScript(caPath: string, wantSha1: string) } /** - * Remove script: deletes every Root entry with the exact thumbprint, then re-checks. + * Remove script: verifies picker identity for every exact-thumbprint Root entry before + * deleting any of them, then re-checks. Legacy picker roots need not match the new install profile. * Removal from Root raises OS confirmation like an install, so this runs on the * interactive prefix with a bounded 30s budget: predecessor cleanup also runs in headless * removal may prompt, mirroring the macOS keychain prompt on migration cleanup. @@ -235,10 +237,15 @@ export function buildWindowsTrustRemoveScript(wantSha1: string): string { return [ "$ErrorActionPreference='Stop'", `$want=${psLiteral(want, "picker CA fingerprint")}`, + `$pickerName=${psLiteral(PICKER_CA_COMMON_NAME, "picker CA common name")}`, "try {", `$store=${currentUserRootStore()}`, "$store.Open('ReadWrite')", - "try { $found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want }); foreach($c in $found) { $store.Remove($c) } } finally { $store.Close() }", + "try { $found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want });", + "foreach($c in $found) {", + `if($c.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ne $pickerName) { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`, + "}", + "foreach($c in $found) { $store.Remove($c) } } finally { $store.Close() }", `$check=${currentUserRootStore()}`, "$check.Open('ReadOnly')", "try { $left=@($check.Certificates | Where-Object { $_.Thumbprint -eq $want });", @@ -306,10 +313,21 @@ export async function trustPickerCaWindows( export async function untrustPickerCaWindows( fingerprintSha1: string, run?: WindowsTrustRunner, + cert?: { pem: string }, ): Promise<{ ok: boolean }> { const exec = run ?? defaultWindowsPowerShellRunner; if (normalizePickerTrustSha1(fingerprintSha1) === null) return { ok: false }; try { + // Public recovery state is not removal authority. Accept legacy picker roots, + // but reject foreign certificates before invoking even an injected runner. + if (cert) { + const root = new X509Certificate(cert.pem); + const names = root.subject.split("\n"); + if (!root.ca || !names.includes(`CN=${PICKER_CA_COMMON_NAME}`) + || names.filter(name => name.startsWith("CN=")).length !== 1 + || root.issuer !== root.subject || !root.verify(root.publicKey) + || pickerCaFingerprints(cert.pem).sha1 !== normalizePickerTrustSha1(fingerprintSha1)) return { ok: false }; + } const result = await exec(powershellArgs(buildWindowsTrustRemoveScript(fingerprintSha1), true), { timeoutMs: WINDOWS_TRUST_UNTRUST_TIMEOUT_MS, }); diff --git a/src/claude/intercept/picker-trust.ts b/src/claude/intercept/picker-trust.ts index e407e90d6cf..2d45ebeff9a 100644 --- a/src/claude/intercept/picker-trust.ts +++ b/src/claude/intercept/picker-trust.ts @@ -131,7 +131,10 @@ export async function untrustPickerCa( platform: NodeJS.Platform = process.platform, ): Promise<{ ok: boolean }> { if (platform === "win32") { - return untrustPickerCaWindows(fingerprintSha1, run === defaultSecurityRunner ? undefined : run); + try { + return await untrustPickerCaWindows(fingerprintSha1, run === defaultSecurityRunner ? undefined : run, + { pem: readFileSync(caPath, "utf8") }); + } catch { return { ok: false }; } } if (platform !== "darwin") return { ok: false }; const keychain = loginKeychainPath(); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 53843c6c5ac..1f6a84f4f85 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1458,7 +1458,7 @@ export async function runDoctor(args: string[] = []): Promise { // Observe-only and warning-level: a bypassed Desktop first-party install still serves the // CLI and every non-Desktop client, so this never records a doctor failure. - const desktopSystemProxyLines = formatDesktopSystemProxyLines(collectDesktopSystemProxy(doctorConfig)); + const desktopSystemProxyLines = formatDesktopSystemProxyLines(await collectDesktopSystemProxy(doctorConfig)); if (desktopSystemProxyLines.length > 0) { console.log("\nClaude Desktop first-party vs Windows system proxy"); for (const line of desktopSystemProxyLines) console.log(line); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index d1be6d74a05..3399950eca8 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -125,6 +125,7 @@ while that proxy is available, including when missing trust leaves the picker pe `claude.ai` tunnels relay blindly. Missing trust preserves the profile and its selection. The system-proxy observation applies to picker-off installs and pending installs with no applied picker profile. +Doctor verifies the selected profile's loopback CONNECT relay directly, independently of picker trust or effectiveness. Off, absent, unselected, unreadable, stale-settings and unavailable-relay cases retain the registry diagnostic; a credential-bearing main intercept proxy does not count as Desktop's egress relay. Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; `ocx claude config set --first-party on|off` and the Claude Code page switch control the CLI intent; `ocx ensure` refreshes a stale or absent env while it is on. @@ -186,6 +187,7 @@ The shared CONNECT primitive accepts optional `allowedTargets` authorities. It s normalizes that list at startup; an empty list denies all, and other host/port pairs receive 403 before tunnel selection or dialing. Authentication and loopback refusal remain in force. Existing Claude consumers omit this option and retain blind forwarding; it enables no new integration or certificate trust. +Windows trust removal in `src/claude/intercept/picker-trust-windows.ts` requires the exact picker common name and fingerprint before deleting a current-user Root entry. Public recovery certificates are verified as self-signed picker roots before removal; legacy roots may be removed without matching the current installation extension profile. Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo. The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. diff --git a/tests/claude-integration/claude-desktop-system-proxy.test.ts b/tests/claude-integration/claude-desktop-system-proxy.test.ts index ca0cf51d34f..c9b43d1c4bf 100644 --- a/tests/claude-integration/claude-desktop-system-proxy.test.ts +++ b/tests/claude-integration/claude-desktop-system-proxy.test.ts @@ -15,6 +15,11 @@ import { type WindowsProxyBypassValues, } from "../../src/lib/windows-system-proxy"; import type { OcxConfig } from "../../src/types"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectDesktopPickerProfile } from "../../src/claude/desktop-picker-profile"; +import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; const CLASH = parseWindowsProxyServer("127.0.0.1:7897"); const CLASH_BYPASS = "localhost;127.*;192.168.*;10.*;"; @@ -191,27 +196,63 @@ describe("collectDesktopSystemProxy", () => { claudeCode: { desktopMode: "first-party" } } as Pick; const untouchable = () => { throw new Error("registry read off the applicable path"); }; - test("never touches the registry off Windows", () => { + test("a selected Windows picker profile with a blind relay bypasses the system proxy without in-process runtime state", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-doctor-picker-")); + const library = join(root, "configLibrary"); + const options = { platform: "win32" as const, env: { OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: library }, homeDir: root }; + const relay = await startConnectProxy(0, { + interceptPort: 1, interceptHosts: [], selectTunnel: () => ({ kind: "blind" }), + dialUpstream: () => { throw new Error("doctor must not dial an upstream"); }, + }); + try { + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, "_meta.json"), JSON.stringify({ + appliedId: "picker", entries: [{ id: "picker", name: "opencodex-picker" }], + })); + writeFileSync(join(library, "picker.json"), JSON.stringify({ egressProxyUrl: `http://127.0.0.1:${relay.port}` })); + const result = await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => inspectDesktopPickerProfile(options), + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + }); + expect(result).toEqual({ kind: "not-applicable" }); + expect(formatDesktopSystemProxyLines(result)).toEqual([]); + await relay.close(); + expect(await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => inspectDesktopPickerProfile(options), + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + })).toEqual(verdict("conflict")); + } finally { + await relay.close(); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("never touches the registry off Windows", async () => { for (const platform of ["linux", "darwin"] as const) { - expect(collectDesktopSystemProxy(config, { platform, readSystemProxy: untouchable, readBypass: untouchable })) + expect(await collectDesktopSystemProxy(config, { platform, readSystemProxy: untouchable, readBypass: untouchable, + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable })) .toEqual({ kind: "not-applicable" }); } }); - test("never touches the registry on Windows without a Desktop first-party env", () => { - const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "off", readSystemProxy: untouchable, readBypass: untouchable }; - expect(collectDesktopSystemProxy(config, deps)).toEqual({ kind: "not-applicable" }); + test("never touches the registry on Windows without a Desktop first-party env", async () => { + const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "off", readSystemProxy: untouchable, readBypass: untouchable, + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable }; + expect(await collectDesktopSystemProxy(config, deps)).toEqual({ kind: "not-applicable" }); }); - test("a stale first-party env on Windows never prints ok", () => { + test("a stale first-party env on Windows never prints ok", async () => { const stale = (systemProxy: DesktopSystemProxyInput["systemProxy"], bypass: WindowsProxyBypassValues): DesktopSystemProxyDeps => - ({ platform: "win32", firstPartyState: () => "stale", readSystemProxy: () => systemProxy, readBypass: () => bypass }); + ({ platform: "win32", firstPartyState: () => "stale", readSystemProxy: () => systemProxy, readBypass: () => bypass, + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable }); for (const [systemProxy, bypass, kind] of [ [{ kind: "disabled" }, NO_WPAD, "no-proxy"], [CLASH, BYPASSED, "bypassed"], ] as const) { - const result = collectDesktopSystemProxy(config, stale(systemProxy, bypass)); + const result = await collectDesktopSystemProxy(config, stale(systemProxy, bypass)); expect(result).toEqual(verdict(kind, { settingsStale: true })); const lines = formatDesktopSystemProxyLines(result); expect(lines.join("\n")).not.toContain(" ok"); @@ -219,15 +260,77 @@ describe("collectDesktopSystemProxy", () => { expect(lines.join("\n")).toContain("ocx ensure"); } - const conflict = collectDesktopSystemProxy(config, stale(CLASH, NO_WPAD)); + const conflict = await collectDesktopSystemProxy(config, stale(CLASH, NO_WPAD)); expect(conflict).toEqual(verdict("conflict", { settingsStale: true })); const text = formatDesktopSystemProxyLines(conflict).join("\n"); expect(text).toContain("!!"); expect(text).toContain("ocx ensure"); }); - test("an applied env on Windows reads both registry surfaces", () => { - const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "applied", readSystemProxy: () => CLASH, readBypass: () => BYPASSED }; - expect(collectDesktopSystemProxy(config, deps)).toEqual(verdict("bypassed")); + test("an applied env on Windows reads both registry surfaces", async () => { + const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "applied", readSystemProxy: () => CLASH, readBypass: () => BYPASSED, + inspectPickerProfile: () => ({ kind: "absent" }) }; + expect(await collectDesktopSystemProxy(config, deps)).toEqual(verdict("bypassed")); + }); + + test("registry diagnostics remain for absent, unselected, unsafe or unavailable picker profiles", async () => { + let registryReads = 0; + const deps: DesktopSystemProxyDeps = { + platform: "win32", firstPartyState: () => "applied", + readSystemProxy: () => { registryReads++; return CLASH; }, + readBypass: () => { registryReads++; return NO_WPAD; }, + pickerRelayAvailable: untouchable, + }; + for (const kind of ["absent", "not_selected", "unsafe"] as const) { + const inspectPickerProfile: NonNullable = () => kind === "absent" + ? { kind } : kind === "not_selected" ? { kind, entryId: "picker" } : { kind, reason: "profile_missing" }; + expect(await collectDesktopSystemProxy(config, { ...deps, inspectPickerProfile })).toEqual(verdict("conflict")); + } + const inspectPickerProfile: NonNullable = () => + ({ kind: "applied", entryId: "picker", proxyUrl: "http://127.0.0.1:10201" }); + for (const pickerRelayAvailable of [() => false, async () => { throw new Error("relay unavailable"); }]) { + expect(await collectDesktopSystemProxy(config, { ...deps, inspectPickerProfile, pickerRelayAvailable })).toEqual(verdict("conflict")); + } + expect(await collectDesktopSystemProxy(config, { ...deps, inspectPickerProfile: untouchable })).toEqual(verdict("conflict")); + expect(registryReads).toBe(12); + }); + + test("picker off does not hide registry diagnostics behind a leftover selected profile", async () => { + expect(await collectDesktopSystemProxy({ ...config, claudeCode: { ...config.claudeCode, intercept: { picker: false } } }, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable, + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + })).toEqual(verdict("conflict")); + }); + + test("available picker relay skips both registry reads without checking picker trust or effectiveness", async () => { + const urls: string[] = []; + expect(await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => ({ kind: "applied", entryId: "picker", proxyUrl: "http://127.0.0.1:10201" }), + pickerRelayAvailable: async url => { urls.push(url); return true; }, + readSystemProxy: untouchable, readBypass: untouchable, + })).toEqual({ kind: "not-applicable" }); + expect(urls).toEqual(["http://127.0.0.1:10201"]); + }); + + test("a foreign HTTP listener or credential-bearing intercept proxy is not a Desktop picker relay", async () => { + const foreign = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("Only CONNECT", { + status: 405, headers: { Allow: "CONNECT" }, + }) }); + const intercept = await startConnectProxy(0, { interceptPort: 1, authToken: "synthetic-test-token", + dialUpstream: () => { throw new Error("doctor must not dial an upstream"); } }); + try { + for (const port of [foreign.port, intercept.port]) { + expect(await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => ({ kind: "applied", entryId: "picker", proxyUrl: `http://127.0.0.1:${port}` }), + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + })).toEqual(verdict("conflict")); + } + } finally { + foreign.stop(true); + await intercept.close(); + } }); }); diff --git a/tests/claude-integration/claude-picker-trust-windows.test.ts b/tests/claude-integration/claude-picker-trust-windows.test.ts index d55081219f1..91d5b02b660 100644 --- a/tests/claude-integration/claude-picker-trust-windows.test.ts +++ b/tests/claude-integration/claude-picker-trust-windows.test.ts @@ -210,6 +210,9 @@ test("windows untrust removes by exact thumbprint and reports failures", async ( assertScriptShape(script, []); expect(script).toContain(SHA1); expect(script).toContain("Thumbprint -eq"); + expect(script).toContain(`$pickerName='${PICKER_CA_COMMON_NAME}'`); + expect(script).toContain("GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ne $pickerName"); + expect(script.indexOf("GetNameInfo")).toBeLessThan(script.indexOf("$store.Remove")); expect(await untrustPickerCaWindows(SHA1, capture(result("OCX_PICKER_TRUST=ERROR\n", 1)).run)) .toEqual({ ok: false }); const idle: WindowsTrustRunner = async () => { throw new Error("runner must stay idle"); }; @@ -235,10 +238,33 @@ test("the shared entry point dispatches win32 to the windows adapter", async () .toEqual({ ok: true }); expect(installed.argv[0]!.slice(0, -1)).toEqual([...WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX]); const removed = capture(result("OCX_PICKER_TRUST=REMOVED\n", 0)); - expect(await untrustPickerCa(LEAF, SHA1, removed.run, "win32")).toEqual({ ok: true }); + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-remove-")); + try { + const pem = mintPickerCa(PICKER_CA_COMMON_NAME), path = join(dir, "ca.pem"); + writeFileSync(path, pem); + expect(await untrustPickerCa(path, pickerCaFingerprints(pem).sha1, removed.run, "win32")).toEqual({ ok: true }); + } finally { rmSync(dir, { recursive: true, force: true }); } expect(removed.argv[0]!.slice(0, -1)).toEqual([...WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX]); }); +test("Windows removal refuses foreign recovery certificates but supports legacy picker roots", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-remove-identity-")); + const path = join(dir, "ca.pem"); + const foreign = createCertificateAuthority({ commonName: "Foreign application root" }).certPem; + const legacy = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME }).certPem; + const idle = capture(result("OCX_PICKER_TRUST=REMOVED\n", 0)); + try { + writeFileSync(path, foreign); + expect(await untrustPickerCa(path, pickerCaFingerprints(foreign).sha1, idle.run, "win32")).toEqual({ ok: false }); + expect(idle.argv).toHaveLength(0); + writeFileSync(path, legacy); + expect(await untrustPickerCa(path, SHA1, idle.run, "win32")).toEqual({ ok: false }); + expect(idle.argv).toHaveLength(0); + expect(await untrustPickerCa(path, pickerCaFingerprints(legacy).sha1, idle.run, "win32")).toEqual({ ok: true }); + expect(idle.argv).toHaveLength(1); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + const FAKE_TRUSTED_POWERSHELL = "C:\\trusted-system32\\WindowsPowerShell\\v1.0\\powershell.exe"; From 850cd689bf003315057f6b93fea0411ace026a86 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 18:35:56 +0900 Subject: [PATCH 27/27] fix(claude): retire picker trust before uninstall removes its records Full ocx uninstall stopped the relay and deleted the config directory without releasing the Desktop picker profile or untrusting the picker root, leaving a trusted root in the login keychain or the Windows current-user Root store and Desktop pinned to a stopped proxy. Run the picker cleanup and pending-predecessor drain after Desktop disconnect and before config removal, and refuse removal on any residual so ca.pem and the pending journal still name the root to untrust. Also bring structure/clients/claude-desktop.md back under its 600-line budget. --- src/cli/uninstall-client-state.ts | 32 +++++++++++++++++++++++++++++ structure/clients/claude-desktop.md | 24 ++++++---------------- tests/cli/uninstall.test.ts | 32 +++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/cli/uninstall-client-state.ts b/src/cli/uninstall-client-state.ts index f200bf11b1f..2b6e11377e7 100644 --- a/src/cli/uninstall-client-state.ts +++ b/src/cli/uninstall-client-state.ts @@ -5,9 +5,13 @@ import { assertClientLifecycleHeld, withClientLifecycle } from "../client/lifecy import { inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store"; import { removeOwnedConfigState, type ConfigRemovalResult } from "../lib/config-ownership"; import { windowsSecretAclReapPendingAtOrBelow } from "../lib/windows-secret-acl"; +import { supportsDesktopPicker } from "../claude/desktop-picker-platform"; import { sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { cleanupOwnedIntegrationsBeforeUninstall } from "./uninstall-integrations"; +/** Outcome of retiring the Desktop picker profile and every picker root this install trusted. */ +export interface PickerUninstallCleanup { ok: boolean; residual?: string[] } + export interface UninstallClientStateDeps { readConnection: typeof readClientConnectionState; inspectDesktop: typeof inspectRemoteDesktopCleanup; @@ -18,6 +22,25 @@ export interface UninstallClientStateDeps { remove: () => ConfigRemovalResult; /** True while a timed-out icacls child still owns a path at or below the config directory. */ aclReapPending: (rootPath: string) => boolean; + /** Release the picker profile, then untrust the current and any pending predecessor picker root. */ + removePickerArtifacts: () => Promise; +} + +/** + * The picker root lives in the OS trust store (login keychain, CurrentUser Root on Windows), outside + * the config directory. Deleting config first would erase ca.pem and the pending-untrust journal, + * the only records naming which root to remove, and strand a trusted root after uninstall. + */ +async function removePickerArtifactsBeforeUninstall(): Promise { + if (!supportsDesktopPicker()) return { ok: true }; + const configDir = getConfigDir(); + const [{ removeDesktopPickerArtifacts }, { drainPendingPickerCaUntrust }] = await Promise.all([ + import("../claude/desktop-picker"), + import("../claude/intercept/picker-ca-cleanup"), + ]); + const removed = await removeDesktopPickerArtifacts({ configDir }); + if (!removed.ok) return removed; + return await drainPendingPickerCaUntrust(configDir) ? { ok: true } : { ok: false, residual: ["pending_trust"] }; } const defaults: UninstallClientStateDeps = { @@ -29,6 +52,7 @@ const defaults: UninstallClientStateDeps = { cleanupIntegrations: cleanupOwnedIntegrationsBeforeUninstall, remove: () => removeOwnedConfigState(getConfigDir()), aclReapPending: rootPath => windowsSecretAclReapPendingAtOrBelow(rootPath), + removePickerArtifacts: removePickerArtifactsBeforeUninstall, }; /** Restore connection-owned client artifacts before removing their ownership/recovery records. */ @@ -67,6 +91,14 @@ export async function removeOwnedConfigAfterDesktopCleanup( throw new Error("Client cleanup refused: finish Desktop recovery before uninstalling."); } + // Picker cleanup takes L itself, so it runs before the final lock. A residual keeps every + // record the retry needs: the config directory still names the root to untrust. + const picker = await deps.removePickerArtifacts(); + if (!picker.ok) { + throw new Error(`Client cleanup refused: Claude Desktop picker cleanup left ${picker.residual?.join(", ") || "state"} behind; ` + + "approve the certificate removal prompt or run 'ocx claude desktop picker off', then rerun 'ocx uninstall'."); + } + // A connection can appear while asynchronous cleanup is finishing. The final // inspection and actual remover share L, but never hold C while deleting its DB. return deps.withLifecycle(async held => { diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index acd2cd34227..aaec5f7ca48 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -121,11 +121,7 @@ and the auto-detect (WPAD) flag in `Connections\DefaultConnectionSettings`, and a bypass, no covering proxy, or an undecidable PAC script or WPAD. A failed registry read is reported as unreadable, never as an absent value, and a stale settings env never earns an `ok`. It never prints the proxy value and never records a doctor failure, because the CLI and other clients -still route. An applied, selected picker profile uses Desktop's app-owned fixed egress proxy -while that proxy is available, including when missing trust leaves the picker pending and -`claude.ai` tunnels relay blindly. Missing trust preserves the profile and its selection. -The system-proxy observation applies to picker-off installs and pending installs with no -applied picker profile. +still route. An applied, selected picker profile uses Desktop's app-owned fixed egress proxy while that proxy is available, including when missing trust leaves the picker pending and `claude.ai` tunnels relay blindly. Missing trust preserves the profile and its selection. The system-proxy observation applies to picker-off installs and pending installs with no applied picker profile. Doctor verifies the selected profile's loopback CONNECT relay directly, independently of picker trust or effectiveness. Off, absent, unselected, unreadable, stale-settings and unavailable-relay cases retain the registry diagnostic; a credential-bearing main intercept proxy does not count as Desktop's egress relay. Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; @@ -192,10 +188,7 @@ Windows trust removal in `src/claude/intercept/picker-trust-windows.ts` requires Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo. The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. -Gateway remains the default Desktop mode. On macOS and Windows, picker intent defaults on -only within the chosen first-party mode: either an explicit Desktop integration `false` or -`claudeCode.intercept.picker: false` disables it. Default intent does not install certificate -trust; startup and status only inspect trust, and explicit activation owns installation. +Gateway remains the default Desktop mode. On macOS and Windows, picker intent defaults on only within the chosen first-party mode: either an explicit Desktop integration `false` or `claudeCode.intercept.picker: false` disables it. Default intent does not install certificate trust; startup and status only inspect trust, and explicit activation owns installation. On macOS and Windows, when the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also wires Claude Desktop picker mode: a second loopback CONNECT proxy on the dedicated picker proxy @@ -270,8 +263,7 @@ stored in that directory; public certificates and non-secret identity metadata r `/claude-picker/`. Every unbound intercept startup attempt makes a best-effort cleanup of legacy `ca.key` before eligibility checks, including client role, disabled routing/interception, and ephemeral public ports; cleanup failures do not block startup. See the [runtime lifecycle contract](../runtime.md#claude-intercept-pair). Linux skips picker CA, credential-store and proxy construction entirely; the main intercept pair remains available. On restart the lifecycle keeps the applied profile and restores through the controller with `allowTrustPrompt: false`. An unchanged approved identity with an available credential store needs -no macOS Certificate Trust Settings add/remove operation. Missing, revoked or unknown trust leaves the -picker pending; restart and status never install trust. Explicit `on`, `trust`, or first-party apply completes the trust step, including the Windows current-user Root confirmation when needed. +no macOS Certificate Trust Settings add/remove operation. Missing, revoked or unknown trust leaves the picker pending; restart and status never install trust. Explicit `on`, `trust`, or first-party apply completes the trust step, including the Windows current-user Root confirmation when needed. Legacy predecessor cleanup may still require consent during migration. Native keychain unlock and application-access dialogs are controlled by macOS; restart or upgrade does not guarantee their absence. `picker-ca-store.ts` owns the versioned OS credential service, canonical-config identity namespace, @@ -288,9 +280,7 @@ activation, which uses the same persistent authority path. On macOS trust is added without a policy string: Chromium skips host-scoped trust settings, so `inspectPickerTrust` treats a current CA whose exported user trust settings carry `kSecTrustSettingsPolicyString` as untrusted and an explicit trust step replaces it; an -export it cannot read makes trust `unknown`, which never arms. On Windows trust is the current-user Root store entry matching the exact fingerprint with a verified leaf. The PowerShell runner terminates its child on timeout or output-read failure and preserves the failure category. A rotated-out picker certificate is -removed from the login keychain on macOS or the current-user Root store on Windows as its replacement is published, and a failed removal stops the -picker arming. Publication of `ca.pem` and `ca-owner.json` happens only inside the +export it cannot read makes trust `unknown`, which never arms. On Windows trust is the current-user Root store entry matching the exact fingerprint with a verified leaf. The PowerShell runner terminates its child on timeout or output-read failure and preserves the failure category. A rotated-out picker certificate is removed from the login keychain on macOS or the current-user Root store on Windows as its replacement is published, and a failed removal stops the picker arming. Publication of `ca.pem` and `ca-owner.json` happens only inside the `ca.lock.sqlite` lock (`picker-ca.ts`): lock acquisition is reported separately from the callback, so a busy lock publishes nothing, and a missing or mismatched owner record for our own certificate is rewritten under the lock so a second process cannot rotate out a live owner's @@ -352,11 +342,9 @@ enable attempt added trust and a later check or profile write fails, it removes an earlier successful picker profile keeps the trust it needs. The owned profile helpers in `src/claude/desktop-picker-profile.ts` use the standard row `opencodex-picker`, whose file contains only `egressProxyUrl`. The previous Desktop selection is stored in -`/claude-picker/profile-state.json`, never in Desktop's `_meta.json`. On Windows the same row is written through the existing `%LOCALAPPDATA%` configLibrary writer. +`/claude-picker/profile-state.json`, never in Desktop's `_meta.json`. On Windows the same row is written through the existing `%LOCALAPPDATA%` configLibrary writer. Full `ocx uninstall` (`src/cli/uninstall-client-state.ts`) runs the same picker cleanup after Desktop disconnect and before config removal: profile release, current-root untrust, then the pending-predecessor drain. Any residual refuses removal, so `ca.pem` and the pending journal still name the root a retry must untrust. -The local controls are `ocx claude desktop picker on|off|status|trust`; CLI help, toggle messages and GUI trust states use OS-neutral certificate-trust wording. With a live server, `on`, -`off`, and transition cleanup use the controller; `trust` performs the operator's local OS trust -step (login keychain on macOS, current-user Root confirmation on Windows) and then reports the result to the server. Before installing the root, the CLI independently +The local controls are `ocx claude desktop picker on|off|status|trust`; CLI help, toggle messages and GUI trust states use OS-neutral certificate-trust wording. With a live server, `on`, `off`, and transition cleanup use the controller; `trust` performs the operator's local OS trust step (login keychain on macOS, current-user Root confirmation on Windows) and then reports the result to the server. Before installing the root, the CLI independently requires the picker common name on a self-signed CA and the exact critical `claude.ai`-only DNS and all-IP exclusion constraints, plus the full minted extension profile — critical `CA:TRUE` basicConstraints, a `keyCertSign|cRLSign`-only keyUsage, a non-critical subjectKeyIdentifier, and diff --git a/tests/cli/uninstall.test.ts b/tests/cli/uninstall.test.ts index 74d99e1429a..632f457f83f 100644 --- a/tests/cli/uninstall.test.ts +++ b/tests/cli/uninstall.test.ts @@ -332,11 +332,14 @@ function uninstallFixture() { finishCleanup: boolean; lease?: ClientLifecycleHeld; aclReapPending: boolean; + picker: { ok: boolean; residual?: string[] }; + pickerCleanup: Array<{ disconnectsBefore: number; finalLocksBefore: number }>; calls: { read: number; cleanup: number; integrations: number; remove: number; finalLock: number }; cleanupOptions: Array[0]>; } = { connection: { kind: "disconnected" }, desktop: { kind: "absent" }, receipt: { kind: "absent" }, finishCleanup: true, aclReapPending: false, + picker: { ok: true }, pickerCleanup: [], calls: { read: 0, cleanup: 0, integrations: 0, remove: 0, finalLock: 0 }, cleanupOptions: [], }; const deps: UninstallClientStateDeps = { @@ -387,6 +390,12 @@ function uninstallFixture() { return { status: "removed", residualPaths: [] }; }, aclReapPending: () => fixture.aclReapPending, + removePickerArtifacts: async () => { + // Picker cleanup takes the same L; reaching it here proves uninstall does not hold L yet. + withClientLifecycleSync(held => assertClientLifecycleHeld(held), { lockPath }); + fixture.pickerCleanup.push({ disconnectsBefore: fixture.calls.cleanup, finalLocksBefore: fixture.calls.finalLock }); + return fixture.picker; + }, }; const bytes = () => sentinels.map(path => readFileSync(join(configDir, path), "utf8")); return { fixtureRoot, configDir, lockPath, fixture, deps, bytes }; @@ -487,6 +496,28 @@ describe("uninstall client cleanup before owner-state deletion", () => { }); }); + test("picker trust is retired after disconnect and outside L, before config removal", async () => { + await withUninstallFixture(async f => { + f.fixture.connection = connectedFixture; + f.fixture.desktop = { kind: "active", owner: cleanupOwner }; + await removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps); + expect(f.fixture.pickerCleanup).toEqual([{ disconnectsBefore: 1, finalLocksBefore: 0 }]); + expect(f.fixture.calls.remove).toBe(1); + }); + }); + + test("a picker trust residual preserves the records naming the root and skips removal", async () => { + await withUninstallFixture(async f => { + f.fixture.picker = { ok: false, residual: ["trust"] }; + const before = f.bytes(); + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)) + .rejects.toThrow("Claude Desktop picker cleanup left trust behind"); + expect(f.fixture.calls.integrations).toBe(0); + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + }); + }); + test("a replacement connection before disconnect claims L survives uninstall", async () => { await withUninstallFixture(async f => { f.fixture.connection = connectedFixture; @@ -629,6 +660,7 @@ describe("uninstall restores recorded third-party integrations before deleting r return { status: "removed", residualPaths: [] }; }, aclReapPending: () => false, + removePickerArtifacts: async () => ({ ok: true }), }; return { root, home, env, configDir, clientConfig, store, deps }; }