From 678517f56c322bdf2e55c8e380d9004ad8a80e65 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 25 Aug 2026 19:29:33 +0900 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] release: prepare 2.63.0 version metadata (#5612) --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b2fbcb56130..33322e5d556 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2541,7 +2541,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.62.0" +version = "2.63.0" dependencies = [ "dbus", "reqwest 0.12.24", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 45b39cd890c..e46937659b0 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.62.0" +version = "2.63.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index ff6232c563c..36df79737fb 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.62.0", + "version": "2.63.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 9c7252d8004..245a9b51a12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.62.0", + "version": "2.63.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From a32227bb979f6faf38cf592a0a702e099095cd79 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 07:24:26 +0900 Subject: [PATCH 18/23] release: prepare 2.69.0 version metadata --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9bdc7f94b53..320a9a2b840 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2551,7 +2551,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.70.0" +version = "2.69.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f2af7b8c59c..ed8f57bf1bb 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.70.0" +version = "2.69.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index da106098a8a..a3790fcbc8c 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.70.0", + "version": "2.69.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index fbe21ba9e8a..82f92b4f3cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.70.0", + "version": "2.69.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 46c07c5e0f7157b19f79a06fbaf79d3e04c8308b Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 09:26:51 +0900 Subject: [PATCH 19/23] fix(release): sign the packaged macOS keyring addons before notarization (#6271) * fix(release): sign the packaged macOS keyring addons before notarization Notarization rejected the 2.73.0 preview app: Resources/keyring/*.node, bundled since #6161, were unsigned or ad-hoc and had no secure timestamp, and Tauri does not sign files under Resources. Sign each darwin addon in place with the Developer ID identity, hardened runtime and timestamp after the certificate import and before tauri build, verify the result, and fail a real release that lacks signing material. * fix(release): match keyring signature fields without a pipe (cherry picked from commit 11782eedc3758d90c037244ab1cf36f17a286ccc) --- .github/workflows/release.yml | 39 +++++++++++++++++++ .../release-desktop-scripts.test.ts | 17 ++++++++ 2 files changed, 56 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08a5e66cd05..764eb0622a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -413,6 +413,45 @@ jobs: echo "$description" | grep -q "flags=.*runtime" echo "$description" | grep -q "Timestamp=" + # Tauri signs the app, its sidecar and the widget it is handed, but nothing under Resources. + # The packaged keyring addons (#6161) are Mach-O code, so Apple notarization rejects the whole + # app unless each carries the Developer ID signature, the hardened runtime and a secure + # timestamp (2.73.0-preview.20260930 was refused for exactly that). Sign them in place, after + # the certificate import and before the bundler copies them. + - name: Sign the packaged keyring addons + if: runner.os == 'macOS' + env: + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + shopt -s nullglob + addons=(desktop/src-tauri/resources/keyring/*.darwin-*.node) + if [ "${#addons[@]}" -eq 0 ]; then + echo "::error::No macOS keyring addon was prepared for the bundle." + exit 1 + fi + if [ "${DESKTOP_SIGNING_CONFIGURED}" != "true" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release must sign the packaged keyring addons." + exit 1 + fi + echo "No signing material; the keyring addons stay as prepared for this non-release build." + exit 0 + fi + for addon in "${addons[@]}"; do + codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY" "$addon" + codesign --verify --strict "$addon" + description="$(codesign -dvvv "$addon" 2>&1)" + echo "$description" + # Here-strings, not pipes: under pipefail a matcher that exits on its first hit can + # SIGPIPE the writer and fail the assertion it was meant to pass. + grep -q "TeamIdentifier=$APPLE_TEAM_ID" <<<"$description" + grep -q "flags=.*runtime" <<<"$description" + grep -q "Timestamp=" <<<"$description" + done + - name: Prepare Windows installer version if: runner.os == 'Windows' shell: bash diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index db141b5e1c2..570e3067504 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -537,6 +537,23 @@ describe("widget extension signing", () => { .toContain("APPLE_CERTIFICATE"); }); + test("the packaged keyring addons are Developer ID signed before the bundler copies them", () => { + // Notarization refused 2.73.0-preview.20260930: Resources/keyring/*.node were ad-hoc or + // unsigned and had no secure timestamp, and Tauri does not sign files under Resources. + const sign = steps.find(step => step.name === "Sign the packaged keyring addons"); + expect(sign?.if).toBe("runner.os == 'macOS'"); + expect(sign?.env?.MACOS_SIGN_IDENTITY).toContain("APPLE_SIGNING_IDENTITY"); + expect(sign?.run).toContain("desktop/src-tauri/resources/keyring/*.darwin-*.node"); + expect(sign?.run).toContain('codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY"'); + expect(sign?.run).toContain('grep -q "TeamIdentifier=$APPLE_TEAM_ID" <<<"$description"'); + expect(sign?.run).toContain('grep -q "Timestamp=" <<<"$description"'); + // A real release never falls back to unsigned addons; only a dry run may. + expect(sign?.run).toContain("A real release must sign the packaged keyring addons."); + expect(indexOfStepRunning("security create-keychain")).toBeLessThan(indexOfStep(sign!.name!)); + expect(indexOfStep("Prepare macOS sidecars")).toBeLessThan(indexOfStep(sign!.name!)); + expect(indexOfStep(sign!.name!)).toBeLessThan(indexOfStep("Build desktop bundles")); + }); + test("the certificate is importable before the widget is signed and is removed afterwards", () => { // codesign resolves an identity through the keychain search list, and Tauri does not build // its own keychain until the bundling step, which is after this one. From de5c672c5d283a681a95e84882a15c3461644f17 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 20:48:56 +0900 Subject: [PATCH 20/23] chore(release): 2.74.0 --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 23e7615e415..74ed32ff04c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.75.0" +version = "2.74.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6aa1932c8f5..81e8e2b51da 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.75.0" +version = "2.74.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 256d52efe12..87ae5a99695 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.75.0", + "version": "2.74.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 138fd7eb09f..1808a150251 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.75.0", + "version": "2.74.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From c1dc8e4e1895276ed4f70240eebe2539fcfddd7c Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 23:21:53 +0900 Subject: [PATCH 21/23] fix(deps): bump proxy-addr, fast-uri, ip-address and source-map-js for new advisories release.yml's dependency audit refused 2.79.0 on a critical advisory published after the candidate was cut: proxy-addr <2.0.8 is vulnerable to IP spoofing via IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h), reached through @modelcontextprotocol/sdk > express. gui/ reports source-map-js <1.2.2 (GHSA-68fv-2mgg-jv7q, vite > postcss). bun audit fix, lockfiles only, every bump inside its declared range: proxy-addr 2.0.7 -> 2.0.8, fast-uri 3.1.7 -> 3.1.8, ip-address 10.4.0 -> 10.7.1, and in gui/ source-map-js 1.2.1 -> 1.2.2. Both audits now report no vulnerabilities at --audit-level=high. --- bun.lock | 6 +++--- gui/bun.lock | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 4c123e4c3c3..10de7ca5cfc 100644 --- a/bun.lock +++ b/bun.lock @@ -191,7 +191,7 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -219,7 +219,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], + "ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -263,7 +263,7 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], diff --git a/gui/bun.lock b/gui/bun.lock index dc41b1bdb9c..2cbae98b06d 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -177,7 +177,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], From 0cd21698a86aee7c4830bdcac55e872af8440be4 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 7 Oct 2026 18:14:01 +0900 Subject: [PATCH 22/23] fix(deps): bump @modelcontextprotocol/sdk to 1.31.0 for GHSA-6qxp-vccf-f47h --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 10de7ca5cfc..46893143a37 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ "@hono/node-server": ["@hono/node-server@2.1.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], From 110a13f525088a89e8604b29e4043373b8910904 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Thu, 15 Oct 2026 20:48:05 +0800 Subject: [PATCH 23/23] fix(gui): include hidden models in combo selectors Use the complete management catalog for Combo model selection independently of external model visibility. Bump the workspace cache version, cover hidden model selection and saving, and synchronize Combo documentation. --- .../src/content/docs/fr/guides/combos.md | 2 +- docs-site/src/content/docs/guides/combos.md | 5 +- .../src/content/docs/ja/guides/combos.md | 2 +- .../src/content/docs/ko/guides/combos.md | 2 +- .../src/content/docs/ru/guides/combos.md | 2 +- .../src/content/docs/tr/guides/combos.md | 5 +- .../src/content/docs/zh-cn/guides/combos.md | 2 +- .../src/content/docs/zh-tw/guides/combos.md | 2 +- gui/src/pages/Combos.tsx | 4 +- .../combo-capability-hidden-targets.test.tsx | 69 +++++++++++++++++-- gui/tests/page-loading-contract.test.tsx | 2 +- structure/dashboard-and-usage.md | 12 ++-- 12 files changed, 85 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 4cb051fc727..42469f4fa28 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -277,7 +277,7 @@ flux de travail des tâches. ### Tableau de bord Ouvrez le tableau de bord local et choisissez **Modèles → Combos**. L'espace de travail crée, modifie, renomme et supprime -combos, et son sélecteur de cible exclut les modèles désactivés et les combos imbriqués. +les combos. Les sélecteurs de modèles incluent les modèles masqués : la visibilité ne concerne que les listes externes, pas la sélection interne des combos. Sélectionner un modèle masqué ne le rend pas visible. Le sélecteur de cible exclut toujours les fournisseurs désactivés, les combos imbriqués et les fournisseurs JEV réservés au service de décision. Chaque cible affiche aussi un badge de quota en direct : **Disponible**, **Quota épuisé** ou **Quota inconnu**. L’éditeur bloque Enregistrer et Créer pour une raison de quota uniquement lorsque chaque cible utilisable dispose d’une confirmation serveur encore valide indiquant que la limite d’inférence liée à ses identifiants configurés est épuisée. Les quotas de compte, de modèle, de recherche et de MCP fournis uniquement à titre d’affichage, ainsi que les informations de routage absentes ou expirées, ne déclenchent pas ce blocage. Le blocage expire à la réinitialisation applicable ou à l’expiration de la validité des données et fait l’objet d’une nouvelle vérification lorsque la page devient active ou visible ; Actualiser recharge à la fois les données des combos et les quotas. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 60e3db08e6b..10b96325810 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -739,8 +739,9 @@ task workflow. ### Dashboard Open the local dashboard and choose **Models → Combos**. The workspace creates, edits, renames, and removes -combos, and its target picker excludes disabled models, nested combos, and the credential-only JEV -provider. **Create JEV Auto** opens the same Combo editor with an editable decision target template; +combos. Its model selectors include hidden models: catalog visibility only controls external model +lists, not internal Combo selection, and selecting a hidden model does not make it visible externally. +The target picker still excludes disabled providers, nested combos, and the credential-only JEV provider. **Create JEV Auto** opens the same Combo editor with an editable decision target template; an existing `jev-auto` id or alias is reported instead of creating a duplicate. A JEV Combo also shows **Decision service** (TypeSafe JEV or a configured `jev-decision` provider) and **Decision timeout (ms)**, and the Combos overview lists each JEV Combo's decision service, endpoint, and timeout. diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 1a03b3e6a07..2a9991087e2 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -184,7 +184,7 @@ v1/base/v2 モードと完全な暗号化タスクのワークフローについ ### ダッシュボード -ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースはコンボを作成、編集、名前変更、削除し、そのターゲット ピッカーは無効なモデルとネストされたコンボを除外します。 +ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースではコンボの作成、編集、名前変更、削除ができます。モデル選択には非表示モデルも含まれます。表示設定は外部のモデル一覧にのみ適用され、コンボ内部の選択には影響しません。非表示モデルを選択しても外部には表示されません。ターゲット選択では、無効なプロバイダー、ネストされたコンボ、決定サービス専用の JEV プロバイダーは引き続き除外されます。 各ターゲットには **利用可能**、**クォータを使い切りました**、**クォータ不明** のライブバッジも表示されます。 エディターがクォータを理由に保存と作成をブロックするのは、使用可能なすべてのターゲットについて、設定された認証情報の推論上限に達したことを示す、サーバーによる確認が現在も有効な場合だけです。表示専用のアカウント・モデル・検索・MCP クォータや、ルーティングの根拠情報の欠落・期限切れによって、このブロックが発生することはありません。ブロックは該当するリセット時刻またはデータの有効期限に解除され、ページがアクティブになるか表示状態になると再確認されます。「更新」はコンボデータとクォータの両方を再読み込みします。ダッシュボードのエディターではまだ `cooldownMs` と `waitForCooldownMs` を設定できません。後続の UI 作業が完了するまでは、構成ファイルまたは管理 API を使用してください。 diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index c9ccf3757b3..21edf34cd25 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -194,7 +194,7 @@ v1/base/v2 모드와 암호화된 작업의 전체 흐름은 [Sub-agent Surface] ### 대시보드 -로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스는 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있으며, 대상 선택기에서는 비활성 모델과 중첩 콤보를 제외합니다. +로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스에서 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있습니다. 모델 선택기에는 숨겨진 모델도 포함됩니다. 표시 설정은 외부 모델 목록에만 적용되며 콤보 내부 선택에는 영향을 주지 않습니다. 숨겨진 모델을 선택해도 외부에 표시되지 않습니다. 대상 선택기에서는 비활성 공급자, 중첩 콤보, 결정 서비스 전용 JEV 공급자를 계속 제외합니다. 각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 편집기는 사용 가능한 모든 대상에 대해 설정된 인증 정보의 추론 한도가 소진되었다는 서버 확인이 현재 유효할 때만 할당량을 이유로 저장과 만들기를 차단합니다. 표시 전용 계정·모델·검색·MCP 할당량이나 누락되거나 만료된 라우팅 근거 정보는 이 차단을 일으키지 않습니다. 차단은 해당 한도의 초기화 시점이나 데이터 유효기간이 끝나면 해제되며 페이지가 활성화되거나 표시될 때 다시 확인됩니다. 새로 고침은 콤보 데이터와 할당량을 모두 다시 불러옵니다. 대시보드 편집기에서는 아직 `cooldownMs`나 `waitForCooldownMs`를 설정할 수 없습니다. 후속 UI 작업이 완료될 때까지 구성 파일이나 관리 API를 사용하세요. diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index fe966d0cbf9..894f39ca8c7 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -234,7 +234,7 @@ Failover намеренно ограничен. Он помогает при п ### Дашборд Откройте локальный дашборд и выберите **Models → Combos**. Рабочая область умеет создавать, редактировать, -переименовывать и удалять combo, а селектор целей исключает отключённые модели и вложенные combo. +переименовывать и удалять combo. Селекторы моделей включают скрытые модели: видимость влияет только на внешние списки, а не на внутренний выбор в combo. Выбор скрытой модели не делает её видимой снаружи. Селектор целей по-прежнему исключает отключённых провайдеров, вложенные combo и провайдеров JEV, предназначенных только для сервиса принятия решений. У каждой цели также отображается актуальный значок квоты: **Доступно**, **Квота исчерпана** или **Квота неизвестна**. Редактор блокирует сохранение и создание из-за квоты только тогда, когда для каждой пригодной к использованию цели есть действующее подтверждение сервера об исчерпании лимита инференса для настроенных учётных данных. Квоты аккаунта, модели, поиска и MCP, предназначенные только для отображения, а также отсутствующие или просроченные данные для принятия решения о маршрутизации не вызывают эту блокировку. Блокировка истекает при соответствующем сбросе квоты или окончании срока актуальности данных и проверяется повторно, когда страница становится активной или видимой; «Обновить» повторно загружает и данные combo, и квоты. Редактор дашборда пока не предоставляет `cooldownMs` и `waitForCooldownMs`; до появления соответствующего UI используйте файл конфигурации или Management API. diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index d2c3be63d49..e5d8dc05083 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -305,8 +305,9 @@ Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın. Yerel kontrol panelini açın ve **Modeller → Kombolar** seçeneğini belirleyin. Çalışma alanı komboları oluşturur, düzenler, yeniden adlandırır ve kaldırır; -hedef seçicisi ise devre dışı bırakılmış modelleri ve iç içe geçmiş komboları -hariç tutar. +model seçicileri gizli modelleri de içerir. Görünürlük yalnızca dış model listelerini etkiler, +kombo içindeki seçimi etkilemez; gizli bir modeli seçmek onu dışarıda görünür yapmaz. +Hedef seçicisi devre dışı sağlayıcıları, iç içe komboları ve yalnızca karar hizmeti sunan JEV sağlayıcılarını hariç tutar. Her hedef ayrıca canlı bir kota rozeti gösterir: **Kullanılabilir**, **Kota tükendi** veya **Kota bilinmiyor**. Düzenleyici, kota nedeniyle Kaydet ve Oluştur işlemlerini yalnızca kullanılabilir hedeflerin tümü için yapılandırılmış kimlik bilgisine ait çıkarım sınırının tükendiğini doğrulayan geçerli sunucu bilgisi varsa engeller. Yalnızca görüntüleme amaçlı hesap, model, arama ve MCP kotaları ya da eksik veya süresi dolmuş yönlendirme kanıtları bu engellemeye neden olmaz. Engelleme, ilgili sıfırlama zamanında veya verinin güncellik süresi dolduğunda sona erer ve sayfa etkin ya da görünür olduğunda yeniden kontrol edilir; Yenile, hem kombo verilerini hem de kotaları yeniden yükler. diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index d0513216b58..7ae396add05 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -214,7 +214,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 ### Dashboard -打开本地 dashboard 并选择 **Models → Combos**。该工作区可以创建、编辑、重命名和删除 combo,其目标选择器会排除已禁用的模型和嵌套 combo。 +打开本地 dashboard 并选择 **Models → Combos**。该工作区可以创建、编辑、重命名和删除 combo。模型选择器使用包含隐藏模型的完整目录:显示/隐藏设置只影响对外模型列表,不影响 Combo 内部选择,选中隐藏模型也不会将其对外显示。目标选择器仍排除已禁用的提供方、嵌套 combo 和仅用于决策服务的 JEV 提供方。 每个目标还会显示实时额度徽章:**可用**、**额度已用尽**或**额度未知**。只有当每个可用目标均有当前有效的服务器确认,表明其所配置凭据的推理限额已耗尽时,编辑器才会因额度而禁止保存和创建。仅供显示的账户、模型、搜索和 MCP 额度,以及缺失或已过期的路由依据,都不会触发此限制。此限制会在适用的重置时间或数据有效期结束时解除,并在页面变为活动或可见状态时重新检查;刷新会同时重新加载 Combo 数据和额度。dashboard 编辑器目前还不能设置 `cooldownMs` 或 `waitForCooldownMs`;在后续 UI 完成前,请使用配置文件或管理 API。 diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 3fdb413a417..bb70ccceb52 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -221,7 +221,7 @@ Codex v2 子代理有一個重要限制([issue #92](https://github.com/lidge-j ### 儀表板 -開啟本機儀表板並選擇 **Combos**。該工作區可建立、編輯、重新命名與移除 combo,且其目標 picker 會排除已停用的模型與巢狀 combo。 +開啟本機儀表板並選擇 **Combos**。該工作區可建立、編輯、重新命名與移除 combo。模型選擇器包含隱藏模型;顯示/隱藏設定僅影響對外模型清單,不影響 Combo 內部選擇,選取隱藏模型也不會將其對外顯示。目標選擇器仍排除已停用的供應商、巢狀 combo 與僅提供決策服務的 JEV 供應商。 每個目標也會顯示即時額度徽章:**可用**、**額度已用盡**或**額度未知**。只有當每個可用目標均有目前有效的伺服器確認,顯示其所設定憑證的推論限額已耗盡時,編輯器才會因配額而停用儲存與建立。僅供顯示的帳戶、模型、搜尋與 MCP 配額,以及缺失或已過期的路由依據,都不會觸發此限制。此限制會在適用的重設時間或資料有效期限結束時解除,並在頁面變為作用中或可見狀態時重新檢查;重新整理會同時重新載入 Combo 資料與配額。 diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index c7cd9194727..a760fff0515 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -83,7 +83,7 @@ export default function Combos({ onCountChange?: (count: number) => void; }) { const t = useT(); - const cacheKey = `ocx.combos.workspace.v1:${apiBase}`; + const cacheKey = `ocx.combos.workspace.v2:${apiBase}`; const cached = useMemo(() => seedCombos(cacheKey), [cacheKey]); /* @@ -207,7 +207,7 @@ export default function Combos({ ...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}), }; capabilityModels.push(option); - if (model.disabled !== true) models.push(option); + models.push(option); // Catalog visibility does not restrict internal Combo selection. } // Add a default placeholder only when the catalog has no row, including disabled rows. diff --git a/gui/tests/combo-capability-hidden-targets.test.tsx b/gui/tests/combo-capability-hidden-targets.test.tsx index e4d7c75bec2..012d0e24860 100644 --- a/gui/tests/combo-capability-hidden-targets.test.tsx +++ b/gui/tests/combo-capability-hidden-targets.test.tsx @@ -9,7 +9,7 @@ import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../src/se import { parseComboList } from "../src/combo-workspace-data"; const API_BASE = "http://localhost"; -const CACHE_KEY = `ocx.combos.workspace.v1:${API_BASE}`; +const CACHE_KEY = `ocx.combos.workspace.v2:${API_BASE}`; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; @@ -190,7 +190,7 @@ test("JEV selected-target effort controls use the disabled row's advertised ladd expect(container.textContent).not.toContain("Reasoning efforts unknown"); }); -test("a disabled provider default never returns in the new-member picker", async () => { +test("hidden models including the provider default appear once in new and existing member pickers", async () => { mockCatalog([hiddenImage, { ...hiddenImage, id: "visible", disabled: false }], [target(hiddenImage)], {}, { vision: "hidden" }); await mount(); await openDetail(); @@ -199,8 +199,67 @@ test("a disabled provider default never returns in the new-member picker", async const dialog = container.querySelector("dialog")!; await chooseProvider("vision", dialog); const options = [...dialog.querySelector('select[aria-label="Model"]')!.options].map(option => option.value); - expect(options).toEqual(["", "visible"]); - expect(options).not.toContain("hidden"); + expect(options).toEqual(["", "hidden", "visible"]); + const model = dialog.querySelector('select[aria-label="Model"]')!; + await act(async () => { + model.value = "hidden"; + model.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + expect(model.value).toBe("hidden"); + expectImages(true, dialog); +}); + +test("all-hidden catalogs remain selectable and old filtered caches are ignored", async () => { + writeSessionListCacheEntry(`ocx.combos.workspace.v1:${API_BASE}`, { + combos: parseComboList({ combos: [combo([target(hiddenImage)])] }), + providers: [{ name: "vision" }], models: [], cataloguedComboIds: ["hidden-test"], + }); + const api = mockCatalog([hiddenImage, { ...hiddenImage, id: "other" }], [target(hiddenImage)]); + await mount(); + expect(api.requests()).toBe(1); + await act(async () => { container.querySelector('button[aria-label="Add combo"]')!.click(); }); + const dialog = container.querySelector("dialog")!; + await chooseProvider("vision", dialog); + expect([...dialog.querySelector('select[aria-label="Model"]')!.options].map(option => option.value)) + .toEqual(["", "hidden", "other"]); +}); + +test("editing can select and save a hidden model without changing its visibility", async () => { + const visible = { ...hiddenImage, id: "visible", disabled: false }; + mockCatalog([hiddenImage, visible], [target(visible)]); + const fetchCatalog = globalThis.fetch; + const writes: { path: string; body: Record }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + writes.push({ path: new URL(String(input)).pathname, body: JSON.parse(String(init.body)) }); + return Response.json({ success: true }); + } + return fetchCatalog(input, init); + }) as typeof fetch; + await mount(); + await openDetail(); + const model = container.querySelector('select[aria-label="Model"]')!; + expect([...model.options].map(option => option.value)).toEqual(["", "hidden", "visible"]); + await act(async () => { + model.value = "hidden"; + model.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + const save = container.querySelector("#cwi-edit-save")!; + expect(save.disabled).toBe(false); + await act(async () => { save.click(); }); + expect(writes).toHaveLength(1); + expect(writes[0]).toMatchObject({ path: "/api/combos", body: { combo: { targets: [target(hiddenImage)] } } }); + expect(hiddenImage.disabled).toBe(true); +}); + +test("JEV decision model suggestions include hidden models but exclude the JEV combo itself", async () => { + mockCatalog([hiddenImage], [target(hiddenImage)], { strategy: "jev", decisionModel: "vision/hidden" }); + await mount(); + await openDetail(); + const routes = [...container.querySelectorAll('datalist[id$="-decision-model-options"] option')] + .map(option => option.value); + expect(routes).toContain("vision/hidden"); + expect(routes).not.toContain("combo/hidden-test"); }); test("a default missing from the catalog still gets a picker placeholder", async () => { @@ -222,7 +281,7 @@ test("cache remount and live revalidation preserve hidden-target capabilities", expect(cached.data.capabilityModels).toEqual([{ provider: "vision", id: "hidden", inputModalities: ["text", "image"], reasoningEfforts: ["low", "high"], }]); - expect(cached.data.models).toEqual([]); + expect(cached.data.models).toEqual(cached.data.capabilityModels); await act(async () => { root!.unmount(); }); root = undefined; clearClientResourceStoresForTests(); diff --git a/gui/tests/page-loading-contract.test.tsx b/gui/tests/page-loading-contract.test.tsx index d12d9b589c3..45f117e3618 100644 --- a/gui/tests/page-loading-contract.test.tsx +++ b/gui/tests/page-loading-contract.test.tsx @@ -123,7 +123,7 @@ let testWindow: Window; const originalFetch = globalThis.fetch; const API_BASE = "http://localhost"; -const CACHE_KEY = `ocx.combos.workspace.v1:${API_BASE}`; +const CACHE_KEY = `ocx.combos.workspace.v2:${API_BASE}`; const CACHED_PAGE = { combos: [], providers: [{ name: "openai", disabled: false, hiddenFromPicker: false, authMode: "forward", adapter: "openai", baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-5" }], diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 3cbebeb3a11..a3508d79d28 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -461,12 +461,12 @@ Chat helper admission in `src/server/responses/core.ts` follows the [deferred st ## Combo editor routing quota -`src/server/management/provider-routes.ts` projects `routingQuota` after each quota read using the -current provider configuration and [scoped inference evidence](runtime.md#scoped-provider-quota-for-combo-selection). -The DTO carries only state, observation time and an exclusive `validUntil`; cached display reports -and private credential bindings are unchanged. Known states expire within 30 minutes; exhaustion -may expire earlier when the runtime predicate clears at a reset boundary, accounting for other -windows and persistent USD blockers. +`gui/src/pages/Combos.tsx` supplies all non-Combo management catalog rows to internal model selectors, including hidden models. Catalog visibility does not restrict Combo member or JEV decision-model selection and selecting a hidden model does not change its external visibility. Disabled-provider, decision-service and recursion exclusions remain unchanged; the v2 workspace cache avoids reusing v1 visibility-filtered candidates. + +`src/server/management/provider-routes.ts` projects `routingQuota` after each quota read using the current provider +configuration and [scoped inference evidence](runtime.md#scoped-provider-quota-for-combo-selection). The DTO carries only state, +observation time and an exclusive `validUntil`; cached display reports and private credential bindings are unchanged. Known states +expire within 30 minutes; exhaustion may expire earlier when the runtime predicate clears at a reset boundary, accounting for other windows and persistent USD blockers. `gui/src/combo-workspace-data.ts` accepts only this projection for quota-based Save/Create blocking. Missing, invalid or expired evidence is unknown. `gui/src/pages/Combos.tsx` wakes at the rendered