From 678517f56c322bdf2e55c8e380d9004ad8a80e65 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 25 Aug 2026 19:29:33 +0900 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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 8db8c3d60413fdd255231d07fd99dbcb9a1c596f Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:49:48 +0800 Subject: [PATCH 18/19] feat: ChatGPT desktop send-unblock intercept (opt-in) Adds an opt-in `chatgptDesktop.unblockSend` integration for the ChatGPT desktop app. When enabled, the service binds a local TLS listener for `chatgpt.com` (shared intercept CA, port defaults to public port + 200) and rewrites the subscription-quota send locks out of the payloads the desktop app reads: - `blocked_features[send|tpp_send]` and `limits_progress[send]` entries in conversation payloads - `rate_limit.allowed` / `rate_limit.limit_reached` gate flags in the `/backend-api/wham/usage` snapshot and usage stream Quota display stays honest: percentages, reset timestamps and the upsell banner pass through byte-identical, so the app keeps showing the account's real usage while the composer unlocks for turns whose model calls are routed to third-party providers by opencodex. Design notes: - Launch rule (`--host-resolver-rules=MAP chatgpt.com 127.0.0.1:`) is printed at startup; only the exact host `chatgpt.com` is mapped, so auth.openai.com and the codex-cloud WebSocket stay native. - Fire-and-forget lifecycle like the Claude intercept: a bind failure degrades to a warning and never blocks startup. - SSE is rewritten line-buffered; untouched streams keep their exact chunking and line endings. - Config group is opt-in, off by default, malformed reads as off. Tests: 11 cases over JSON/SSE rewrite, gate flipping, unchanged detection, malformed-entry passthrough and display-field preservation. --- src/chatgpt/desktop-unblock/listener.ts | 140 ++++++++++++++++ src/chatgpt/desktop-unblock/rewrite.ts | 146 +++++++++++++++++ src/chatgpt/desktop-unblock/runtime.ts | 75 +++++++++ src/config/schema/config-schema.ts | 6 + src/server/index.ts | 4 + src/server/index/chatgpt-unblock-lifecycle.ts | 34 ++++ src/types/config.ts | 15 ++ tests/chatgpt-unblock/rewrite.test.ts | 151 ++++++++++++++++++ 8 files changed, 571 insertions(+) create mode 100644 src/chatgpt/desktop-unblock/listener.ts create mode 100644 src/chatgpt/desktop-unblock/rewrite.ts create mode 100644 src/chatgpt/desktop-unblock/runtime.ts create mode 100644 src/server/index/chatgpt-unblock-lifecycle.ts create mode 100644 tests/chatgpt-unblock/rewrite.test.ts diff --git a/src/chatgpt/desktop-unblock/listener.ts b/src/chatgpt/desktop-unblock/listener.ts new file mode 100644 index 00000000000..9b4bdf4af95 --- /dev/null +++ b/src/chatgpt/desktop-unblock/listener.ts @@ -0,0 +1,140 @@ +import type { Server } from "bun"; +import type { PemKeyPair } from "../../claude/intercept/local-ca"; +import { forwardHeadersForUpstream } from "../../claude/intercept/listener"; +import { stripSendBlocksFromJson, stripSendBlocksFromSseLine } from "./rewrite"; + +/** + * TLS listener for the ChatGPT desktop send-unblock intercept. + * + * Launched with `--host-resolver-rules="MAP chatgpt.com 127.0.0.1:"`, the desktop app + * dialls this listener believing it reached chatgpt.com. Requests are relayed verbatim to the + * real upstream with the caller's own auth headers; responses pass through untouched except + * that conversation payloads lose their client-side send-lock entries. Nothing is logged and + * no credential is persisted -- the listener is a pipe, not a store. + * + * Only the exact host `chatgpt.com` is ever presented here. Subdomains (`ab.chatgpt.com`, + * `codex-cloud-backend.chatgpt.com`) and `auth.openai.com` are not mapped by the launcher, so + * login, telemetry and cloud sessions stay native. + */ + +export const CHATGPT_UNBLOCK_UPSTREAM = "https://chatgpt.com"; +export const CHATGPT_INTERCEPT_HOST = "chatgpt.com"; + +// fetch() transparently decodes the body, so the encoding headers would describe bytes the +// client never sees. +const RESPONSE_STRIP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", +]); + +export interface ChatgptUnblockListenerOptions { + leaf: PemKeyPair; + upstreamBase?: string; + idleTimeout?: number; + fetchImpl?: typeof fetch; + /** Test seam: bind a fixed port instead of an ephemeral one. */ + port?: number; +} + +function responseHeaders(source: Response): Headers { + const headers = new Headers(); + source.headers.forEach((value, name) => { + if (!RESPONSE_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +/** + * Line-oriented SSE rewriter. Complete lines are checked one at a time so an untouched stream + * keeps its exact chunking and line endings; only `data:` lines whose JSON loses an entry are + * re-serialized. + */ +export function sseRewriteStream(debug?: (line: string, rewritten: string | null) => void): TransformStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + return new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }); + let index: number; + while ((index = pending.indexOf("\n")) !== -1) { + const line = pending.slice(0, index); + pending = pending.slice(index + 1); + const rewritten = stripSendBlocksFromSseLine(line); + debug?.(line, rewritten); + controller.enqueue(encoder.encode(`${rewritten ?? line}\n`)); + } + }, + flush(controller) { + if (pending.length === 0) return; + const rewritten = stripSendBlocksFromSseLine(pending); + debug?.(pending, rewritten); + controller.enqueue(encoder.encode(rewritten ?? pending)); + pending = ""; + }, + }); +} + +function isJsonContentType(contentType: string): boolean { + return contentType.includes("application/json") || contentType.endsWith("+json"); +} + +function isEventStreamContentType(contentType: string): boolean { + return contentType.includes("text/event-stream"); +} + +export async function relayWithSendUnblock( + req: Request, + upstreamBase: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = new URL(req.url); + const target = `${upstreamBase.replace(/\/$/, "")}${url.pathname}${url.search}`; + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + let upstream: Response; + try { + upstream = await fetchImpl(target, { + method: req.method, + headers: forwardHeadersForUpstream(req.headers), + body: hasBody ? req.body : undefined, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); + } catch (error) { + return Response.json( + { error: { message: `chatgpt unblock relay failed: ${error instanceof Error ? error.message : String(error)}` } }, + { status: 502 }, + ); + } + const headers = responseHeaders(upstream); + const contentType = upstream.headers.get("content-type") ?? ""; + if (isJsonContentType(contentType)) { + let text: string; + try { + text = await upstream.text(); + } catch { + return new Response(JSON.stringify({ error: { message: "chatgpt unblock upstream read failed" } }), { status: 502, headers }); + } + const rewritten = stripSendBlocksFromJson(text); + return new Response(rewritten ?? text, { status: upstream.status, statusText: upstream.statusText, headers }); + } + if (isEventStreamContentType(contentType) && upstream.body) { + return new Response(upstream.body.pipeThrough(sseRewriteStream()), { status: upstream.status, statusText: upstream.statusText, headers }); + } + return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers }); +} + +/** Bind the intercept TLS listener on an ephemeral loopback port. */ +export function startChatgptUnblockListener(options: ChatgptUnblockListenerOptions): Server { + const upstreamBase = options.upstreamBase ?? CHATGPT_UNBLOCK_UPSTREAM; + return Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert: options.leaf.certPem, key: options.leaf.keyPem }, + idleTimeout: options.idleTimeout ?? 255, + async fetch(req) { + return relayWithSendUnblock(req, upstreamBase, options.fetchImpl); + }, + }); +} diff --git a/src/chatgpt/desktop-unblock/rewrite.ts b/src/chatgpt/desktop-unblock/rewrite.ts new file mode 100644 index 00000000000..91719c13d88 --- /dev/null +++ b/src/chatgpt/desktop-unblock/rewrite.ts @@ -0,0 +1,146 @@ +/** + * Send-unblock rewriting for the ChatGPT desktop intercept. + * + * The ChatGPT desktop app disables the conversation composer from two backend data shapes: + * + * 1. Conversation payloads (`/conversation/init` and friends) attach `blocked_features` + * entries named `send` (or `tpp_send`) and `limits_progress` entries for `send` with + * `remaining <= 0`. + * 2. The desktop usage snapshot (`/backend-api/wham/usage[/stream]`) carries + * `rate_limit.allowed: false` + `rate_limit.limit_reached: true` while the logged-in + * ChatGPT subscription quota is exhausted. + * + * Both describe the account's own subscription quota -- data that is meaningless for turns + * whose model calls are routed to third-party providers by opencodex. + * + * The rewriter removes exactly the send-lock entries and flips exactly the usage gate flags. + * Quota display stays honest: `banner_info` / `rate_limit_upsell`, the `used_percent`, + * `reset_at` and window fields, `model_limits`, `model_usage` and every other key pass + * through untouched, so the app keeps showing the account's real usage while the composer + * unlocks. + */ + +/** `blocked_features[].name` values the desktop composer treats as a send lock. */ +const SEND_BLOCKED_FEATURE_NAMES = new Set(["send", "tpp_send"]); + +/** `limits_progress[].feature_name` value for the composer's send gate. */ +const SEND_LIMIT_FEATURE_NAME = "send"; + +export interface RewriteResult { + value: unknown; + changed: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSendBlockedFeature(entry: unknown): boolean { + return isRecord(entry) && SEND_BLOCKED_FEATURE_NAMES.has(String(entry.name ?? "")); +} + +function isExhaustedSendLimit(entry: unknown): boolean { + if (!isRecord(entry) || entry.feature_name !== SEND_LIMIT_FEATURE_NAME) return false; + const remaining = entry.remaining; + return typeof remaining === "number" && remaining <= 0; +} + +/** + * Recursively strip send-lock entries from any `blocked_features` / `limits_progress` arrays. + * Malformed entries are kept: the rewrite owns removal of known-shaped blocks, not validation. + */ +export function stripSendBlocks(value: unknown): RewriteResult { + if (Array.isArray(value)) { + let changed = false; + const items = value.map(item => { + const result = stripSendBlocks(item); + changed ||= result.changed; + return result.value; + }); + return { value: items, changed }; + } + if (!isRecord(value)) return { value, changed: false }; + let changed = false; + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "blocked_features" && Array.isArray(child)) { + const kept = child.filter(entry => !isSendBlockedFeature(entry)); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + if (key === "limits_progress" && Array.isArray(child)) { + const kept = child.filter(entry => !isExhaustedSendLimit(entry)); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + const result = stripSendBlocks(child); + changed ||= result.changed; + out[key] = result.value; + } + return { value: out, changed }; +} + +/** + * Flip the desktop usage snapshot's send gate in place: `rate_limit.allowed` false -> true and + * `rate_limit.limit_reached` true -> false, at any depth (top-level for snapshot endpoints, + * under `usage` for stream events). Window percentages, reset timestamps, the upsell banner + * and every other display field are left exactly as the backend sent them. + * + * Returns whether anything changed. + */ +export function unlockRateLimitGate(value: unknown): boolean { + let changed = false; + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + if (!isRecord(node)) return; + const rateLimit = node.rate_limit; + if (isRecord(rateLimit)) { + if (rateLimit.allowed === false) { + rateLimit.allowed = true; + changed = true; + } + if (rateLimit.limit_reached === true) { + rateLimit.limit_reached = false; + changed = true; + } + } + for (const child of Object.values(node)) visit(child); + }; + visit(value); + return changed; +} + +/** + * Rewrite a JSON response body. Returns `null` when the body is not valid JSON or contains + * nothing to rewrite, so callers can pass the original bytes through untouched. + */ +export function stripSendBlocksFromJson(text: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + const stripped = stripSendBlocks(parsed); + const unlocked = unlockRateLimitGate(stripped.value); + return stripped.changed || unlocked ? JSON.stringify(stripped.value) : null; +} + +/** + * Rewrite a single SSE line. ChatGPT conversation and usage-stream events carry one JSON + * document per `data:` line; lines that parse to a payload with send blocks or a closed usage + * gate are replaced, everything else passes through byte-identical. Returns `null` when the + * line is unchanged. + */ +export function stripSendBlocksFromSseLine(line: string): string | null { + const match = /^(data: ?)(.*)$/.exec(line); + if (!match) return null; + const rewritten = stripSendBlocksFromJson(match[2]!); + if (rewritten === null) return null; + return `${match[1]}${rewritten}`; +} diff --git a/src/chatgpt/desktop-unblock/runtime.ts b/src/chatgpt/desktop-unblock/runtime.ts new file mode 100644 index 00000000000..2171ee85f90 --- /dev/null +++ b/src/chatgpt/desktop-unblock/runtime.ts @@ -0,0 +1,75 @@ +import type { Server } from "bun"; +import type { OcxConfig } from "../../types"; +import { getConfigDir } from "../../config/paths"; +import { + claudeInterceptCaCertPath, + ensureLocalInterceptCaForStartup, + issueLocalInterceptLeaf, +} from "../../claude/intercept/local-ca"; +import { CHATGPT_INTERCEPT_HOST, startChatgptUnblockListener } from "./listener"; + +/** + * Lifecycle for the ChatGPT desktop send-unblock listener. + * + * Opt-in via `chatgptDesktop.unblockSend`. The listener shares the Claude intercept authority + * (one trusted certificate covers both features) and binds a stable loopback port derived from + * the public port so the launcher's `--host-resolver-rules` value survives restarts. A bind + * failure degrades to a warning exactly like the Claude intercept pair: the proxy's other + * duties never depend on this listener existing. + */ + +export const CHATGPT_UNBLOCK_PORT_OFFSET = 200; + +export function chatgptUnblockEnabled(config: Pick): boolean { + if (config.runtimeRole === "client") return false; + return config.chatgptDesktop?.unblockSend === true; +} + +export function chatgptUnblockPort(config: Pick, publicPort: number): number { + const configured = config.chatgptDesktop?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + return publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; +} + +/** The resolver rule to hand the ChatGPT desktop app at launch. */ +export function chatgptUnblockResolverRule(port: number): string { + return `MAP ${CHATGPT_INTERCEPT_HOST} 127.0.0.1:${port}`; +} + +export interface ChatgptUnblockState { + port: number; + caCertPath: string; +} + +export interface ChatgptUnblockHandle extends ChatgptUnblockState { + listener: Server; + stop(): Promise; +} + +export interface StartChatgptUnblockOptions { + config: OcxConfig; + /** Bound public port; the derived listener port is offset from it. */ + publicPort: number; + configDir?: string; +} + +/** + * Bind the listener. Resolves `null` when the feature is disabled. A bind failure is reported + * by rejecting; callers treat it as a degraded optional integration, never a startup failure. + */ +export async function startChatgptUnblock(options: StartChatgptUnblockOptions): Promise | null> { + if (!chatgptUnblockEnabled(options.config)) return null; + const configDir = options.configDir ?? getConfigDir(); + const ca = await ensureLocalInterceptCaForStartup(configDir); + const leaf = issueLocalInterceptLeaf(ca, [CHATGPT_INTERCEPT_HOST]); + // The port must be the configured one, not ephemeral: the launcher's resolver rule names it. + const listener = startChatgptUnblockListener({ leaf, port: chatgptUnblockPort(options.config, options.publicPort) }); + return { + port: listener.port ?? chatgptUnblockPort(options.config, options.publicPort), + caCertPath: claudeInterceptCaCertPath(configDir), + listener, + stop: async () => { + await listener.stop(true); + }, + }; +} diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b072b615032..50a4cb5c34a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -230,6 +230,12 @@ export const configSchema = z.object({ enabled: z.boolean().optional(), leadTimeMinutes: z.number().int().min(1).max(60).optional(), }).optional().catch(undefined), + // ChatGPT desktop send-unblock (opt-in, default off). Same degrade-to-off rule: a malformed + // group must never cost the operator their other settings. + chatgptDesktop: z.object({ + unblockSend: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + }).optional().catch(undefined), // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool // feature must never cost the operator their providers. pool: z.object({ diff --git a/src/server/index.ts b/src/server/index.ts index 187bbdc21e0..fb57b7d6877 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -196,6 +196,7 @@ import { import { createReadinessGate, type ReadinessGate } from "./readiness"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle"; +import { createChatgptUnblockLifecycle } from "./index/chatgpt-unblock-lifecycle"; import { createPackageTreeIntegrityGuardForServer } from "./index/package-tree-guard"; import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; @@ -623,6 +624,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe let loopbackServer: Server | null = null; let managementIngressServer: Server | null = null; const claudeIntercept = createClaudeInterceptLifecycle(); + const chatgptUnblock = createChatgptUnblockLifecycle(); const inboundBodyLimitBytes = resolveInboundBodyLimitWithWarning(config); function ingressForServer(requestServer: Server): ServerIngress { @@ -734,6 +736,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe config, publicPort: server.port ?? listenPort, requestedPort: listenPort, maxRequestBodySize: inboundBodyLimitBytes, dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer), }); + chatgptUnblock.start({ config, publicPort: server.port ?? listenPort }); } catch (error) { unregisterQuotaAutoRefresh?.(); userCostOverlayReconciler?.stop(); @@ -767,6 +770,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe ? [() => managementIngressRef.stop(closeActiveConnections)] : []), () => claudeIntercept.stop(), + () => chatgptUnblock.stop(), async () => { await remoteWorkspaceShutdown?.(); }, async () => { try { diff --git a/src/server/index/chatgpt-unblock-lifecycle.ts b/src/server/index/chatgpt-unblock-lifecycle.ts new file mode 100644 index 00000000000..95bcef64b09 --- /dev/null +++ b/src/server/index/chatgpt-unblock-lifecycle.ts @@ -0,0 +1,34 @@ +import type { ChatgptUnblockHandle, StartChatgptUnblockOptions } from "../../chatgpt/desktop-unblock/runtime"; +import { chatgptUnblockResolverRule, startChatgptUnblock } from "../../chatgpt/desktop-unblock/runtime"; + +/** + * Owns the ChatGPT desktop send-unblock listener on behalf of `startServer`. The listener is + * an optional integration: a bind failure degrades to a warning, never to a startup failure, + * because every other duty keeps working without it. `startServer` stays synchronous, so the + * start is fire-and-forget and `stop()` awaits whatever it produced. + */ +export interface ChatgptUnblockLifecycle { + start(options: StartChatgptUnblockOptions): void; + stop(): Promise; +} + +export function createChatgptUnblockLifecycle(): ChatgptUnblockLifecycle { + let pending: Promise | null> = Promise.resolve(null); + return { + start(options) { + pending = startChatgptUnblock(options).then(handle => { + if (handle) { + console.log(`🔓 ChatGPT send-unblock active on https://127.0.0.1:${handle.port} (CA: ${handle.caCertPath})`); + console.log(` Launch the ChatGPT app with: open -a ChatGPT --args --host-resolver-rules='${chatgptUnblockResolverRule(handle.port)}'`); + } + return handle; + }).catch((error: unknown) => { + console.warn(`⚠ ChatGPT send-unblock could not start: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + }, + async stop() { + await (await pending)?.stop(); + }, + }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 6fc6939cce8..557220b345a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -955,6 +955,21 @@ export interface OcxConfig { * spends a second credit. A malformed value reads as off. */ resetCreditAutoRedeem?: { enabled?: boolean; leadTimeMinutes?: number }; + /** + * ChatGPT desktop-app integration, opt-in and off by default. + * + * With `unblockSend: true` the service binds a local TLS listener for `chatgpt.com` and + * rewrites the subscription-quota send locks out of the payloads the desktop app reads: + * `blocked_features[send]` / `limits_progress[send]` entries in conversation payloads, and + * `rate_limit.allowed` / `rate_limit.limit_reached` in the `/backend-api/wham/usage` + * snapshot and stream. Quota display (percentages, reset times, upsell banner) is left + * untouched, so the app keeps showing the account's real usage while the composer unlocks + * for turns whose model calls are routed to third-party providers. The app must be launched + * with the resolver rule printed at startup, and the intercept CA must be trusted once (see + * the startup log). A malformed value reads as off. `port` (1–65535) overrides the default + * listener port (public port + 200). + */ + chatgptDesktop?: { unblockSend?: boolean; port?: number }; /** * Shared account-pool kernel, opt-in and off by default. * diff --git a/tests/chatgpt-unblock/rewrite.test.ts b/tests/chatgpt-unblock/rewrite.test.ts new file mode 100644 index 00000000000..9e90830ca56 --- /dev/null +++ b/tests/chatgpt-unblock/rewrite.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { stripSendBlocks, stripSendBlocksFromJson, stripSendBlocksFromSseLine, unlockRateLimitGate } from "../../src/chatgpt/desktop-unblock/rewrite"; + +const blockedPayload = { + banner_info: { + name: "codex_limit_reached", + banner_type: "text", + resets_after: "2026-09-26T19:46:00Z", + }, + blocked_features: [ + { name: "send", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + { name: "tpp_send", block_reason: "work_subscription_required", resets_after: null }, + { name: "image_gen", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + ], + limits_progress: [ + { feature_name: "send", remaining: 0, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "reason", remaining: 3, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "send", remaining: 2, reset_after: "2026-09-26T19:46:00Z" }, + ], + model_limits: [{ model_slug: "gpt-5-codex" }], +}; + +describe("stripSendBlocks", () => { + test("removes send locks and keeps quota display data", () => { + const result = stripSendBlocks(blockedPayload); + expect(result.changed).toBe(true); + const value = result.value as typeof blockedPayload; + expect(value.blocked_features).toEqual([ + { name: "image_gen", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + ]); + // An exhausted `send` limit is removed; a non-exhausted one and other features stay. + expect(value.limits_progress).toEqual([ + { feature_name: "reason", remaining: 3, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "send", remaining: 2, reset_after: "2026-09-26T19:46:00Z" }, + ]); + // Display data is untouched. + expect(value.banner_info).toEqual(blockedPayload.banner_info); + expect(value.model_limits).toEqual(blockedPayload.model_limits); + }); + + test("reports unchanged payloads and leaves non-object input alone", () => { + expect(stripSendBlocks(blockedPayload).changed).toBe(true); + expect(stripSendBlocks({ blocked_features: [] }).changed).toBe(false); + expect(stripSendBlocks({ limits_progress: [{ feature_name: "send", remaining: 1 }] }).changed).toBe(false); + expect(stripSendBlocks("text").changed).toBe(false); + expect(stripSendBlocks(null).changed).toBe(false); + }); + + test("keeps malformed entries and recurses into nested payloads", () => { + const nested = { conversation: { blocked_features: [{ name: "send" }, "junk", 7] } }; + const result = stripSendBlocks(nested); + expect(result.changed).toBe(true); + expect((result.value as typeof nested).conversation.blocked_features).toEqual(["junk", 7]); + }); +}); + +describe("stripSendBlocksFromJson", () => { + test("rewrites a conversation-init style body", () => { + const rewritten = stripSendBlocksFromJson(JSON.stringify(blockedPayload)); + expect(rewritten).not.toBeNull(); + const parsed = JSON.parse(rewritten!) as typeof blockedPayload; + expect(parsed.blocked_features).toHaveLength(1); + expect(parsed.blocked_features[0]!.name).toBe("image_gen"); + expect(parsed.banner_info).toEqual(blockedPayload.banner_info); + }); + + test("returns null for invalid JSON and clean payloads", () => { + expect(stripSendBlocksFromJson("not json")).toBeNull(); + expect(stripSendBlocksFromJson(JSON.stringify({ banner_info: null }))).toBeNull(); + }); +}); + +describe("unlockRateLimitGate", () => { + // Shape captured from a real /backend-api/wham/usage/stream snapshot event. + const usageSnapshot = { + version: 1, + stream_id: "d485cc87-f9f6-431c-9908-8b99a854e252", + sequence: 1, + usage: { + plan_type: "pro", + rate_limit: { + allowed: false, + limit_reached: true, + primary_window: { used_percent: 100, limit_window_seconds: 604800, reset_after_seconds: 205162, reset_at: 1790423160 }, + secondary_window: null, + }, + model_usage: { "gpt-6-astra": { available: false, available_at: "2026-09-26T11:46:01Z", credits_would_enable: true } }, + spend_control: { reached: false, individual_limit: null }, + rate_limit_upsell: { banner_type: "pro_rate_limit_reached", title: "Codex 和工作使用额度已用完", reset_at: 1790423160 }, + rate_limit_reset_credits: { available_count: 1, applicable_available_count: 1 }, + }, + generated_at_ms: 1790217999865, + }; + + test("flips the gate flags and keeps every display field", () => { + const value = structuredClone(usageSnapshot); + expect(unlockRateLimitGate(value)).toBe(true); + const gate = (value.usage as typeof usageSnapshot.usage).rate_limit; + expect(gate.allowed).toBe(true); + expect(gate.limit_reached).toBe(false); + // Display data is untouched. + expect(gate.primary_window).toEqual(usageSnapshot.usage.rate_limit.primary_window); + expect((value.usage as typeof usageSnapshot.usage).rate_limit_upsell).toEqual(usageSnapshot.usage.rate_limit_upsell); + expect((value.usage as typeof usageSnapshot.usage).model_usage).toEqual(usageSnapshot.usage.model_usage); + }); + + test("reports no change for open gates and unrelated payloads", () => { + const open = structuredClone(usageSnapshot); + (open.usage.rate_limit as Record).allowed = true; + (open.usage.rate_limit as Record).limit_reached = false; + expect(unlockRateLimitGate(open)).toBe(false); + expect(unlockRateLimitGate({ usage: { plan_type: "pro" } })).toBe(false); + expect(unlockRateLimitGate("text")).toBe(false); + }); + + test("handles snapshot endpoints with a top-level rate_limit", () => { + const snapshot = { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 42 } } }; + expect(unlockRateLimitGate(snapshot)).toBe(true); + expect(snapshot.rate_limit.allowed).toBe(true); + expect(snapshot.rate_limit.primary_window.used_percent).toBe(42); + }); +}); + +describe("stripSendBlocksFromSseLine", () => { + test("rewrites data lines carrying send locks", () => { + const event = { type: "conversation.limit", blocked_features: [{ name: "send", block_reason: "usage_limit" }] }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(rewritten).toBe("data: " + JSON.stringify({ type: "conversation.limit", blocked_features: [] })); + }); + + test("rewrites usage-stream events carrying a closed rate limit gate", () => { + const event = { + version: 1, + sequence: 1, + usage: { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } }, + }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(rewritten).not.toBeNull(); + const parsed = JSON.parse(rewritten!.slice("data: ".length)) as typeof event; + expect(parsed.usage.rate_limit.allowed).toBe(true); + expect(parsed.usage.rate_limit.limit_reached).toBe(false); + expect(parsed.usage.rate_limit.primary_window.used_percent).toBe(100); + }); + + test("passes through non-data lines, clean data and malformed JSON", () => { + expect(stripSendBlocksFromSseLine("event: conversation.limit")).toBeNull(); + expect(stripSendBlocksFromSseLine('data: {"type":"delta"}')).toBeNull(); + expect(stripSendBlocksFromSseLine("data: [partial")).toBeNull(); + expect(stripSendBlocksFromSseLine(": keep-alive")).toBeNull(); + }); +}); From 57d604d51bd94e3dc9172d0f2f8e47a117586f74 Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:49:56 +0800 Subject: [PATCH 19/19] feat: launch watcher for ChatGPT desktop send-unblock The Chromium resolver rule only applies when the app is launched with it, so a normal Dock/Spotlight launch reaches the real chatgpt.com and the composer locks again. Adds a launchd agent that watches the app's Electron SingletonLock (written on every launch) and, exactly once per launch, restarts the app with the resolver rule if it was started without one. There is no resident polling process: launchd wakes the one-shot script on the lock event and the script exits after one check. - `ocx chatgpt status|install-watcher|uninstall-watcher|launch` - The watcher only acts when the opencodex intercept listener is actually listening, so with the feature off the app stays native. - Install is idempotent (bootout + bootstrap) and survives reboots. --- src/chatgpt/desktop-unblock/launch-watcher.ts | 182 ++++++++++++++++++ src/cli/chatgpt-command.ts | 92 +++++++++ src/cli/dispatch.ts | 4 + src/cli/registry.ts | 13 ++ 4 files changed, 291 insertions(+) create mode 100644 src/chatgpt/desktop-unblock/launch-watcher.ts create mode 100644 src/cli/chatgpt-command.ts diff --git a/src/chatgpt/desktop-unblock/launch-watcher.ts b/src/chatgpt/desktop-unblock/launch-watcher.ts new file mode 100644 index 00000000000..0f7fc8bd50a --- /dev/null +++ b/src/chatgpt/desktop-unblock/launch-watcher.ts @@ -0,0 +1,182 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { CHATGPT_INTERCEPT_HOST } from "./listener"; +import { chatgptUnblockResolverRule } from "./runtime"; + +/** + * Launch integration for the ChatGPT desktop send-unblock intercept. + * + * The Chromium resolver rule only applies when the app is launched with it, so a normal + * Dock/Spotlight start reaches the real chatgpt.com and the composer locks again. This module + * installs a launchd agent that watches the app's Electron `SingletonLock` -- written on every + * launch -- and, exactly once per launch, restarts the app with the resolver rule if it was + * started without one. There is no resident polling process: launchd wakes the script on the + * lock event and the script exits after one check. + * + * The watcher only acts when the opencodex intercept listener is actually listening, so with + * the feature off the app is left completely native. + */ + +export const CHATGPT_APP_PATH = "/Applications/ChatGPT.app"; +/** The desktop app is `openai-codex-electron` internally: its Electron userData dir is `Codex`. */ +export const CHATGPT_SINGLETON_LOCK_PATH = "Library/Application Support/Codex/SingletonLock"; +export const CHATGPT_UNBLOCK_WATCHER_LABEL = "com.opencodex.chatgpt-unblock-watcher"; + +function expandHome(path: string): string { + return path.startsWith("~") ? join(homedir(), path.slice(1)) : path; +} + +export interface ChatgptUnblockWatcherPaths { + scriptPath: string; + plistPath: string; + errPath: string; + lockPath: string; +} + +export function chatgptUnblockWatcherPaths(configDir?: string): ChatgptUnblockWatcherPaths { + const dir = configDir ?? getConfigDir(); + return { + scriptPath: join(dir, "chatgpt-unblock-watcher.sh"), + plistPath: expandHome(`~/Library/LaunchAgents/${CHATGPT_UNBLOCK_WATCHER_LABEL}.plist`), + errPath: join(dir, "chatgpt-unblock-watcher.err"), + lockPath: expandHome(`~/${CHATGPT_SINGLETON_LOCK_PATH}`), + }; +} + +/** The one-shot launchd script: restart the app with the rule if this launch lacked it. */ +export function buildChatgptUnblockWatcherScript(port: number): string { + const rule = chatgptUnblockResolverRule(port); + return `#!/bin/bash +# opencodex ChatGPT send-unblock launch watcher (one-shot, launchd-triggered). +# Fires when the ChatGPT desktop app creates its Electron SingletonLock (i.e. on every +# launch). If the app was started WITHOUT the host-resolver rule that points ${CHATGPT_INTERCEPT_HOST} at +# the opencodex TLS listener (normal Dock/Spotlight launch), it is restarted once with the +# rule. Correctly-launched instances and an absent intercept are left alone. + +PORT=${port} +RULE='${rule}' +LOG="$HOME/.opencodex/chatgpt-unblock-watcher.log" + +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } + +# Intercept must be listening; otherwise leave the app alone. +if ! lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then + exit 0 +fi +# App running? +if ! pgrep -f "ChatGPT.app/Contents/MacOS/ChatGPT" >/dev/null 2>&1; then + exit 0 +fi +# Already launched with the rule? +if pgrep -f "MacOS/ChatGPT $RULE" >/dev/null 2>&1; then + exit 0 +fi +log "unflagged ChatGPT detected; restarting with resolver rule" +osascript -e 'quit app "ChatGPT"' >/dev/null 2>&1 +sleep 3 +open -a ChatGPT --args "$RULE" +log "relaunched with rule" +`; +} + +/** One-shot launchd agent: wake on the app's SingletonLock event, run the script, exit. */ +export function buildChatgptUnblockWatcherPlist(scriptPath: string, watchPath: string, errPath: string): string { + return ` + + + + Label + ${CHATGPT_UNBLOCK_WATCHER_LABEL} + ProgramArguments + + /bin/bash + ${scriptPath} + + WatchPaths + + ${watchPath} + + StandardErrorPath + ${errPath} + + +`; +} + +function sh(command: string, args: string[]): { ok: boolean; output: string } { + try { + const output = execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + return { ok: true, output }; + } catch (error) { + const err = error as { status?: number; stdout?: string; stderr?: string }; + return { ok: false, output: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() }; + } +} + +export interface InstallChatgptUnblockWatcherOptions { + port: number; + configDir?: string; + /** Test seam: skip the macOS / app-presence guards. */ + assumeSupported?: boolean; +} + +/** Install the launch watcher: write script + agent plist and load it with launchd. */ +export function installChatgptUnblockWatcher(options: InstallChatgptUnblockWatcherOptions): void { + if (process.platform !== "darwin" && !options.assumeSupported) { + throw new Error("the ChatGPT launch watcher is only supported on macOS"); + } + if (!options.assumeSupported && !existsSync(CHATGPT_APP_PATH)) { + throw new Error(`${CHATGPT_APP_PATH} not found; install the ChatGPT desktop app first`); + } + const paths = chatgptUnblockWatcherPaths(options.configDir); + mkdirSync(expandHome("~/Library/LaunchAgents"), { recursive: true }); + writeFileSync(paths.scriptPath, buildChatgptUnblockWatcherScript(options.port), { mode: 0o700 }); + writeFileSync(paths.plistPath, buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, paths.errPath)); + // Idempotent load: boot out any previous generation first. + sh("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + sh("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, paths.plistPath]); +} + +/** Remove the launch watcher: unload the agent and delete its files. */ +export function uninstallChatgptUnblockWatcher(configDir?: string): void { + const paths = chatgptUnblockWatcherPaths(configDir); + sh("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + for (const path of [paths.plistPath, paths.scriptPath]) { + try { + rmSync(path); + } catch { + /* already gone */ + } + } +} + +export interface ChatgptUnblockWatcherStatus { + scriptInstalled: boolean; + plistInstalled: boolean; + agentLoaded: boolean; + scriptUpToDate: boolean; + plistUpToDate: boolean; +} + +export function chatgptUnblockWatcherStatus(port: number, configDir?: string): ChatgptUnblockWatcherStatus { + const paths = chatgptUnblockWatcherPaths(configDir); + const scriptInstalled = existsSync(paths.scriptPath); + const plistInstalled = existsSync(paths.plistPath); + const agentLoaded = sh("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]).ok; + const scriptUpToDate = scriptInstalled + && readFileSync(paths.scriptPath, "utf8") === buildChatgptUnblockWatcherScript(port); + const plistUpToDate = plistInstalled + && readFileSync(paths.plistPath, "utf8") === buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, paths.errPath); + return { scriptInstalled, plistInstalled, agentLoaded, scriptUpToDate, plistUpToDate }; +} + +/** Launch the ChatGPT desktop app with the resolver rule (macOS). */ +export function launchChatgptWithRule(port: number): void { + if (process.platform !== "darwin") { + throw new Error("launching the ChatGPT desktop app is only supported on macOS"); + } + execFileSync("open", ["-a", "ChatGPT", "--args", chatgptUnblockResolverRule(port)], { stdio: "ignore" }); +} diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts new file mode 100644 index 00000000000..edeeb35e619 --- /dev/null +++ b/src/cli/chatgpt-command.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import { loadConfig } from "../config"; +import { findLiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; +import { chatgptUnblockWatcherStatus, installChatgptUnblockWatcher, launchChatgptWithRule, uninstallChatgptUnblockWatcher } from "../chatgpt/desktop-unblock/launch-watcher"; +import { CHATGPT_UNBLOCK_PORT_OFFSET, chatgptUnblockResolverRule } from "../chatgpt/desktop-unblock/runtime"; + +/** + * `ocx chatgpt` — inspect and operate the ChatGPT desktop send-unblock integration. + * + * ocx chatgpt status Feature, listener, watcher and app state + * ocx chatgpt install-watcher Install the launch watcher (Dock/Spotlight launches too) + * ocx chatgpt uninstall-watcher Remove the launch watcher + * ocx chatgpt launch Launch the app with the resolver rule + */ + +function sh(command: string, args: string[]): boolean { + try { + execFileSync(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + return true; + } catch { + return false; + } +} + +/** Port the intercept listens on: explicit config, else live proxy + offset, else default + offset. */ +export function resolveChatgptUnblockPort(config: OcxConfig, livePort: number | undefined): number { + const configured = config.chatgptDesktop?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + const publicPort = livePort ?? (typeof config.port === "number" ? config.port : 10100); + return publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; +} + +export async function handleChatgptCommand(args: string[]): Promise { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + console.log(`Usage: + ocx chatgpt status Feature, listener, watcher and app state + ocx chatgpt install-watcher Install the launch watcher (covers Dock/Spotlight launches) + ocx chatgpt uninstall-watcher Remove the launch watcher + ocx chatgpt launch Launch the ChatGPT app with the resolver rule`); + return sub ? 0 : 64; + } + + const config = loadConfig(); + const live = await findLiveProxy().catch(() => null); + const port = resolveChatgptUnblockPort(config, live?.port); + const rule = chatgptUnblockResolverRule(port); + + if (sub === "status") { + const enabled = config.chatgptDesktop?.unblockSend === true; + const listening = sh("lsof", ["-nP", "-iTCP", `:${port}`, "-sTCP:LISTEN"]); + const watcher = chatgptUnblockWatcherStatus(port); + const appRunning = sh("pgrep", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"]); + const appFlagged = sh("pgrep", ["-f", `MacOS/ChatGPT ${rule}`]); + console.log(`ChatGPT send-unblock: + feature enabled: ${enabled ? "yes" : "no (set chatgptDesktop.unblockSend: true)"} + listener port: ${port}${listening ? " (listening)" : " (not listening)"} + resolver rule: ${rule} + watcher script: ${watcher.scriptInstalled ? (watcher.scriptUpToDate ? "installed" : "installed (outdated; reinstall)") : "not installed"} + watcher agent: ${watcher.agentLoaded ? "loaded" : watcher.plistInstalled ? "installed but not loaded" : "not installed"} + app: ${appRunning ? (appFlagged ? "running with rule" : "running WITHOUT rule (composer will lock)") : "not running"}`); + return 0; + } + + if (sub === "install-watcher") { + if (config.chatgptDesktop?.unblockSend !== true) { + console.error("chatgptDesktop.unblockSend is not enabled; add it to ~/.opencodex/config.json first:"); + console.error(' { "chatgptDesktop": { "unblockSend": true } }'); + return 1; + } + installChatgptUnblockWatcher({ port }); + console.log(`🛰 Launch watcher installed for port ${port}.`); + console.log(" Normal Dock/Spotlight launches of the ChatGPT app are now corrected automatically."); + return 0; + } + + if (sub === "uninstall-watcher") { + uninstallChatgptUnblockWatcher(); + console.log("Launch watcher removed."); + return 0; + } + + if (sub === "launch") { + launchChatgptWithRule(port); + console.log(`Launched the ChatGPT app with ${rule}`); + return 0; + } + + console.error(`unknown subcommand: ${sub}`); + return 64; +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index c1f5c0092e8..e020502e510 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -943,6 +943,10 @@ const commandRunners: Record = { } return await cmdClaude(deps.args.slice(1)); }, + chatgpt: async deps => { + const { handleChatgptCommand } = await import("./chatgpt-command"); + return await handleChatgptCommand(deps.args.slice(1)); + }, opencode: async deps => { const { cmdOpencode } = await import("./opencode"); return await cmdOpencode(deps.args.slice(1)); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 8deb44a3a4c..fc42789e9cc 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -462,6 +462,19 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Claude Code settings: ocx claude config ...", ], }, + { + name: "chatgpt", + usage: "ocx chatgpt ", + summary: "Inspect and operate the ChatGPT desktop send-unblock integration.", + details: [ + "status Feature, intercept listener, watcher and app launch state.", + "install-watcher Install the launchd watcher so Dock/Spotlight launches of the ChatGPT", + " app are automatically corrected to carry the host-resolver rule.", + "uninstall-watcher Remove the launch watcher script and agent.", + "launch Launch the ChatGPT app with the resolver rule.", + "Requires chatgptDesktop.unblockSend: true in config for install-watcher.", + ], + }, { name: "opencode", usage: "ocx opencode [opencode args...]",