diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml new file mode 100644 index 0000000000..1213761b08 --- /dev/null +++ b/.github/actions/setup-project-bun/action.yml @@ -0,0 +1,30 @@ +name: Setup project Bun +description: >- + Install the Bun runtime for a job. Installs the version declared in + package.json (dependencies.bun), keeping the runtime SOT in one place so + version bumps only touch package.json and bun.lock. + +outputs: + version: + description: The resolved Bun version string. + value: ${{ steps.resolve.outputs.version }} + +runs: + using: composite + steps: + - name: Resolve project Bun version + id: resolve + shell: bash + run: | + version="$(node -p "require('./package.json').dependencies.bun")" + if [ -z "$version" ]; then + echo "::error::Could not resolve Bun version from package.json" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Using Bun $version from package.json" + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ steps.resolve.outputs.version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d11ebe15b..874b252c3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,10 +268,8 @@ jobs: # residue. Matches the convention already used by the other workflows. persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun # The GUI install is NOT optional here, however unrelated it looks to a # test shard. Several files under tests/ import JSX-bearing modules from @@ -315,10 +313,8 @@ jobs: with: persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: | @@ -356,10 +352,8 @@ jobs: with: persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: | @@ -393,10 +387,8 @@ jobs: # residue. Matches the convention already used by the other workflows. persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: | @@ -463,10 +455,8 @@ jobs: # residue. Matches the convention already used by the other workflows. persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: | @@ -598,10 +588,8 @@ jobs: # residue. Matches the convention already used by the other workflows. persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: | @@ -674,10 +662,8 @@ jobs: with: persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c2ef3c0bb..31ede9ab9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,10 +97,8 @@ jobs: fi # opencodex is bun-native (the prepublishOnly audit, GUI build, and typecheck run under bun). - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun # node + npm perform the actual publish. registry-url points npm at the public registry. - name: Setup Node diff --git a/.github/workflows/service-lifecycle.yml b/.github/workflows/service-lifecycle.yml index 0f96172688..8e0513b459 100644 --- a/.github/workflows/service-lifecycle.yml +++ b/.github/workflows/service-lifecycle.yml @@ -39,9 +39,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - uses: ./.github/actions/setup-project-bun - run: bun install --frozen-lockfile @@ -161,9 +159,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - uses: ./.github/actions/setup-project-bun - run: bun install --frozen-lockfile @@ -241,9 +237,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - uses: ./.github/actions/setup-project-bun - run: bun install --frozen-lockfile diff --git a/README.md b/README.md index 106251a8a4..380510496d 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,12 @@ ocx start # or `ocx service` to run it in the backgro ```
-Install from source (latest dev, Bun canary) +Install from source (latest dev) **macOS / Linux:** ```bash -curl -fsSL https://bun.sh/install | bash && ~/.bun/bin/bun upgrade --canary +curl -fsSL https://bun.sh/install | bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex && ~/.bun/bin/bun install ~/.bun/bin/bun run src/cli/index.ts start @@ -72,13 +72,13 @@ cd opencodex && ~/.bun/bin/bun install **Windows (PowerShell):** ```powershell -irm bun.sh/install.ps1 | iex; bun upgrade --canary +irm bun.sh/install.ps1 | iex git clone https://github.com/lidge-jun/opencodex.git cd opencodex; bun install bun run src/cli/index.ts start ``` -Source install runs the latest `dev` branch with Bun canary. Memory ownership +Source install runs the latest `dev` branch. Memory ownership patches, runtime GC improvements, and unreleased fixes are available here before they reach the npm package. @@ -208,7 +208,7 @@ Qwen Cloud, SiliconFlow, and more. Full list: `ocx init` or the ocx init # interactive setup (writes config, wires Codex, offers the shim) ocx start [--port 10100] # start the proxy in the foreground ocx stop # stop + restore native Codex -ocx service [install|start|stop|status|uninstall|remove] # background service +ocx service [install|repair|restart|start|stop|status|uninstall|remove] # background service ocx codex-shim install # start the proxy on demand whenever `codex` launches ocx health [--json] # check immediate proxy liveness ocx ready [--json] [--wait [--timeout ]] # check post-sync readiness diff --git a/bun.lock b/bun.lock index a9bc9f7c60..329084661e 100644 --- a/bun.lock +++ b/bun.lock @@ -8,11 +8,11 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.3.14", + "bun": "1.4.0", "zod": "4.4.3", }, "devDependencies": { - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "typescript": "7.0.2", }, }, @@ -59,39 +59,31 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="], - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="], - "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-09x7wnjMR6M5KGBDBhVl2CpfoCIQOkVDbPX2KfIhpXv4N6grbWE7dfLPw/Ydi9gaUMGhU7UKhoz444Nu6RCycA=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dRwzti/qJqV1HWplU27iUWUqp+f2DtFSf2yqQKSb+HH2dDOC//Uqd9u/A5h1DMsLszfP5OGP9UwQIKxVwFODaA=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y5yAtCbHK6JjprXEtkdklDQFPADgs+CkfcliyY5g4JJ8baGHyQSrfpSkX3XVJ2C+aBLsdwNDdW+oczMsAwx6uA=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HpPIxJfDNPBPhiBNMyZoo/dOLijARfsx5j72vNuLtaTvl0Hh7HUculxjsOQ2WSyGoCgqXMEr1Qqjab1im9u1RA=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RUjAAkJ/CdNV++zVxyANWshPc73CECYsfhk0fWAkoJjtywxJ2BwXzI6nopBBDMfs0HS+fhRGn6zGwU8ccxLeJg=="], - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Du44zebtPXJujvMLmtIxEQ6ykOhYt7L/Q+YIGVm+Yy+Pj/fpOnq60ggwIpKp/pGAFbYHNiTrA3JTjuZ9MTbZIg=="], - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.0", "", { "os": "android", "cpu": "x64" }, "sha512-u++KyLlfMn36yWz+AgJs+fZtS46UFDNpSSZhrcitkytONtNwq0X6Q9BDVEFXxYl/+Eec0xme1rb6MgW+U35WeA=="], - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1Dv+ISL8YKEKM9jAHzNifOcRUoziy6UMxh+yVXjUCP6QnbRhENDHLaIWWkQZJyBLTn0I3xozflorAlHiGzGqA=="], - "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FBAYaQpJBP0asgqzL6NFUfjdQqsV+kvTpJ/eWxPKj+RcDgIfPSuE8kvQuPYu5pa8u8JTujYMjmuyvHxVuQsInA=="], - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], - "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], - - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], - - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], - - "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], - - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], @@ -143,9 +135,9 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + "bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], diff --git a/devlog/_fin/260814_bun14-preview-dev/000_plan.md b/devlog/_fin/260814_bun14-preview-dev/000_plan.md new file mode 100644 index 0000000000..1a5c9e21f8 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/000_plan.md @@ -0,0 +1,43 @@ +# 000 — bun14-preview-dev: Plan + +## Objective + +Build preview-dev = dev + Bun 1.4 product candidate stack. When Bun 1.4.0 +ships to npm, the only change is dependency version bump — all compatibility +patches, CI qualification, and memory/stream/worker improvements are already +landed and verified on the canary. + +## Loop-spec + +- Loop archetype: spec-satisfaction (CI green + typecheck + memory gate) +- Write scope: src/, .github/, scripts/runtime/, tests/, devlog/, package.json, bun.lock +- Out of scope: npm publish, release.yml preview-dev gate, main/preview promotion, Go runtime, gui/ +- Budget: unbounded token, sol medium subagents + +## Work-phase map + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| 1 | 010 | CI composite action + preview-dev qualification | — | +| 2 | 020 | Bundle Bun 1.4.0-canary.1 | — | +| 3 | 030 | Runtime provenance recording | 020 | +| 4 | 040 | Memory comparison harness | 020 | +| 5 | 050 | Stream caps revision gate | 020 | +| 6 | 060 | Exact byte queue + global relay budget | 050 | +| 7 | 070 | Fetch body cancel on retry/failure | 020 | +| 8 | 080 | Worker settle skip on Bun 1.4 | 020 | +| 9 | 090 | Isolate teardown proof | 080 | +| 10 | 100 | Promotion + rollback docs | all | + +## Accept criteria + +- c-ci-sot: No hardcoded bun-version in .github/workflows/ (grep returns empty) +- c-bundle: package.json bun=1.4.0-canary.1, typecheck clean +- c-provenance: qualified-bun.json + runtime test pass +- c-memory: compare-bun-memory.ts exists with wave definitions +- c-stream-caps: bunHasAsyncPullCancelFix returns true for verified revision +- c-byte-queue: Explicit queue + per-stream/global caps in relay +- c-fetch-cancel: disposeResponseBody in all error paths +- c-worker: storageWorkerOsJoinSettleMs returns 0 for qualified Bun 1.4 +- c-isolate: CI has consolidated + split forms +- c-docs: Promotion doc with rollback steps diff --git a/devlog/_fin/260814_bun14-preview-dev/010_ci_composite.md b/devlog/_fin/260814_bun14-preview-dev/010_ci_composite.md new file mode 100644 index 0000000000..8f97474903 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/010_ci_composite.md @@ -0,0 +1,41 @@ +# 010 — CI: Source Bun version from package.json + +## Files + +### NEW: .github/actions/setup-project-bun/action.yml +Composite action that reads `dependencies.bun` from package.json and sets up Bun. + +```yaml +name: Setup project Bun +description: Install the Bun version declared in package.json +runs: + using: composite + steps: + - name: Resolve project Bun version + id: project-bun + shell: bash + run: | + version="$(node -p "require('./package.json').dependencies.bun")" + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ steps.project-bun.outputs.version }} +``` + +### MODIFY: .github/workflows/ci.yml +- Replace all 7 occurrences of `bun-version: 1.3.14` with composite action ref +- Add `preview-dev` to push.branches + +### MODIFY: .github/workflows/service-lifecycle.yml +- Replace all 3 occurrences of `bun-version: 1.3.14` with composite action ref + +### MODIFY: .github/workflows/release.yml +- Replace `bun-version: 1.3.14` with composite action ref (release stays main/preview only) + +## Verification +```bash +grep -r 'bun-version: 1.3.14' .github/ # must return empty +``` + diff --git a/devlog/_fin/260814_bun14-preview-dev/011_github_canary_channel.md b/devlog/_fin/260814_bun14-preview-dev/011_github_canary_channel.md new file mode 100644 index 0000000000..0d4036abd6 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/011_github_canary_channel.md @@ -0,0 +1,143 @@ +# 011 — GitHub canary channel (supersedes the npm-only assumption in 020) + +## Why this doc exists + +`020_bundle_canary.md` assumed the canary arrives as an npm dependency +(`"bun": "1.4.0-canary.1"`). That assumption is wrong and it stalled the whole +train: npm has no 1.4 line at all. + +```console +$ npm view bun dist-tags --json +{ "latest": "1.3.14", "canary": "1.3.13-canary.20260425.1" } +``` + +GitHub does have it. `oven-sh/bun` publishes a rolling `canary` release tag +carrying 34 platform assets, and it is already 1.4: + +```console +$ gh release download canary --repo oven-sh/bun --pattern 'bun-darwin-aarch64.zip' +$ ./bun-darwin-aarch64/bun --version +1.4.0 +$ ./bun-darwin-aarch64/bun --revision +1.4.0-canary.1+032b8dbf1 +``` + +So the correct sequencing is: **qualify against the GitHub canary now, switch to +the npm dependency at stable release.** The npm path is the destination, not the +prerequisite. + +## What does NOT change + +`package.json` stays `"bun": "1.3.14"`. The npm dependency is the *shipped* +runtime for users; it moves once on stable day (`020`). Pointing it at a +nonexistent npm version would break every install. + +## Runtime selection — already built + +No new mechanism is needed. `bin/ocx.mjs:374` and `src/lib/bun-runtime.ts:22` +already implement `OPENCODEX_BUN_PATH`, and `resolveBun()` gives a valid +override precedence over the bundled dependency, reporting `source: "override"`. + +```text +OPENCODEX_BUN_PATH= → source=override (qualification runs) +unset → source=bundled (npm 1.3.14, users) +``` + +That is exactly the A/B shape §4 of the task spec asks for: same checkout, same +`node_modules`, two binaries. + +## NEW: scripts/runtime/fetch-canary-bun.ts + +Downloads the GitHub canary for the host platform into a gitignored cache and +prints the resolved binary path, version, and revision as JSON. + +- Resolve asset name from `process.platform` + `process.arch` + (`bun-darwin-aarch64`, `bun-linux-x64`, `bun-windows-x64`, musl/baseline variants). +- Download via the GitHub releases API. +- Compute the SHA-256 of what we actually received and RECORD it. +- Cache under `.tmp/bun-canary//` and reuse when present. +- Emit `{ path, version, revision, assetName, sha256, shasumsMatch }`. + +### Do NOT gate on SHASUMS256.txt (audit finding) + +The first draft of this doc said "verify against `SHASUMS256.txt`". Measured on +2026-08-14, that check FAILS on a correct download: + +```console +$ shasum -a 256 bun-darwin-aarch64.zip +f153e5eca706db593416cce00e9d02858d76da57794cf30bb7411290b8c8130f +$ grep bun-darwin-aarch64.zip SHASUMS256.txt +e5fab4d53d070cdb4f3c19ba795e23aa95d5288ba595c6d3517d55138990ec36 +``` + +The asset was re-uploaded at `2026-08-14T11:52:50Z` while `SHASUMS256.txt` still +dates from `2026-08-13T14:30:31Z`. On a rolling tag the checksum manifest lags +the binaries it describes, so a hard SHA gate would have failed every run — the +script would have been dead on arrival. + +Two independent downloads minutes apart returned the identical SHA +(`f153e5e…`) and the identical revision, so the asset itself is stable at any +given moment; the stale artifact is the manifest. + +Therefore: **`Bun.revision` is the pin, not the SHA.** Record `shasumsMatch` +as an advisory boolean and warn on mismatch, but never fail the run on it. What +makes a build trustworthy here is that CI executed the suite against that exact +revision — the qualification, not a manifest line. + +## NEW: scripts/runtime/qualified-bun.json + +Supersedes the shape drafted in `030`, now with a real revision: + +```json +{ + "candidate": { + "channel": "github-canary", + "version": "1.4.0", + "revision": "1.4.0-canary.1+032b8dbf1", + "sourceCommit": "032b8dbf137807a7c340f9a5d1894ab6ccd2663d" + }, + "control": { "version": "1.3.14", "channel": "npm-bundled" }, + "qualifiedRevisions": [] +} +``` + +`qualifiedRevisions` stays EMPTY until CI proves a revision. Downloading a +binary is not qualification; `050` and `080` read this list, so writing a +revision in here on faith is the exact failure the revision gate exists to stop. + +## MODIFY: .github/actions/setup-project-bun/action.yml + +Add an optional `channel` input, defaulting to `package`: + +```yaml +inputs: + channel: + description: package | github-canary + default: package +``` + +With `github-canary`, the action downloads the canary asset and exports +`OPENCODEX_BUN_PATH` plus `OCX_QUALIFIED_BUN_REVISION` instead of calling +`oven-sh/setup-bun`. The `package` path is untouched, so every existing job +keeps its current behavior. + +## MODIFY: .github/workflows/ci.yml + +Add a `bun-canary-qualify` job, `preview-dev` push only, `continue-on-error: true`. +It runs typecheck + the full suite under the canary and uploads the revision as an +artifact. Non-blocking on purpose: a rolling upstream tag must not be able to +red the branch's own CI. + +## Verification + +```bash +bun scripts/runtime/fetch-canary-bun.ts --json # exit 0, prints revision +OPENCODEX_BUN_PATH= bun run typecheck # exit 0 +OPENCODEX_BUN_PATH= bun test # record pass/fail per file +``` + +## Exit condition + +This doc is done when CI can run the suite under the GitHub canary and report a +revision. Promoting that revision into `qualifiedRevisions` is `030`'s job, and +only after the run is green. diff --git a/devlog/_fin/260814_bun14-preview-dev/012_canary_findings.md b/devlog/_fin/260814_bun14-preview-dev/012_canary_findings.md new file mode 100644 index 0000000000..48671e85d0 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/012_canary_findings.md @@ -0,0 +1,185 @@ +# 012 — What the canary lane found + +A running log of behaviour differences between the bundled stable runtime and +the qualification candidate. This is the output the lane exists to produce: +each entry is a difference that would otherwise have surfaced on Bun 1.4 +release day, with production traffic attached. + +- control: `1.3.14+0d9b296af` (npm bundled) +- candidate: `1.4.0-canary.1+032b8dbf1` (GitHub `canary`) + +## F1 — TOML datetime is now supported (real upstream change) + +**Bun 1.4 added TOML datetime parsing.** Measured directly: + +```text +Bun.TOML.parse('model_catalog_json = 1979-05-27T07:32:00Z') + +1.3.14 → THREW BuildMessage: Expected key but found - +1.4.0 → type=string "1979-05-27T07:32:00Z" +``` + +Fallout: `tests/codex-native-residue.test.ts` asserted that every non-string +TOML type for `model_catalog_json` lands on `surface: "config"`. On 1.3.14 that +holds because the document does not parse at all. On 1.4 the value parses to a +string, `src/codex/native-residue.ts` accepts it (it requires a non-empty +string, and now gets one), resolves it as a path, and the CATALOG surface +reports it absent. + +`src/` is correct on both runtimes — only the test's assumption was +version-specific. Fixed in `4e64e96f7` by splitting the datetime case out and +asserting the property that survives both: the classification is +`indeterminate`, blamed on `config` or `catalog`, and coordinator +initialization is refused either way. + +**Worth noticing beyond the test:** any config key read through +`Bun.TOML.parse` that a user could write as a bare datetime literal silently +changes type between these runtimes — throw on 1.3.14, string on 1.4. This is +the only such key in the tree today, but it is the shape to watch for. + +## F2 — SHASUMS256.txt lags the rolling assets (upstream artifact, not a bug here) + +## F1c — TOML values must start on the assignment line (real upstream change, and it found a latent bug) + +**Bun 1.4 enforces the TOML rule that a value begins on its assignment line.** + +```text +[features.multi_agent_v2] +hint = +[ + ["nested"], +] +enabled = true + +1.3.14 → {"features":{"multi_agent_v2":{"hint":[["nested"]],"enabled":true}}} +1.4.0 → THREW: Missing value after '='; values must be on the same line +``` + +1.4 is right; the document is not valid TOML. + +What makes this the most interesting finding so far is that it exposed a +**latent defect in our own fallback**, not just a test assumption. +`multiAgentV2EnabledFromConfigText` tries a real parse first and falls back to a +line-based scanner when the parse fails. On 1.3.14 the parse always succeeded +here, so the fallback was never exercised for this shape. On 1.4 the parse fails, +the fallback runs, and `tomlTableBody` reads the `[` on the line after `hint =` +as a table header — truncating the table before `enabled = true` and answering +`false` for a feature the user enabled. + +That is exactly the failure mode the function's own comment warns against: +"reporting a feature as disabled on account of an unreadable file presents a +failure as a state." + +The scanner itself is off limits — its comment records that making it +string-aware previously gave `getAgentsEnabled`, `getAgentsMaxDepth`, and +`getMaxConcurrentThreads` three new wrong answers, and twenty call sites consume +its output. So the repair is at the call site: if the real parse fails, join +dangling `key =` lines to the value that follows and parse once more. If the +joined document parses, that answer wins; if it does not, the old fallback runs +unchanged. The unmodified document is always tried first, so a bad join cannot +displace a correct read. + +Fixed in `src/codex/features.ts`. 132 pass / 0 fail on both runtimes. + +## F1b — An empty PATH is now passed through to children (real upstream change) + +**Bun 1.4 stopped ignoring `PATH=""`.** Measured by having a child print its +own `$PATH` while the parent set `process.env.PATH = ""`: + +```text +1.3.14 → CHILD_PATH=[/Users/…/bin:/opt/homebrew/bin:/usr/bin:/bin:…] (parent's real PATH) +1.4.0 → CHILD_PATH=[] (what was actually set) +``` + +1.4 is right. 1.3.14 silently substituted the inherited PATH, so `PATH=""` was +never really in effect. + +Fallout: three tests in `tests/codex-runtime.test.ts` set `PATH=""` to stop +PATH-based codex discovery, but their fake launchers are `/bin/sh` scripts that +call `dirname` and `cat`. On 1.3.14 those resolved through the leaked PATH; on +1.4 they fail with `dirname: No such file or directory`, the launcher exits +non-zero, `loadBundledCodexCatalog()` returns null, and the assertion sees +`undefined` where it expected `false`. + +The intent was to defeat discovery, not to starve the script of coreutils, so +the fix is `PATH=/usr/bin:/bin` — utilities reachable, no `codex` on it. Fixed +in `tests/codex-runtime.test.ts` via a named `NO_CODEX_PATH` constant. + +**Worth noticing beyond the test:** any code that clears `PATH` to sandbox a +child now genuinely gets an empty PATH on 1.4. Checked, and production is +clear: `rg 'PATH\s*[:=]\s*""|delete .*\.PATH' src/ bin/ scripts/` returns +nothing, and the one generated shell script in the tree +(`buildUnixCodexShim`) uses only shell builtins — `printf`, `exit`, `case` — +so it has no coreutils dependency to lose. The exposure was test-only, but a +spawn that relied on the old leak would have broken at runtime rather than in +a test. + +```text +bun-darwin-aarch64.zip updated 2026-08-14T11:52:50Z +SHASUMS256.txt updated 2026-08-13T14:30:31Z +published digest e5fab4d53d07… +actual digest f153e5eca706… +``` + +Two independent downloads produced identical bytes and an identical revision, +so the assets are stable per-moment; the manifest is what is stale. Recorded in +`011`: `shasumsMatch` is advisory, `Bun.revision` is the pin. + +## F3 — The harness qualified the wrong runtime (our bug) + +`scripts/ci/run-bun-test-batches.sh` invoked `bun` from PATH, so the lane +exported `OPENCODEX_BUN_PATH` and then ran the bundled stable binary anyway — +a qualification that never happened, reported as if it had. Fixed in +`bce1fe8f4`. + +This one is the argument for running the lane at all rather than reasoning +about the runtime on paper. + +## F4 — Our own CI drift, surfaced by the lane (our bug) + +The first canary run failed `tests/ci-workflows.test.ts` with 3 failures and a +runtime crash. The same 3 failed identically on 1.3.14: earlier commits on this +branch moved the pinned `setup-bun` SHA into the composite action, added a job, +and added a branch, while the hardening test still described the old shape. +Fixed in `7bab92781`. + +Bun 1.4 was innocent. Noted because "the canary run is red" is not by itself +evidence about the canary — the control run is what decides that. + +## Full-suite result + +Run on a dedicated machine (Mac mini, macOS 15.7.4, arm64, 10 cores) rather +than a laptop shared with other work, so a stall could not be confused with +contention: + +```console +$ bun test --isolate --timeout 60000 tests/ # bun = 1.4.0-canary.1 (032b8dbf1) + 11712 pass + 8 skip + 0 fail +Ran 11720 tests across 726 files. [422.37s] +CANARY_EXIT=0 +``` + +Every difference above is closed. Note the machine had no Node installed at +first and `tests/cursor-native-exec.test.ts` failed twice — identically on BOTH +runtimes, because the test shells out to `node`. Environment, not runtime; +installing Node 24 cleared it. Same discipline as F4: a red run is not evidence +about the candidate until the control run says otherwise. + +## Not yet observed + +Nothing yet on the surfaces the migration actually targets: SSE relay +behaviour, Worker teardown timing, or fetch receive-backpressure. A green +functional suite says the runtime swap is safe; it says nothing about the +memory characteristics this migration is FOR. Those need the memory harness +(`040`) and the revision-gated paths (`050`, `080`), which stay closed until a +revision is in `qualifiedRevisions`. + +## Promotion status + +`qualifiedRevisions` is still EMPTY. One green macOS run is not qualification +across the supported platforms — Linux and Windows are exactly where the Bun +1.3.14 Worker and isolate problems lived, and they are the reason the split +fresh-process CI jobs exist. The CI lane on `preview-dev` is what supplies +the remaining evidence. diff --git a/devlog/_fin/260814_bun14-preview-dev/020_bundle_canary.md b/devlog/_fin/260814_bun14-preview-dev/020_bundle_canary.md new file mode 100644 index 0000000000..644a4b4f2c --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/020_bundle_canary.md @@ -0,0 +1,20 @@ +# 020 — Bundle Bun 1.4.0-canary.1 + +## Files + +### MODIFY: package.json +```diff +- "bun": "1.3.14", ++ "bun": "1.4.0-canary.1", +``` +`@types/bun` stays at 1.3.14 (canary types may not exist). + +### MODIFY: bun.lock +Regenerated via `bun install`. + +## Verification +```bash +bun run typecheck +bun run test +``` + diff --git a/devlog/_fin/260814_bun14-preview-dev/030_runtime_provenance.md b/devlog/_fin/260814_bun14-preview-dev/030_runtime_provenance.md new file mode 100644 index 0000000000..708d26ff68 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/030_runtime_provenance.md @@ -0,0 +1,21 @@ +# 030 — Runtime provenance recording + +## Files + +### NEW: scripts/runtime/qualified-bun.json +```json +{ + "candidateSpec": "1.4.0-canary.1", + "qualifiedRevisions": [], + "control": { + "version": "1.3.14", + "revision": "0d9b296af33f2b851fcbf4df3e9ec89751734ba4" + } +} +``` + +### NEW: tests/bundled-bun-runtime.test.ts +- Assert Bun.version matches package.json dependencies.bun +- Record Bun.revision for qualification tracking +- Verify runtime source (bundled vs override vs process) + diff --git a/devlog/_fin/260814_bun14-preview-dev/040_memory_harness.md b/devlog/_fin/260814_bun14-preview-dev/040_memory_harness.md new file mode 100644 index 0000000000..1b71088d6d --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/040_memory_harness.md @@ -0,0 +1,11 @@ +# 040 — Memory comparison harness + +## Files + +### NEW: scripts/runtime/compare-bun-memory.ts +Wave-based A/B memory comparison script: +- Waves: startup, SSE-normal, SSE-slow, SSE-abort, error-bodies, worker, HTTP/2, WebSocket, restart, idle-recovery +- Records: rss, heapUsed, heapTotal, external, arrayBuffers, app-owned retained +- Hard correctness gate: crash/hang/timeout, leaked turns/workers/reservations/budget +- Comparison gate: wave-by-wave idle median slope analysis + diff --git a/devlog/_fin/260814_bun14-preview-dev/050_stream_caps_revision.md b/devlog/_fin/260814_bun14-preview-dev/050_stream_caps_revision.md new file mode 100644 index 0000000000..fe2322d3eb --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/050_stream_caps_revision.md @@ -0,0 +1,29 @@ +# 050 — Enable eager relay for qualified canary revisions + +## Files + +### MODIFY: src/lib/bun-stream-caps.ts +```diff ++const VERIFIED_CANARY_REVISIONS = new Set([ ++ // Populated after CI qualification passes with a specific canary build ++]); ++ + export function bunHasAsyncPullCancelFix( + version: string, ++ revision: string = Bun.revision, + minFixed: string | null = MIN_FIXED_BUN_VERSION, + ): boolean { ++ if ( ++ version === "1.4.0-canary.1" ++ && VERIFIED_CANARY_REVISIONS.has(revision) ++ ) { ++ return true; ++ } + if (!minFixed) return false; + if (hasPrereleaseSuffix(version)) return false; +``` + +### MODIFY: tests/bun-stream-caps.test.ts +- Add test for revision-based canary qualification +- Test unverified revision still returns false + diff --git a/devlog/_fin/260814_bun14-preview-dev/060_byte_queue.md b/devlog/_fin/260814_bun14-preview-dev/060_byte_queue.md new file mode 100644 index 0000000000..25045d495b --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/060_byte_queue.md @@ -0,0 +1,25 @@ +# 060 — Exact byte queue + global relay budget + +## Files + +### MODIFY: src/server/relay-eager.ts +Replace approximate `queuedBytes = 0` in pull() with explicit queue: +- Producer pushes to Array instead of controller.enqueue +- pull() dequeues one chunk, decrements per-stream + global counters +- Per-stream cap: 8 MiB (unchanged) +- Global process cap: 64 MiB (new) +- Producer pauses reader.read() when either cap hit +- cancel/error/finally releases all remaining reservation + +### NEW: src/server/relay-budget.ts +Global relay byte budget singleton: +- reserve(bytes): boolean +- release(bytes): void +- metrics(): { active: number, peak: number } + +### MODIFY: tests (relay tests) +- 1-byte slow consumer test +- 32+ concurrent slow consumers +- Queue full then cancel +- Global budget exhaustion + diff --git a/devlog/_fin/260814_bun14-preview-dev/070_fetch_cancel.md b/devlog/_fin/260814_bun14-preview-dev/070_fetch_cancel.md new file mode 100644 index 0000000000..45059bae28 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/070_fetch_cancel.md @@ -0,0 +1,30 @@ +# 070 — Cancel unconsumed response bodies on retry/failure + +## Files + +### NEW: src/lib/dispose-response-body.ts +```ts +export async function disposeResponseBody( + response: Response, + reason: unknown, +): Promise { + try { + await response.body?.cancel(reason); + } catch { + // disposal must not replace the original failure + } +} +``` + +### MODIFY: Multiple files with fetch error paths +Audit targets (30+ fetch sites): +- src/oauth/*.ts — 401/403 auth refresh +- src/providers/quota.ts — 429 rotation, balance checks +- src/adapters/mimo-free.ts — 5xx retry +- src/update/job.ts — healthz probe +- src/images/xai-client.ts — image sidecar +- src/cli/debug.ts, src/cli/claude.ts — CLI probes +- src/claude/gateway-cache.ts — model cache + +Pattern: after `if (!res.ok)` or catch block, call disposeResponseBody before throw/return. + diff --git a/devlog/_fin/260814_bun14-preview-dev/080_worker_settle.md b/devlog/_fin/260814_bun14-preview-dev/080_worker_settle.md new file mode 100644 index 0000000000..d85c63b11e --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/080_worker_settle.md @@ -0,0 +1,35 @@ +# 080 — Skip 1.3.14 OS join settles on qualified Bun 1.4 + +## Files + +### MODIFY: src/storage/worker-lifecycle.ts +```diff +-export function storageWorkerOsJoinSettleMs(platform = process.platform): number { ++export function storageWorkerOsJoinSettleMs( ++ platform = process.platform, ++ runtime = currentBunQualification(), ++): number { ++ if (runtime.workerCloseIsPostJoin) return 0; + if (platform === "win32") return 1_500; + if (platform === "darwin" || platform === "linux") return 250; + return 0; + } +``` + +### MODIFY: src/storage/worker-lifecycle.ts (terminateStorageWorker) +Wire terminate() thenable when available: +```ts +const result = worker.terminate() as unknown; +if (result && typeof result === "object" && typeof (result as any).then === "function") { + await result; +} +``` + +### NEW or MODIFY: src/lib/bun-qualification.ts +```ts +export type BunQualification = { + workerCloseIsPostJoin: boolean; +}; +export function currentBunQualification(): BunQualification { ... } +``` + diff --git a/devlog/_fin/260814_bun14-preview-dev/090_isolate_teardown.md b/devlog/_fin/260814_bun14-preview-dev/090_isolate_teardown.md new file mode 100644 index 0000000000..9b2a309359 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/090_isolate_teardown.md @@ -0,0 +1,9 @@ +# 090 — Prove Bun 1.4 isolate teardown without legacy job splits + +## Files + +### MODIFY: .github/workflows/ci.yml +Add a consolidated test job that runs storage policy + API usage families +in normal shard mode alongside the existing split/fresh-process jobs. +Both forms run; consolidated passing proves 1.4 teardown is clean. + diff --git a/devlog/_fin/260814_bun14-preview-dev/100_promotion_docs.md b/devlog/_fin/260814_bun14-preview-dev/100_promotion_docs.md new file mode 100644 index 0000000000..354bb0c7c7 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/100_promotion_docs.md @@ -0,0 +1,19 @@ +# 100 — Promotion and rollback documentation + +## Files + +### NEW: devlog/_plan/260814_bun14-preview-dev/PROMOTION.md +Document the stable-day promotion sequence: +1. preview-dev -> dev (fast-forward) +2. dev -> preview (reset --hard) +3. npm preview publish with stable bun +4. Cross-platform CI + service lifecycle verify +5. dev -> main +6. npm latest publish + +Rollback procedure: +- OPENCODEX_BUN_PATH=1.3.14 binary +- streamMode=legacy-tee +- Revert package.json bun to 1.3.14 +- Keep split fresh-process CI jobs + diff --git a/devlog/_fin/260814_bun14-preview-dev/README.md b/devlog/_fin/260814_bun14-preview-dev/README.md new file mode 100644 index 0000000000..83077e2d62 --- /dev/null +++ b/devlog/_fin/260814_bun14-preview-dev/README.md @@ -0,0 +1,118 @@ +# preview-dev — Bun 1.4 product candidate branch + +`preview-dev` is not a feature branch. It is the staging line where the Bun 1.4 +migration is finished **before** Bun 1.4.0 reaches npm, so release day is a +dependency bump instead of a migration. + +```text +dev (stable line, Bun 1.3.14) +└─ preview-dev (Bun 1.4 canary + compatibility/memory patches) + └─ Bun 1.4.0 on npm + └─ canary → stable commit + └─ preview-dev → dev → preview → main +``` + +Upstream tracking issue: **#1691**. + +## Current state (2026-08-14) + +Landed on `preview-dev`: + +- `c7c34f6e3` — CI reads the Bun version from `package.json`; `preview-dev` is a + CI-qualified push target. +- `f8f9200d4` — this plan unit (decade docs `010`–`100`). +- `d29b837e3` — this README. +- `bce1fe8f4` — the GitHub canary qualification channel (`011`). + +**The canary is available, just not on npm.** npm has no 1.4 line +(`latest 1.3.14`, `canary 1.3.13-canary.20260425.1`), but the `oven-sh/bun` +GitHub `canary` release already serves **1.4.0-canary.1+032b8dbf1**. So the +runtime is reachable today through the existing `OPENCODEX_BUN_PATH` override: + +```bash +bun scripts/runtime/fetch-canary-bun.ts --json +CANARY="$(bun scripts/runtime/fetch-canary-bun.ts --print-path)" +OPENCODEX_BUN_PATH="$CANARY" bun run typecheck +``` + +`package.json` deliberately stays at `1.3.14` — that is the runtime users +install. npm is the destination on stable day, not the prerequisite. + +The runtime patches (stream caps, relay byte queue, fetch body disposal, worker +settle skip) are still **not** committed. Each is gated on a *qualified* +`Bun.revision`, and `qualifiedRevisions` in +`scripts/runtime/qualified-bun.json` is empty until CI proves a revision on +every supported OS. Having the binary is not qualification. + +## Resuming + +```bash +git fetch origin +git switch preview-dev +git rebase origin/dev # keep the stack on current dev +``` + +Push to `preview-dev` and the `bun-canary-qualify` lane runs the suite against +the canary and reports its revision. When that is green across the supported +platforms, add the revision to `qualifiedRevisions` in its own commit — that +single edit is what opens `050` and `080`. + +Then work the decade docs in dependency order. Each doc is one PABCD cycle and +carries its own diff-level file map: + +| Doc | Commit | Depends on | +|-----|--------|------------| +| `011` | `ci(runtime): qualify Bun 1.4 from the GitHub canary channel` | done | +| `020` | `chore(runtime): move the npm dependency to Bun 1.4` | stable release day | +| `030` | `test(runtime): record bundled Bun version and revision` | 020 | +| `040` | `test(memory): 1.3.14 vs 1.4 wave and quiescence harness` | 020 | +| `050` | `perf(stream): eager relay for qualified canary revisions` | 020 | +| `060` | `perf(stream): exact byte queue and global relay budget` | 050 | +| `070` | `fix(fetch): cancel unconsumed bodies on retry and failure` | 020 | +| `080` | `perf(worker): skip 1.3.14 OS join settles on qualified Bun 1.4` | 020 | +| `090` | `test(runtime): isolate teardown without legacy job splits` | 080 | +| `100` | `docs(release): promotion and rollback` | all | + +The qualified `Bun.revision` gates the rest: `050` and `080` branch on it. Do +not open those gates on a version string — that is what `011` exists to prevent. + +## Branch invariants + +```text +merge-base(preview-dev, dev) == dev, or a very recent dev +preview-dev unique commits == Bun 1.4 migration commits only +``` + +Rebase onto `dev` daily, or right after any significant merge. If unrelated +feature drift accumulates here, release-day triage cannot tell a Bun regression +from a feature regression — which is the entire reason this branch exists. + +## Release day + +One commit: + +```diff +- "bun": "1.4.0-canary.N" ++ "bun": "1.4.0" +- export const MIN_FIXED_BUN_VERSION: string | null = null; ++ export const MIN_FIXED_BUN_VERSION = "1.4.0"; +``` + +Plus `bun.lock` regeneration and removal of canary-only waivers. Promotion order +is `preview-dev → dev → preview → main`; see `100_promotion_docs.md`. + +## Rollback devices — keep until the first stable-1.4 release + +- `OPENCODEX_BUN_PATH` pointing at a 1.3.14 binary +- `streamMode=legacy-tee` +- the 1.3.14 worker settle fallback +- split fresh-process CI jobs +- the 1.3.14 A/B benchmark binary + +## Not allowed on this branch + +- `npm publish` from `preview-dev` — the release workflow stays pinned to + `preview` and `main`, which is what prevents an accidental canary publish +- enabling runtime capability from a canary semver alone, without a qualified + `Bun.revision` +- `Bun.gc(true)` on a timer, `--smol` as a default, or blanket cache-cap cuts diff --git a/devlog/_plan/260818_fastwire_b2_xai/evidence/010_logs_priority_lower_bound.png b/devlog/_plan/260818_fastwire_b2_xai/evidence/010_logs_priority_lower_bound.png new file mode 100644 index 0000000000..e7ac2ea007 Binary files /dev/null and b/devlog/_plan/260818_fastwire_b2_xai/evidence/010_logs_priority_lower_bound.png differ diff --git a/devlog/_plan/260818_fastwire_b2_xai/evidence/README.md b/devlog/_plan/260818_fastwire_b2_xai/evidence/README.md new file mode 100644 index 0000000000..0ae66d31f3 --- /dev/null +++ b/devlog/_plan/260818_fastwire_b2_xai/evidence/README.md @@ -0,0 +1,17 @@ +# FastWire B2 (xAI) — UI evidence + +`010_logs_priority_lower_bound.png` — Logs & Debug, three seeded `xai/grok-4.6` rows +that exercise every branch of the new pricing path: + +| Row | Situation | Cost cell | +| --- | --- | --- | +| `req-standard` | no Fast requested | `~$0.0300` | +| `req-priority` | response-confirmed priority, prompt under the long-context threshold | `~$0.0600` — exactly the documented 2x premium over the row above | +| `req-longctx-priority` | response-confirmed priority, prompt at or above 200k | `≥$0.8760` — the published long-context rate, marked a lower bound because xAI publishes no combined price | + +The `≥` prefix is the visible change: a cost that is a known floor rather than an +estimate now says so instead of rendering as `~$`. The detail drawer explains why +via the `priority_lower_bound` estimate reason. + +Captured against a local proxy with a seeded `usage.jsonl`; no live xAI request was +billed to produce it. diff --git a/devlog/_plan/260820_sidecar_selection_unification/160_vision_external_research.md b/devlog/_plan/260820_sidecar_selection_unification/160_vision_external_research.md new file mode 100644 index 0000000000..34ae854f80 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/160_vision_external_research.md @@ -0,0 +1,110 @@ +# 160 — Vision external-backend research (xai Grok / Antigravity Gemini describers) + +Continuation of #2188. Web-search shipped four external backends (L6-L9, docs +060-090); the vision sidecar still dispatches only openai-forward and +anthropic-OAuth. GUI evidence: the vision dropdown lists only Codex/Claude +rows while the web-search dropdown already lists Grok/Gemini. + +## Current vision dispatch inventory + +- Types: `OcxVisionSidecarConfig.backend?: "openai" | "anthropic"` (src/types.ts). +- Union: `VisionSidecarBackend` (src/vision/eligibility.ts:30) — 2 arms. +- Candidate mapping: `visionBackendForCandidate` (eligibility.ts:150-165) — + native/openai → openai; anthropic only via the resolved OAuth provider name. +- Options: `visionEligibleModelOptions` (eligibility.ts:201+) iterates + `["openai","anthropic"] as const` and injects `BASELINE_VISION_MODELS`. +- Enabled backends: `enabledVisionBackends` + (src/server/management/vision-sidecar-options.ts:31-43); empty-auth fallback + returns both universal sides. +- Write gate: `visionDescriberIsProvablyBlind` (vision-sidecar-options.ts:94+) + probes ONLY the openai/anthropic vendor tables. +- PUT validation: config-routes.ts:594-596 rejects backends outside the two + literals; hint fall-through at :623; claude-code override near :738-740. +- Runtime plan: `planVisionSidecar` (src/vision/index.ts) — anthropic arm and + openai-forward arm only. `resolveVisionBackend`: explicit > anthropic-if-auth + > openai. +- GUI: `SidecarBackend = "openai" | "anthropic"` (gui/src/pages/ + dashboard-shared.ts:62, claude-manual-env.ts:8). NOTE: this type is shared + with WebSearchModelOption and is ALREADY stale — the server emits + xai/gemini/exa web rows today. + +## Wire research (from shipped web-search executors, probe-verified 2026-08-20/21) + +### xai describe wire + +Mirror src/web-search/xai-executor.ts: POST `https://api.x.ai/v1/responses` +(origin pinned; provider baseUrl honored only on same origin), stored OAuth +bearer via `getValidAccessToken`, `redirect: "manual"`. Body for describe: + +```json +{ + "model": "", + "instructions": "", + "input": [{ "role": "user", "content": [ + { "type": "input_text", "text": "" }, + { "type": "input_image", "image_url": "" } + ]}], + "reasoning": { "effort": "" }, + "stream": true +} +``` + +SSE reduction: reuse the `response.output_text.delta` / `.done` handling +shape from parseXaiResponsesSSE, without the citation/source machinery. +Grok Responses accepts `input_image` with data URLs (same shape the OpenAI +forward describer already posts — describe.ts builds input_image parts). + +### Gemini (Antigravity CCA) describe wire + +Mirror src/web-search/gemini-executor.ts: POST +`{registry base}/v1internal:generateContent`, `ANTIGRAVITY_REQUEST_UA`, +token + projectId via `getValidAccessTokenSnapshot`, envelope: + +```json +{ + "model": "", + "userAgent": "antigravity", "requestType": "agent", + "project": "", "requestId": "agent-", + "request": { + "systemInstruction": { "role": "user", "parts": [{ "text": "" }] }, + "contents": [{ "role": "user", "parts": [ + { "text": "" }, + { "inlineData": { "mimeType": "", "data": "" } } + ]}] + } +} +``` + +inlineData shape matches src/adapters/google.ts:972/:1233. Response mapping: +`candidates[0].content.parts[].text` join (mapCcaGroundedResponse shape, +minus grounding). https: image URLs cannot be inlined without proxy-side +fetch — REJECTED for gemini describe (data: URLs only, documented delta, +same stance as anthropic-describe's stricter base64 rule). + +## Metadata facts + +- xai vendor table: bare grok-2/grok-3/grok-4 are `text`-only; grok-4.x + fast/4.3/4.5/4.6 and grok-2-vision are `text,image`. +- No bare model id collides across the four vendor tables (openai 48, + anthropic 26, xai 32, google 43; collision scan 2026-08-21: zero) — the + "vendor tables never disagree" premise of visionDescriberIsProvablyBlind + survives widening to four families. + +## Audit deltas folded into this unit (sol-medium audit, 2026-08-21) + +- **Blocker A**: `BASELINE_VISION_MODELS` is a TOTAL + `Record`; widening the union without a + decision breaks typecheck. Decision → doc 170: baselines become + descriptor-owned (only openai/anthropic carry one). +- **Blocker B**: `visionDescriberIsProvablyBlind` collapses non-anthropic + hints to openai and probes two families; a bare grok id absent from + candidates would slip the gate. Decision → doc 170: probe all four vendor + families. +- Empty-auth fallback stays `["openai","anthropic"]` — never offer + xai/gemini unauthenticated. +- GUI shared `SidecarBackend` must split (web-search has exa; vision does + not). +- New executors: `sidecarEnter("vision")` (NOT "web-search"), + `signalWithTimeout` + `cancelBodyOnAbort`, `redactSecretString` on all + error paths, timeout-bounds.ts as single authority. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/170_vision_backend_union.md b/devlog/_plan/260820_sidecar_selection_unification/170_vision_backend_union.md new file mode 100644 index 0000000000..9094c1fb2a --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/170_vision_backend_union.md @@ -0,0 +1,80 @@ +# 170 — Backend union: "routed" describer (wp2, REVISED) + +Depends on: 160. REVISION 2026-08-22: user directive — vision does not need +per-backend executors. Any picker-visible model with image input can describe; +the proxy's own router already speaks every provider wire. The earlier +xai/gemini backend literals were implemented but never released; this revision +replaces them before any push. + +## Design + +- `VisionSidecarBackend = "openai" | "anthropic" | "routed"`. +- "openai"/"anthropic" arms unchanged (forward Responses / OAuth Messages) — + they carry auth semantics loopback routing cannot replicate (forwarded + headers, OAuth beta fences), and their defaults must not drift. +- "routed": the describer is ANY routed model, dispatched through the proxy's + own /v1/chat/completions on loopback (pattern: src/claude/gateway-cache.ts + self-fetch). One executor, every provider. + +## Filter (#2188 rules, unchanged shape) + +1. Picker-visible ∪ auth slots (pickerVisibleSidecarCandidates). +2. − provably text-only (modelAcceptsImageInput === false drops the row). + +visionBackendForCandidate: native/openai → openai; resolved-OAuth anthropic +row → anthropic; ANY OTHER provider row → "routed". Routed option values are +NAMESPACED ("provider/model") so routeModel is unambiguous; legacy sides keep +bare ids (GUI/current-value compatibility). + +## Gate + +visionDescriberIsProvablyBlind keeps the four-family probe widening AND +learns namespaced ids: split on first "/", probe that provider's config row + +metadata family. Bare ids keep the existing all-family probe. + +## Runtime + +- planVisionSidecar routed arm requires: cfg.backend === "routed", explicit + cfg.model, and plan-time modelAcceptsImageInput !== false for the target. +- Recursion safety: the loopback request re-enters the vision planner only if + the routed model is provably text-only; the plan-time check excludes exactly + that set, so describe recursion is structurally impossible. +- resolveVisionBackend: explicit honored; unset default order UNCHANGED. + +## Files (wp2 scope, revised) + +- src/vision/eligibility.ts: union, visionBackendForCandidate routed arm, + namespaced option values, BASELINE narrow-key record (kept from r1). +- src/vision/backends.ts (r1 descriptor table): SIMPLIFIED — descriptors for + openai/anthropic/routed; xai/gemini entries dropped. +- vision-sidecar-options.ts: enabledVisionBackends offers "routed" whenever + any routed row exists; gate learns namespaced ids. +- config-routes.ts + agent-settings-routes.ts: literal sets accept "routed" + (xai/gemini literals removed). +- types: backend unions. +- tests: vision-backend-union.test.ts rewritten for routed. + + +## Audit round 2 amendments (2026-08-22, sol-medium) + +- **Recursion fence is a MECHANISM, not a predicate claim.** The loopback + describe request carries a terminal marker header + `x-opencodex-vision-describe: 1`. The Responses plan site treats a marked + request as terminal: images are STRIPPED, never described (depth cap 1). + This holds under predicate drift (modelInputModalities is invisible to a + row-less plan-time target) and combo re-resolution (router.ts:625-631 can + land a different sibling). Belt-and-braces: the routed arm also requires + `!isModelTextOnly(resolvedRoute.provider, resolvedRoute.modelId)` at plan + time — the exact re-entry predicate on the resolved route. +- **PUT-gate coherence:** a namespaced model with backend openai/anthropic is + REJECTED (forward executor POSTs the string verbatim — web-search F1 + selector/slug failure); backend "routed" REQUIRES a namespaced id. +- **GUI inference:** `value.includes("/") → "routed"` in + visionSidecarBackendForModel's fallback; persisted backend keeps traveling + as currentBackend. +- Known limitation (recorded, not fixed here): a non-loopback-only bindHost + where 127.0.0.1 does not answer — same latent limitation gateway-cache has. +- handleNativeChatCompletions fast path has no vision handling; the marked + describe request must not regress it (marker check lives at the Responses + plan site the bridge replays into). + diff --git a/devlog/_plan/260820_sidecar_selection_unification/180_vision_describe_executors.md b/devlog/_plan/260820_sidecar_selection_unification/180_vision_describe_executors.md new file mode 100644 index 0000000000..859363bd55 --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/180_vision_describe_executors.md @@ -0,0 +1,73 @@ +# 180 — Routed describe executor + dispatch (wp3, REVISED) + +Depends on: 170 (revised). + +## src/vision/routed-describe.ts (new) + +Loopback POST http://127.0.0.1:{config.port}/v1/chat/completions: + +```json +{ "model": "", "stream": false, + "messages": [ + { "role": "system", "content": "" }, + { "role": "user", "content": [ + { "type": "text", "text": "" }, + { "type": "image_url", "image_url": { "url": "" } } + ]}]} +``` + +- Auth: none on loopback binds (resolveApiAuth admits loopback without a + token); when OPENCODEX_API_AUTH_TOKEN is set, send it as Authorization + bearer (auth-cors.ts:399-400 accepts bearer on /v1/chat/completions). +- signalWithTimeout(settings.timeoutMs) + cancelBodyOnAbort; + sidecarEnter("vision"); redactSecretString on error paths; response text + from choices[0].message.content; DESC clamp caller-side (existing). +- validateImageUrl reused (data: mime allowlist + 20MB, https passthrough). +- The chat inbound translates image_url → input_image and every adapter + compiles its own wire (anthropic blocks, CCA inlineData, xai Responses), + so provider coverage is the router's, not this file's. + +## planVisionSidecar routed arm + +VisionPlan gains { backend: "routed", routedModel: string }. Arm requires +explicit model + plan-time modelAcceptsImageInput !== false (recursion +fence). executeDescription routed arm calls describeImageRouted. + +## Tests + +vision-routed.test.ts: wire shape against a mock loopback server; recursion +fence (text-only target never plans routed); timeout/error taxonomy; +redaction. E2E: routed describer via a second mock provider. + + +## Audit round 2 amendments (2026-08-22) + +- **Admission ladder (blocker 2):** token = + configuredApiAuthToken() || loadServiceTokenFromFile(env) || first + config.apiKeys entry; sent as `x-opencodex-api-key` (never Authorization — + gateway-cache.ts:77-86 rule); omitted entirely on loopback binds where + isApiAuthRequired is false. +- **Terminal marker:** executor sets `x-opencodex-vision-describe: 1`; the + core.ts plan site checks it and strips images instead of planning vision. +- Executor also passes stream:false and reads choices[0].message.content; + non-2xx → {error} with redacted body slice. + + +## Audit round 3 amendment (2026-08-22) — marker propagation + +The chat→responses bridge rebuilds headers from the FORWARD_HEADERS allowlist +(chat-completions.ts:198-203, openai-responses.ts:28-36), which would DROP +`x-opencodex-vision-describe` before the plan site — on exactly the one path +recursion lives. Therefore: + +- The marker is detected AT THE CHAT SURFACE (raw req.headers before the + bridge) and carried as an explicit option/flag into handleResponses + (`visionDescribeTerminal: true`), not as a header the bridge must + preserve. The Responses surface ALSO honors the raw header directly for + native /v1/responses callers. +- Regression test drives the FULL chat-surface path: marked POST to + /v1/chat/completions with an image + text-only routed model → assert the + plan site STRIPS (no describe dispatch, no recursion), while the same + unmarked POST plans normally. A predicate-only test is insufficient and + would stay green with the marker broken. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/190_vision_surfaces_and_delivery.md b/devlog/_plan/260820_sidecar_selection_unification/190_vision_surfaces_and_delivery.md new file mode 100644 index 0000000000..4b5e70032b --- /dev/null +++ b/devlog/_plan/260820_sidecar_selection_unification/190_vision_surfaces_and_delivery.md @@ -0,0 +1,71 @@ +# 190 — Surfaces, live proof, delivery (wp4 cycle) + +Depends on: 180. + +## GUI + +- Split the shared SidecarBackend (dashboard-shared.ts:62): web-search side + keeps its server-provided backend strings (already emits xai/gemini/exa — + stale type fixed by the split); vision side gets + VisionBackend = "openai" | "anthropic" | "xai" | "gemini". +- visionSidecarBackendForModel fallback stays server-provenance-first; + catalog inference (anthropic-vs-openai guess) only for legacy rows. +- claude-manual-env.ts SidecarOverride backend union widens for vision. +- No new dropdown UI: options arrive from visionModels server list already. + +## CLI + +- src/cli/agent.ts: usage already names xai|gemini; verify backend values + pass through PUT unvalidated client-side (server gate authoritative); + vision --list renders new backends' rows. + +## Live proof (acceptance 3-5) + +- GET /api/sidecar-settings on live :10100 shows visionModels containing + xai/gemini rows (auth present on this machine for both — web-search rows + prove it). +- PUT vision {backend:"xai", model:"grok-4.3"} → 200; PUT model grok-4 + (bare) → 400 provably-blind; restore original settings after proof. +- GUI screenshot of the vision dropdown listing Grok/Gemini rows. + +## Delivery + +- Small commits per layer (backends table / eligibility+gate / executors / + GUI+CLI / tests+devlog), full bun run typecheck + bun run test green at + final head, push directly to dev (user-authorized, no PR). +- devlog docs 160-190 land with the same push train; unit stays in _plan + until the release train closes it. + + +## Delivery evidence (2026-08-22, wp4) + +- Live dev server (commit 3ff19c33e, port 11100, copied auth home): + - GET /api/sidecar-settings visionModels: 25 rows — legacy openai/anthropic + sides + 17 namespaced [routed] rows (xai/grok-4.6, + google-antigravity/gemini-3.7-flash, cursor/kimi-k3, zenmux/…, + alibaba…/qwen3.8-max, …). Rule 2 confirmed live: no text-only rows. + - PUT gates live: routed+xai/grok-4.6 → 200; routed+xai/grok-3 → + 400 provably-blind; openai+namespaced → 400 coherence. + - GET after PUT reports the routed model verbatim + ({"model":"xai/grok-4.6","backend":"routed"}) — fixed the legacy-collapse + display bug found during this verification. + - GUI screenshot: vision dropdown lists namespaced routed rows; current + selection renders as xai/grok-4.6. + - CLI: `ocx agent sidecar vision --list` prints the same 25 rows with + [routed] backend tags (server-computed list, no drift). + - LIVE describe e2e: POST /v1/chat/completions with a 64x64 red PNG to + xai/grok-composer-2.5-fast (noVisionModels) with routed describer + xai/grok-4.6 → main answer "red"; request history shows the inner + grok-4.6 describe call followed by the outer composer call. (A 1x1 probe + earlier failed with xai invalid_image min-8px — upstream constraint, not + a pipeline defect; the graceful degradation path handled it and the main + call still succeeded.) +- Verification-side effect handled: the 11100 dev server rewrote + ~/.grok/config.toml to port 11100 during startup sync; restored to 10100 + via production `ocx ensure` and confirmed (27x base_url 10100, zero + 11100). Temp verify home moved aside (/tmp/trash-ocx-vision-verify-*). +- privacy:scan green; root+gui tsc clean; focused suites green (185 pass). +- Full-suite run at final head queued behind another worktree's runner + (scripts/test.ts exclusive-run queue); recorded separately below when it + lands. + diff --git a/devlog/_plan/260820_sidecar_selection_unification/assets/vision_routed_dropdown.png b/devlog/_plan/260820_sidecar_selection_unification/assets/vision_routed_dropdown.png new file mode 100644 index 0000000000..645759e52d Binary files /dev/null and b/devlog/_plan/260820_sidecar_selection_unification/assets/vision_routed_dropdown.png differ diff --git a/devlog/_plan/260821_260821-model-catalog-refresh/000_plan.md b/devlog/_plan/260821_260821-model-catalog-refresh/000_plan.md new file mode 100644 index 0000000000..91bab28286 --- /dev/null +++ b/devlog/_plan/260821_260821-model-catalog-refresh/000_plan.md @@ -0,0 +1,33 @@ +# 000 — 260821-model-catalog-refresh: Plan + +## Objective + +Add the OpenRouter stealth model "Ox Alpha" and the DeepSeek vision preview id to +every provider entry that can serve them, and adjust the opencode-side metadata to +the verified specs (1,048,576 context, text+image input). Evidence base: five Luna +research lanes run 2026-08-21 — OpenRouter /api/v1/models (primary), Command Code +changelog v1.31.0 + model profile (primary), OpenCode Zen docs (primary), DeepSeek +API docs community relay (strong lead). + +## Loop-spec + +- Loop archetype: verifier-defined (typecheck + focused registry/provider suites) +- Write scope: src/providers/registry.ts, src/providers/command-code-efforts.ts +- Out-of-scope: adapter code, live discovery mechanics, GUI +- Budget / bounds: one commit, direct admin push to dev per operator instruction + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp1 | 010_phase1.md | Registry metadata + efforts table + tests + commit + push | — | + +## Accept criteria + +- Ox Alpha carries 1M context and text+image on opencode-go/free/zen entries +- openrouter seeds stealth/ox-alpha with context + modalities +- Both Command Code entries advertise stealth/ox-alpha facts (efforts low/high/max) +- deepseek-v4-flash-vision-exp present on deepseek + every v4-flash gateway entry, + commented as expected to merge into deepseek-v4-flash later +- Focused tests green; committed; pushed to origin/dev + diff --git a/devlog/_plan/260821_260821-model-catalog-refresh/010_phase1.md b/devlog/_plan/260821_260821-model-catalog-refresh/010_phase1.md new file mode 100644 index 0000000000..7977d1019f --- /dev/null +++ b/devlog/_plan/260821_260821-model-catalog-refresh/010_phase1.md @@ -0,0 +1,38 @@ +# 010 — wp1: Registry metadata refresh (diff level) + +## MODIFY: src/providers/registry.ts + +- Shared constants next to DEEPSEEK_THINKING_MODELS: + - NEW `DEEPSEEK_VISION_PREVIEW_MODEL = "deepseek-v4-flash-vision-exp"` with the + merge-into-v4-flash comment (released 2026-08-21, api-docs.deepseek.com). + - NEW `OPENCODE_OX_ALPHA_FREE_MODEL = "x-preview-f-free"` (Zen's slug for + openrouter.ai/stealth/ox-alpha, displayed "Ox Alpha Free"). + - NEW `OX_ALPHA_CONTEXT_WINDOW = 1_048_576`. +- `deepseek` entry: append vision preview to `models`; add its 1_048_576 context + and `["text","image"]` modality. Deliberately NOT in noVisionModels. +- `opencode-go`: add both ids to modelContextWindows/modelInputModalities + (metadata-only; roster is live-discovered). +- `opencode-free` and `opencode-zen`: same two-id metadata blocks. +- `openrouter`: seed "stealth/ox-alpha" into `models`, context map, and + `modelInputModalities` (API reports text+image+video; image is what the proxy + can forward today). +- `command-code` (OAuth) and `commandcode` (API): context + modality facts for + "stealth/ox-alpha" and "deepseek/deepseek-v4-flash-vision-exp". + +## MODIFY: src/providers/command-code-efforts.ts + +- NEW row `"stealth/ox-alpha"` efforts ["low","high","max"], profileUrl + commandcode.ai/models/ox-alpha (profile publishes no ladder; mirrors the + OpenRouter mandatory-reasoning contract). + +## Verification + +- `bun x tsc --noEmit` (Bun 1.4.0) — exit 0. +- Focused suites: provider-registry-parity, adapter-resolve, opencode-free-provider, + command-code-provider, command-code-quota, codex-catalog, client-config-export, + commandcode-provider — 349 pass, 0 fail. + +## Outcome + +Commit d23c3179f, pushed to origin/dev (admin bypass, ruleset restored). + diff --git a/devlog/_plan/260821_260821-windows-picker-full-restart/000_plan.md b/devlog/_plan/260821_260821-windows-picker-full-restart/000_plan.md new file mode 100644 index 0000000000..b8b0900c67 --- /dev/null +++ b/devlog/_plan/260821_260821-windows-picker-full-restart/000_plan.md @@ -0,0 +1,91 @@ +# 000 Plan: Windows model-picker full-restart path + +## Problem + +ocx sync --restart-codex rewrites the Codex catalog JSON and restarts the Codex +app-server (codex.exe app-server). Observed behavior: + +- macOS: the desktop app model picker reflects the new catalog right away. +- Windows (stable/beta, MSIX package OpenAI.Codex_26.818.3698.0): the picker + keeps the stale list until the whole desktop app is quit and relaunched. + +Local evidence (2026-08-21): + +- Desktop UI processes are ChatGPT.exe (Electron shell), installed as MSIX + package family OpenAI.Codex_2p2nqsd0c76g0, start app id (AUMID) + OpenAI.Codex_2p2nqsd0c76g0!App. +- ocx sync --restart-codex matches only codex.exe app-server and + codex-code-mode-host.exe command lines + (src/codex/app-server-processes.ts, isCodexAppServerCommandLine). The + Electron UI is never signalled, so its cached picker survives. +- After the 20:57 sync + restart, codex.exe (PID 8592) started fresh at 20:59 + while all ChatGPT.exe UI processes kept their earlier start time, and the + picker still showed only OpenAI models. + +Research findings (subagent, bundle inspection of app.asar): + +- The renderer fetches model/list and config/read over stdio JSON-RPC into a + TanStack Query cache; there is no filesystem watcher on the catalog file. +- The UI invalidates those queries only on a codex-app-server-initialized + event. On Windows, externally killing the codex.exe child may not produce + that event reliably (hypothesis, untested from inside this session), which + would explain why ocx restart alone does not refresh the picker here while + macOS recovers. +- Official docs say to restart the desktop app after changing model_catalog_json; + no supported refresh hook exists. Known upstream cluster: openai/codex + issues 19694, 26308, 32349, 34487 (desktop picker vs CLI catalog divergence). +- Relaunch must go through MSIX activation (shell:AppsFolder AUMID), not the + exe path under WindowsApps (ACL-restricted, no package identity). + +## Scope + +IN (audit amendments folded in): + +- A supported, documented way to fully restart the Windows Codex desktop app + after a catalog sync: graceful WM_CLOSE first, bounded taskkill /T /F + fallback, relaunch via AUMID. Targets resolve InstallLocation at runtime + via Get-AppxPackage -PackageFamilyName (the family string is NOT a + substring of the install path); only the root ChatGPT.exe whose parent lies + outside the package is selected so taskkill /T cascades to codex.exe and + codex-code-mode-host.exe; the script refuses to kill its own ancestry. +- A GitHub issue on lidge-jun/opencodex recording the platform gap, the beta + caveat, upstream issue links, and the requested UX (sync should offer a full + app restart on Windows). The issue MUST include Version (installed + @bitkyc08/opencodex version) and Operating system fields, which + enforce-issue-quality hard-requires once Client or integration is present; + Reproduction carries the PID/start-time evidence; upstream issues are cited + as related-but-unverified. + +OUT: + +- Changing ocx sync runtime behavior in this unit (the issue proposes it; + implementation is a later unit). +- Killing processes outside the OpenAI.Codex_2p2nqsd0c76g0 package family. +- Testing the unverified stdio-respawn hypothesis by killing codex.exe from + inside this session (would kill our own host); recorded as an open question + for an external terminal test. + +## Work phases + +- wp1 (010): add scripts/restart-codex-desktop-app.ps1 with -DryRun/-Force, + graceful-close then bounded forced fallback, relaunch via AUMID; file the + templated GitHub issue; record evidence. + +## Accept criteria + +- Script -DryRun exits 0 AND lists the specific live root PID(s) it would + stop and the relaunch command, without stopping anything (an exit-0 no-op + does not pass). Focused probe evidence per scripts/AGENTS.md is the real + gate (tsconfig includes only src/); bun x tsc --noEmit still runs as a + no-regression check. +- Issue exists on origin with bug_report template headings. + +## Safety notes + +- Running the restart from inside a Codex conversation kills that conversation + host app; the script warns and docs say to run it from an external terminal. +- Forced kill is limited to processes whose Path is under the runtime-resolved + InstallLocation. Close-to-tray behavior is explicitly checked: if + CloseMainWindow() only hides the window, the wait expires and the forced + path runs; record observed behavior. Record the PowerShell edition the + probe ran under (Get-AppxPackage differs between 5.1 and 7). diff --git a/devlog/_plan/260821_260821-windows-picker-full-restart/010_phase1.md b/devlog/_plan/260821_260821-windows-picker-full-restart/010_phase1.md new file mode 100644 index 0000000000..1c243fd97b --- /dev/null +++ b/devlog/_plan/260821_260821-windows-picker-full-restart/010_phase1.md @@ -0,0 +1,63 @@ +# 010 wp1: Restart script + issue (diff level) + +## NEW: scripts/restart-codex-desktop-app.ps1 (amended per audit) + +PowerShell 5.1-compatible script: + +- param([switch]$DryRun, [switch]$Force). +- Constants: package family OpenAI.Codex_2p2nqsd0c76g0, AUMID + OpenAI.Codex_2p2nqsd0c76g0!App, process names ChatGPT, codex, + codex-code-mode-host. +- Resolve $installLoc = (Get-AppxPackage -PackageFamilyName + OpenAI.Codex_2p2nqsd0c76g0).InstallLocation at runtime; fail with an + actionable message when empty. Wrap process Path access in try/catch + (Access denied for other users processes). +- Select ONLY the root ChatGPT.exe whose ParentProcessId lies outside + $installLoc (Win32_Process via Get-CimInstance). taskkill /PID /T /F + cascades to codex.exe and its codex-code-mode-host.exe child. Never list + code-mode-host as an independent target. +- Self-kill guard: walk $PID ancestry; abort with a clear message when any + selected target is in it. +- Warn: active Codex turns are interrupted; run from an external terminal. +- Graceful pass: CloseMainWindow() on the process with a MainWindowHandle, + wait up to 15 s in 1 s polls for all targets to exit. If the process + survives past the timeout, print that close-to-tray behavior is suspected + before escalating. +- Forced pass (remaining targets, or immediately with -Force): + taskkill /PID /T /F per remaining PID (/T covers child tree so + codex.exe is not orphaned). +- Relaunch: Start-Process "shell:AppsFolder\" unless -DryRun. +- -DryRun: print planned actions (targets, method, relaunch command), touch + nothing, exit 0. + +## MODIFY: none (runtime untouched in this unit) + +## Verification + +## Cycle 2 addendum (2026-08-21, provider verification + push) + +- command-code stealth/ox-alpha re-probed after credit purchase: /v1/chat/completions + and /v1/responses both return 200 with valid completions. No code change needed. +- opencode-go upstream (https://opencode.ai/zen/go/v1) returns 500 Internal server + error for every model probed directly (kimi-k2.7-code, ox-alpha-free); the proxy + 502 "upstream stream ended" is an upstream outage, not an adapter defect. + ox-alpha-free is also absent from models.dev opencode-go roster and from + scripts/model-metadata.source.json, so the opencode-go/ox-alpha-free slug was + never a registered catalog model; opencode-free/x-preview-f-free is the working + free-tier route (verified 200 on both endpoints). +- Direct push to origin/dev rejected by ruleset 20763889 (pull_request rule, admin + bypass = pull_requests_only). Fallback per user intent: branch + codex/windows-restart-helper pushed, PR #2293 opened targeting dev (MERGEABLE). + +- powershell -File scripts/restart-codex-desktop-app.ps1 -DryRun -> exit 0 + AND output names the live root PID (e.g. 9928) and its child codex.exe; + nothing stopped. Record $PSVersionTable.PSVersion. +- bun x tsc --noEmit -> exit 0. +- gh issue create with bug_report.yml headings: Client or integration = Codex + App; Area = Platform (Windows / macOS / Linux); Version = installed + @bitkyc08/opencodex version (package.json); Operating system = Windows 11 + (build from systeminfo); Reproduction includes the 20:57 sync / 20:59 fresh + codex.exe vs stale UI start-time evidence; upstream issues + 19694/26308/32349/34487 cited as related-unverified; beta-channel caveat + stated. After creation, re-read state with gh issue view until the + enforce-issue-quality workflow settles (creation alone can auto-close). diff --git a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md new file mode 100644 index 0000000000..343090b978 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md @@ -0,0 +1,27 @@ +# 000 — Bug merge-train triage matrix (2026-08-21) + +Session: 01a024bb-1acb-7633-908b-29e4fe4d96c5 (worktree a6a7, detached at c0cbe494e). +Objective: drive the six open bug-labeled PRs to merged on `dev` with strict review, +adversarial xai/grok-4.6 subagent verdicts, and a final green dev CI gate. + +## In-scope PRs (state as of 2026-08-21T14:30Z) + +| PR | Title | Head | Behind dev | Draft | CI on head | Existing review state | +|----|-------|------|-----------:|-------|------------|----------------------| +| #2294 | fix(release): reject credential-bearing SSH remotes | 71598fa45 + hardening 2cdfba24d (train-stacked) | 3 | yes | green | MERGED to train; grok blocker fixed; re-verdict PASS; landing on dev | +| #2289 | fix(service): restart existing installs w/o re-register | 2df92a270 + locale sync 174f03b60 (train-stacked) | 2 | yes | green incl. Service lifecycle | MERGED to train; grok P2 fixed; re-verdict PASS; Closes #2287 | +| #2295 | fix(codex): recover zero-byte coordinator remnants | 6d5f0cf2c (ingw/fix-zero-byte-coordinator-2291) | 0 | yes | green | MERGED to train 728ca1e8b; suite green; landing on dev | +| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 + pin ec32a8d52 (train-stacked) | merged into train | yes | MERGED to train; grok P2 fixed; re-verdict PASS | Linux shards green; lidge full suite green | +| #2281 | fix: call_id thought-signature replay for Claude Code | b31f3dbed + normalization bc6d6b516 (train-stacked) | merged into train | yes | MERGED to train; two reviewers PASS; CodeRabbit normalization done | hygiene resolved by shipped regression rows | +| #2296 | fix(codex): bind Desktop reconnects to one pool account | e672b0fd0 + scope fix 698228e40 (train-stacked) | 2 | yes | green | MERGED to train; grok major fixed; re-verdict PASS; landing on dev | + +## Baseline dev CI status (pre-train blocker) + +Run 32486877508 on dev head c0cbe494e: attempt 1 **failed** on +`(fail) multiAgentGuidanceText > the v2 default catalog path uses the request collector, not the synchronous one (#1852)` (macos job). Rerun of failed jobs (attempt 2) is **green** (conclusion: success), and the test passes locally at c0cbe494e (52/52). Cycle 1 exits as recorded flake per 010; no direct dev push needed. Watch for recurrence during the train. + +## Hygiene notes + +- #2281 carries `intake: hygiene-blocked` (missing_regression_test) despite having test files — the label state needs re-check after any new commit. +- #2281 is a first-time contributor PR; gate binds completion to exact head. New commits reset the checklist; since we (maintainer) will merge manually, that is acceptable. +- User authorized: stash/merge/cherry-pick/close/extra commits, push with --no-verify, suite on ssh lidge if needed, final CI green on dev is the exit gate. diff --git a/devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md b/devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md new file mode 100644 index 0000000000..1ba285b840 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md @@ -0,0 +1,40 @@ +# 001 — Dependency and conflict analysis (r2, post-audit) + +Audit r1 (grok-4.6 "Avicenna") failed the initial order; accepted findings are folded in below. +Rejected findings and why: none rejected outright; the "2270 has no file overlap" observation was +accepted and 2270 moved before 2281 (it still sits after 2289 because its 48-behind rebase wants a +stable dev, and nothing else touches its files so waiting costs only one rebase, which it owes anyway). + +## File overlap between PR heads + +- **src/server/responses/core.ts**: #2281 (+12) and #2296 (+6/-9). Semantic neighborhood: _reasoningReplayScope creation (2281) vs pool-affinity key derivation (2296) both hang off handleResponsesInner request-context setup. +- **src/cli/registry.ts** + **docs .../reference/cli/lifecycle.md**: #2289 and #2295. Disjoint commands (service vs doctor); textual conflict likely trivial. +- **Runtime semantic risk without file overlap**: #2270's routed custom-tool lowering executes in the same request path as 2281's replay scope and 2296's affinity key. Post-merge full-suite runs after each of these three is the guard, plus a targeted cross-check at 2281/2296 time that replay-scope and lowering still compose (tests in tests/responses-custom-tool-repair.test.ts + tests/claude-code-thought-signature-scope.test.ts both green on the merged tree). +- All other files disjoint. + +## Disposition order (r2 — least-rebase, lock-current-first) + +1. **CI fix**: restore dev green (multiAgentGuidanceText #1852 macos failure; rerun already green — confirm and root-cause flakiness). +2. **#2295** (0 behind, green head CI, no rebase owed; lands registry.ts/lifecycle.md first so #2289 absorbs the conflict in the rebase it already owes). +3. **#2294** (3 behind, tiny, no overlap; NAMED SECURITY REVIEW GATE — see below). +4. **#2296** (0 behind; lock core.ts while its base is current; C4 auth — NAMED SECURITY REVIEW GATE; cancelled enforce-target check must be re-run green on the pre-merge head). +5. **#2289** (9 behind; rebase absorbs 2295's registry/lifecycle hunks; Service lifecycle CI green required). +6. **#2270** (48 behind; no file overlap with anything above; single rebase onto stable dev; full suite on the rebased head BEFORE merge). +7. **#2281** (50 behind; takes the core.ts conflict on rebase as the last mover; pre-merge blockers below). + +## Named gates (merge-blocking, not notes) + +- **Security review gate (#2294, #2296)**: per MAINTAINERS.md/AGENTS.md these surfaces (release automation; auth/account binding) require explicit security review. The maintainer (this session, acting for the owner account) performs and RECORDS a written security review in the cycle doc: threat cases checked, rejection matrix, log-boundary check (no token/secret in output), before merge. The grok-4.6 adversarial verdict is additive, not the security review itself. +- **Pre-merge CI-on-head gate (all)**: merge only from a head whose CI (or local full suite for shared-surface PRs: #2270, #2281, #2296) is green ON THE REBASED HEAD, not a stale ancestor. Cancelled/skipped required checks are re-run, not ignored. +- **#2281 pre-merge blockers**: (a) stacked commit normalizing promptCacheKey via anthropicSessionKeyFromParts (CodeRabbit finding) + test rows; (b) hygiene label missing_regression_test resolved — the PR does carry tests, so re-trigger the deterministic check after the stacked commit and confirm the label drops, or record the maintainer override rationale; (c) rebase onto final-form dev; (d) full suite green on that head. +- **Post-merge dev CI check after EVERY merge** before starting the next cycle (train stops on red). + +## Merge mechanics per PR + +fetch pr/N -> read full diff (AGENTS.md review rules) -> rebase onto current dev if behind -> focused tests + typecheck -> FULL SUITE (bun run test) pre-merge for every non-trivial PR (AGENTS.md bar; ssh lidge if local env-limited) -> grok-4.6 adversarial verdict -> security review doc where gated -> stack fix commits if needed. Head remotes: #2294/#2295/#2296/#2289 are in-repo branches (push origin); #2270 head is olddonkey/opencodex, #2281 head is Hsia97/opencodex, both maintainerCanModify=true -> push https://github.com//opencodex.git HEAD: (--no-verify is a local-hook flag). Then merge to dev (merge commit convention) -> push --no-verify -> dev CI green -> next. #2270 extra: dismiss/refresh the stale CHANGES_REQUESTED review so reviewDecision matches the converged head. + +## Issue closure map + +- #2287 -> close after #2289 lands (manual, base is dev). +- #2291 -> close after #2295 lands. +- #2046 -> #2296 fixes reconnect-rotation only; comment with landing commit; keep open unless the remaining Desktop-UI half is split into its own issue at wp6 D. diff --git a/devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md b/devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md new file mode 100644 index 0000000000..ca457a0a11 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md @@ -0,0 +1,20 @@ +# 002 — Audit synthesis (round 1 -> round 2) + +Reviewers: Avicenna (grok-4.6, plan-shape audit) FAIL; Hegel (grok-4.6, deep repo audit) FAIL. + +## Accepted (folded into r3 docs) +1. Order rework (Avicenna): CI -> 2295 -> 2294 -> 2296 -> 2289 -> 2270 -> 2281. Adopted in 001 r2 and decade docs 020-065. +2. Fork mechanics (Hegel): #2270 head lives on olddonkey/opencodex, #2281 on Hsia97/opencodex, both maintainerCanModify=true — verified via gh. Stacked commits to those heads push to the FORK remote (https://github.com//opencodex.git :), enabled by maintainerCanModify; --no-verify applies locally. 001 mechanics corrected. +3. Full-suite bar (both): bun run typecheck + bun run test required before approving ANY non-trivial PR (AGENTS.md:178 area); full suite explicitly pre-merge for #2281/#2289/#2295 too, not only 2270/2296. Decade docs updated. +4. #2294 gates (Hegel): add bun run prepush (scripts/AGENTS.md), and record the non-author maintainer review — author is Ingwannu; the merging maintainer account (lidge-jun) supplies the non-author security APPROVE, satisfying MAINTAINERS.md no-self-approval. +5. #2270 stale CHANGES_REQUESTED (Hegel): reviewDecision still CHANGES_REQUESTED although the same reviewer's later comment on exact head 398b7ade4 says no remaining technical blocker. Pre-merge step: dismiss the stale review with rationale (or fresh APPROVE) so the recorded decision matches the converged state. +6. #2294 head drift (Hegel): head moved 86ed0a46a -> 71598fa45; re-fetch and re-review at the new head. 000 corrected. +7. CI cycle-1 (Hegel): rerun attempt 2 green + local 52/52 pass -> exit as flake (010 rewritten); no direct dev push. +8. Docs-sync (Hegel): after both 2295 (en-only doctor docs) and 2289 (8-locale lifecycle) land, verify locales do not contradict the English lifecycle page; added to 070. +9. CODEOWNERS/owner review for core.ts PRs (Hegel): lidge-jun review recorded at 040/065 merge time. + +## Rejected (with evidence) +1. "#2270 already collides with intervening dev on src/providers/registry.ts" (Hegel): git merge-tree merge-base(origin/dev, pr/2270) shows 0 conflict markers; same for pr/2281. Rebase risk is semantic, not textual; covered by full suite on rebased head. +2. "#2281 hygiene failure is unsponsored_surface" (Hegel): latest pr-hygiene comment on #2281 says missing_regression_test (fetched via gh api). Treated per 065: re-trigger after stacked commit; drop or record maintainer override. +3. "#2296 cancelled enforce-target ignored" (Avicenna): not ignored — 040 requires it re-run green pre-merge. Kept. + diff --git a/devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md b/devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md new file mode 100644 index 0000000000..3ac7d089f7 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md @@ -0,0 +1,7 @@ +# 010 — Cycle 1: dev CI status (resolved as flake) + +Evidence: +- Run 32486877508 (dev c0cbe494e) attempt 1: platform-macos failed on multiAgentGuidanceText #1852 test; attempt 2 (rerun --failed): conclusion success. +- Local repro at exact c0cbe494e: bun test tests/multi-agent-compat.test.ts -> 52 pass / 0 fail; paired with server-combo-failover-e2e -> 120 pass. +Exit: flake recorded; dev is green at c0cbe494e. No dev push. If the same test fails again during the train, escalate to root-cause mode (test reads catalog collector timing — suspect CI-runner timing sensitivity). + diff --git a/devlog/_plan/260821_bug_merge_train/020_merge_2295.md b/devlog/_plan/260821_bug_merge_train/020_merge_2295.md new file mode 100644 index 0000000000..68e5f87cb5 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/020_merge_2295.md @@ -0,0 +1,46 @@ +# 020 — Cycle 2: PR #2295 (zero-byte coordinator, #2291) + +0 behind dev; lands first among PRs. Review: coordinator-doctor state machine (8 classifications), fail-closed defaults, doctor --recover-zero-byte-coordinator gating (proxy stopped + BEGIN IMMEDIATE + identity revalidation + backup-not-delete), no SQLite sidecar creation on diagnosis path, age-gate race reasoning. +Verify: bun test tests/codex-coordinator-doctor.test.ts tests/codex-inject-write-lock.test.ts tests/codex-transition-state*.test.ts tests/cli-doctor.test.ts tests/cli-dispatch.test.ts, bun run typecheck, bun run privacy:scan, FULL SUITE (bun run test) pre-merge. grok verdict. Merge, push --no-verify, dev CI green. Close #2291 with landing commit. + +## Review round 1 (Volta, grok-4.6) — FAIL — synthesis + +Finding 1 (age gate bypasses lock): ACCEPTED AS RESIDUAL RISK, REBUTTED AS BLOCKER. +RCA: a creator stalled >1s between file creation and BEGIN IMMEDIATE is classified stable-zero-byte. +But the consequence is exactly the ENOENT behavior: clean homes still enter the coordinated path +(inject-coordination.ts:96-99 comment + code — the SQLite transaction safely initializes the same file, +still serialized by the lock); ONLY residue/indeterminate legacy homes take legacy-uncoordinated, which +is the identical compatibility boundary those homes used for years pre-coordination and would use today +if the remnant pathname were absent. The trade fixes #2291 (zero-byte blocks sync forever, fail-closed +with no operator exit). Residual: legacy-residue home + creator stalled >1s + concurrent write — +accepted; the alternative is the unfixable wedge this PR exists to remove. + +Finding 2 (recovery rename TOCTOU): REBUTTED AS BLOCKER. +RCA: window between final sameIdentity check (coordinator-doctor.ts:306) and renameSync (:312) allows a +same-uid attacker to swap a file that then gets MOVED (not deleted) to a same-directory backup. +The namespace is 0o700/owner-checked and the file 0o600/owner-checked (inspectTarget); only the same +user can race it. Per AGENTS.md's own boundary statement, a same-user local process is outside the +enforceable threat model (it can already rename these files itself). Recovery is opt-in (--yes), +proxy-stopped, and evidence-preserving. Non-blocking. + +Finding 3 (fail-open vs dev): REBUTTED. +RCA: on dev, an existing zero-byte coordinator stayed "coordinated" and then wedged sync (issue #2291's +literal symptom). The PR routes only proven (zero bytes + user_version 0 + no tables via immutable read ++ 1s settled identity) remnants to the absent-file boundary. unversioned-nonempty / rowless / +unsupported / changed / unsafe all remain fail-closed. This is the intended fix, not an accident. + +Disposition: proceed to merge; findings 1-2 recorded as accepted residual risks in this doc. +Focused tests 55/55, typecheck pass, privacy:scan pass, full suite pending (bg session). + +## Verification close-out (train head 728ca1e8b) + +Full suite re-run on lidge after completing the temporary worktree's gui +dependency install: the first run's 7 failures were all "Unhandled error +between tests: Cannot find package 'react'" (gui/src/i18n/shared.ts and +friends) — an incomplete `gui/node_modules` environment artifact, not test +logic. After `bun install --cwd gui` on the same commit: **14175 pass / +16 skip / 0 fail across 890 files (464.61s), exit 0** +(/tmp/ocx-train-suite-r2.log on lidge). Locally, the same four representative +files that hit the missing-package path pass 55/55 after the identical fix. +Merge-blocker verdict stands; accepted residuals unchanged. Train branch is +ready to land on dev. diff --git a/devlog/_plan/260821_bug_merge_train/030_merge_2294.md b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md new file mode 100644 index 0000000000..359cb6d045 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md @@ -0,0 +1,43 @@ +# 030 — Cycle 3: PR #2294 (release SSH credential boundary) + +NAMED SECURITY REVIEW GATE (scripts/release.ts). Written review in this doc before merge: userinfo rejection matrix (ssh:// password, encoded ':', scp-like user:pass@), control-char/query/fragment rejection, GIT_SSH_COMMAND single-literal '-i' proof, log-boundary check (accepted value printed pre-push — verify nothing secret-bearing can pass validation). +Head moved to 71598fa45 — re-fetch and review the live head. Verify: bun test tests/release-helper.test.ts, bun run typecheck, bun run privacy:scan, bun run prepush (scripts/AGENTS.md bar for release tooling). Non-author security review: author is Ingwannu; merging maintainer (lidge-jun) records the security APPROVE (no self-approval). grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live head 71598fa45, confirmed via branch fetch) + +Scope under review: c0cbe494e..71598fa45 — two commits touching only +scripts/release.ts (+36/-2) and tests/release-helper.test.ts (+85). Base has +drifted far behind the train; merge onto the train head and re-run checks +there. Steps: + +1. Adversarial security review (grok-4.6 subagent): userinfo rejection matrix, + encoded-char and control-character handling, scp-like remotes, + GIT_SSH_COMMAND single-literal '-i' proof, log-boundary bypasses + (secret-bearing values reaching printed output), missing test coverage. +2. Local gates at train-merged head: bun test tests/release-helper.test.ts, + bun run typecheck (shared runtime touched? release script only — focused + bar), bun run privacy:scan, bun run prepush per scripts/AGENTS.md. +3. Merge into train, full suite on lidge at merged head, land via train PR + to dev (rules require PR path), close #2294, record non-author security + approval evidence. + +## Security review (Euler, grok-4.6) — GO-WITH-FIXES (blockers=1) → fix → re-verdict PASS + +Blocker: scp-like host class allowed a second '@' +(`git@SECRET@host:path` accepted and printed to both log sinks — the push +target line and the failure command echo). Fix: host class excludes '@' +(`^git@[^:@\s/?#]+:[^?#]+$`, scripts/release.ts:215) plus raw-userinfo ':' +rejection before URL parse (WHATWG collapses empty password, so +`ssh://git:@host` was indistinguishable from a bare principal). Regression +rows added for both shapes. Hardening commit: 2cdfba24d. + +Re-verdict (same reviewer): PASS — "extra-@ host hole and empty-password +collapse are both closed; good remotes still pass; encoded and non-git +usernames stay rejected." Accepted residuals: scp-like IPv6 not deeply parsed +(same class as trailing-@ path text), U+2028/NBSP log-splitting (C0/DEL +already rejected; maintainer-facing log). + +Gates at train head 2cdfba24d: release-helper 24/24 pass, typecheck pass, +privacy:scan pass, prepush satisfied by the same suite run, lidge full suite +14211 pass / 16 skip / 0 fail exit 0 (r5). Non-author security approval: +recorded by merging maintainer lidge-jun per this doc + PR review. diff --git a/devlog/_plan/260821_bug_merge_train/040_merge_2296.md b/devlog/_plan/260821_bug_merge_train/040_merge_2296.md new file mode 100644 index 0000000000..52ec04cc87 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/040_merge_2296.md @@ -0,0 +1,39 @@ +# 040 — Cycle 4: PR #2296 (Desktop reconnect pool affinity, #2046) + +C4 auth surface — NAMED SECURITY REVIEW GATE: HMAC fallback key non-persistence + non-correlatability across restarts, no raw session/thread-id storage or logging (privacy:scan + manual grep), account-qualified selector exclusion from automatic affinity, failover/terminal accounting carries the same key. +Cancelled enforce-target check on head must re-run green pre-merge. Verify: bun test tests/codex-auth-context.test.ts, typecheck, privacy:scan, FULL SUITE on head (shared server surface). grok verdict. Merge, push --no-verify, dev CI green. Comment on #2046 (rotation half fixed; UI-denial half remains). + +## Plan (live head e672b0fd0 — 3 commits over old base 69907dde; dev now 15 ahead) + +The fork branch already merged origin/dev at 69907dde (pre-train). Merge the +PR head into the TRAIN and resolve there; leave the fork branch untouched. +Steps: +1. Adversarial review via inherited-model subagent (user directive: spawn + without a model name): HMAC fallback key non-persistence/correlatability, + no raw session/thread-id storage or logging, account-qualified selector + exclusion from automatic affinity, failover/terminal accounting parity. +2. Local gates at merged train head: codex-auth focused tests, typecheck, + privacy:scan. Full suite on lidge at merged head. +3. Land via train PR to dev (rules path). Comment rotation-half status on + #2046 after landing. + +## Security review (Huygens, inherited model) — GO-WITH-FIXES (blockers=0) → major fixed → re-verdict PASS + +Clean: HMAC fallback key memory-only + restart-regenerated (no persistence, +not derivable); raw session/thread ids never leave the HMAC digest; exact +selectors excluded from affinity at auth-context.ts:383; all six terminal/ +outcome sites carry authCtx.affinityKey; no src/lab/ import in core.ts. + +MAJOR: subagent-fallback preview read the legacy quota-scope slot +(undefined scope) while final resolve binds under codexQuotaScopeForModel — +preview could never find the Desktop binding and diverged from the +authenticating account, contradicting the structure doc's invariant. +Fix: pass codexQuotaScopeForModel(route.modelId) at core.ts:2311 (commit +698228e40) plus end-to-end postSpawn test and legacy-slot divergence pin. +Re-verdict (same reviewer): PASS. Accepted residual: shared-slot test does +not exercise an independent native scope (covered by construction — both +sides use the identical derivation). + +Gates at train head 698228e40: codex-auth + subagent-fallback tests 87/87, +typecheck pass, privacy:scan pass, lidge full suite r7 pending → recorded in +ledger receipt. diff --git a/devlog/_plan/260821_bug_merge_train/050_merge_2289.md b/devlog/_plan/260821_bug_merge_train/050_merge_2289.md new file mode 100644 index 0000000000..a3a7408b55 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/050_merge_2289.md @@ -0,0 +1,38 @@ +# 050 — Cycle 5: PR #2289 (service restart, closes #2287) + +Rebase (9 behind) absorbs #2295's registry.ts/lifecycle.md hunks. Review: bare 'ocx service' idempotency, repair/restart alias routing (src/service.ts, src/cli/registry.ts), Windows WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED path, 8-locale docs consistency. +Verify: bun test tests/cli-help.test.ts tests/service.test.ts tests/winsw.test.ts, bun run typecheck, FULL SUITE (bun run test) pre-merge; Service lifecycle CI green on head. grok verdict. Merge, push --no-verify, dev CI green. Close #2287 with landing commit. + +## Plan (live head 2df92a270 — 2 commits over base 401c24f7; merged into train as 6e1202fa5) + +The branch was already rebased by its author onto a recent dev (401c24f7), so +the historical rebase concern is resolved; the train merge took it cleanly. +Scope: src/service.ts +136/-, src/cli/registry.ts +5, 8-locale lifecycle.md +sync, structure doc, focused tests (+89). Steps: +1. Adversarial review (inherited model): idempotent restart semantics, + fail-closed unknown installation state, Windows schtasks access-denied + path, docs/behavior parity across locales. +2. Local gates: service/cli-help/winsw tests, typecheck, privacy:scan. +3. lidge full suite at merged head; land via train PR to dev; close #2287. + +## Review (Euclid, inherited model) — GO-WITH-FIXES (blockers=0) → P2 fixed → re-verdict PASS + +Clean: bare `ocx service` idempotency (installed → repair, actively +refreshed + serving-verified, never silently blessed); unknown-state is a +pre-validated fail-closed exit(1) with actionable guidance; Windows tri-state +probe never guesses absence; the #2287 wedge (unknown collapsed into absent → +elevated re-registration) is genuinely closed. + +P2 fixed (commit 174f03b60): English-source Windows fail-closed caveat was +missing from all 7 translations — added to ko/ja/fr/ru/tr/zh-cn/zh-tw per +repo docs-sync rule. Re-verdict: PASS. + +Accepted residuals (P3, pre-existing or non-blocking): end-to-end +serviceCommand wiring test, darwin/linux hook-based probe tests, localized +access-denied markers beyond en/de. + +Gates at train head 174f03b60: service/cli-help/winsw tests 174/174 + +cli-help 13/13, typecheck pass, privacy:scan pass. lidge r8 hit a single +SIGTERM-shutdown timeout (20s wall cap; known timing-sensitive test, passes +locally and in isolation on both hosts); full-suite re-run r8b executed as +the merge gate. diff --git a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md new file mode 100644 index 0000000000..aaab6ea8bf --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md @@ -0,0 +1,36 @@ +# 060 — Cycle 6: PR #2270 (apply_patch routed lowering) + +48 behind; single rebase onto now-stable dev. Preserve the !isCanonicalOpenAiForwardProvider boundary (already on head 398b7ade4; maintainer review r3 found no remaining technical blocker). Review: supportsResponsesCustomTools capability plumbing (registry/derive/types), compaction-body-last reorder invariant, byte-identical non-compaction pin test. +Fork head (olddonkey/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Pre-merge: dismiss stale CHANGES_REQUESTED (converged per reviewer's own head-398b7ade4 comment) or record fresh APPROVE. Verify on REBASED head BEFORE merge: bun test tests/custom-tool-compat.test.ts tests/namespace-tool-compat.test.ts tests/openai-responses-passthrough.test.ts tests/responses-custom-tool-repair.test.ts, bun run typecheck, FULL SUITE (shared routing/adapter surface; ssh lidge if local env-limited). grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live PR head 398b7ade4 — 4 commits over base 7881319e, ~50 behind dev) + +The fork branch is not directly fetchable as a remote ref (fork: olddonkey); +use the PR ref. The branch carries its own rebase history — do NOT rebase the +fork branch; merge the PR ref into the TRAIN and let the train carry it. +Fork push only needed if we stack new commits on the PR itself. Steps: +1. Merge pr/2270 into train, resolve conflicts there. +2. Adversarial review (inherited model): supportsResponsesCustomTools + plumbing, compaction-body-last reorder invariant, byte-identical + non-compaction pin, !isCanonicalOpenAiForwardProvider boundary. +3. Focused custom-tool tests + typecheck + privacy locally at merged head; + lidge full suite; land via train PR to dev; dismiss stale review state via + merge admin path. + +## Review (Bohr, inherited model) — GO-WITH-FIXES (blockers=0) → P2 fixed → re-verdict PASS + +Clean: capability plumbing consistent (undefined/true = passthrough, false = +lowering, explicit-override precedence tested); all consumption sites behind +the exact-base-URL canonical gate; reorder fixes the real latent bug +(compaction replayed custom_tool_call reached strict upstreams unlowered) +with byte-identical non-compaction pin intact; response restoration +fail-closed via buildToolBridgeMaps; no privacy/logging regressions. + +P2 fixed (commit ec32a8d52): negative pin proving the canonical Codex forward +surface ignores supportsResponsesCustomTools:false. Re-verdict: PASS. +Accepted residuals (P3): composed registry-to-handleResponses e2e, +namespace-child deny dedup coverage, tool_choice + lowered apply_patch case. + +Gates at train head ec32a8d52: focused tests 138/138 (+ pin 100/100), +typecheck pass, privacy:scan pass, lidge r9 full suite 14233 pass / 0 fail +exit 0 at 668512a58 + pin-only delta after. diff --git a/devlog/_plan/260821_bug_merge_train/065_merge_2281.md b/devlog/_plan/260821_bug_merge_train/065_merge_2281.md new file mode 100644 index 0000000000..928d768383 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/065_merge_2281.md @@ -0,0 +1,39 @@ +# 065 — Cycle 7: PR #2281 (thought-signature replay, last mover) + +Takes the core.ts rebase conflict deliberately. Pre-merge blockers (ALL merge-blocking): (a) stacked commit: normalize promptCacheKey via anthropicSessionKeyFromParts before assigning clientThreadId (src/server/responses/core.ts ~1888-1896; helper at src/oauth/anthropic-routing.ts:573-594) + trimmed/overlong-key test rows; (b) missing_regression_test hygiene label re-checked after stacked commit — drop or record maintainer override; (c) rebase onto final dev, resolve core.ts against #2296's affinity changes with a semantic re-check (replay scope + affinity key compose; both test files green on merged tree); (d) FULL SUITE green on that head. +Fork head (Hsia97/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Also: reviewDecision is CHANGES_REQUESTED (lidge-jun priority-63 review) — the stacked fixes must answer that review, then refresh/dismiss it. Verify: bun test tests/claude-code-thought-signature-scope.test.ts tests/google-signature-history-roundtrip.test.ts, bun run typecheck, FULL SUITE. Owner (CODEOWNERS core.ts) review recorded at merge. grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live PR head b31f3dbed — 2 commits over base e3b2136b, far behind dev) + +Merge the PR ref into the train and resolve the core.ts conflict there against +the landed affinity work. Steps: +1. Merge pr/2281 into train; resolve core.ts semantically (promptCacheKey + normalization + affinity compose). +2. Stacked commit (a): normalize promptCacheKey via + anthropicSessionKeyFromParts before clientThreadId assignment, with + trimmed/overlong-key test rows. +3. Adversarial review (inherited model) on the merged head: replay scope + correctness, signature integrity, cache-key normalization, privacy. +4. Focused signature tests + typecheck + privacy locally; lidge full suite; + land via train PR to dev; hygiene label (b) resolved by the stacked test + coverage; record owner approval at merge. + +## Review (Locke + second inherited reviewer) — both PASS + +Blocker (a) fixed by the stacked normalization commit bc6d6b516: promptCacheKey +routed through anthropicSessionKeyFromParts before scope assignment — trim + +sha256-over-128 parity with the affinity path; overlong-hash and whitespace +rows added. Reviewers verified: shared-cohort leak structurally blocked twice +(cacheKeySource gate + in-helper re-check); replay cache keys carry the full +provider/adapter/model/credential identity tuple plus serving-identity guard, +so no cross-session or cross-account signature leak; privacy clean (stored +scope is always the translator's opaque hash, never raw user_id). + +Accepted residuals (P3): provenance comment for future client-supplied +cache-key ingress; exact-digest pin and padded-trim row; header-priority row. +Hygiene label (b) resolved: regression coverage shipped in this train +(claude-code-thought-signature-scope.test.ts rows). + +Gates at train head bc6d6b516: focused 27/27, typecheck pass, privacy:scan +pass, lidge r10 full suite 14240 pass / 0 fail exit 0. Owner approval for +core.ts recorded by merging maintainer per repo policy. diff --git a/devlog/_plan/260821_bug_merge_train/070_final_gate.md b/devlog/_plan/260821_bug_merge_train/070_final_gate.md new file mode 100644 index 0000000000..595c30f123 --- /dev/null +++ b/devlog/_plan/260821_bug_merge_train/070_final_gate.md @@ -0,0 +1,8 @@ +# 070 — Cycle 7: final gate + +1. Confirm final dev head CI fully green (gh run list --branch dev; the ci aggregate job). +1b. Docs-sync check: after 2295 (en-only doctor docs) + 2289 (8-locale lifecycle) both land, confirm locale lifecycle pages do not contradict the English page (AGENTS.md docs-sync rule). +2. If macos/windows shard flakes, rerun; if real regression from the train, fix forward on dev. +3. Close remaining linked issues with landing-commit comments (#2287, #2291, #2046 decision). +4. Move devlog unit to _fin with terminal outcomes recorded per PR. +5. Goalplan criteria capturedEvidence filled; cxc loop validate green; update_goal complete. diff --git a/devlog/_plan/260822_dev_release_readiness/000_plan.md b/devlog/_plan/260822_dev_release_readiness/000_plan.md new file mode 100644 index 0000000000..4841be6d11 --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/000_plan.md @@ -0,0 +1,91 @@ +# 000 — dev release readiness (main..dev regression audit) + +origin/main is v2.29.0 (231e622be); origin/dev is 146 commits ahead +(1af7a1e26 at planning time). Goal: a dev head a maintainer can promote — +every subsystem delta audited for regressions, P0/P1s fixed with tests, full +gates green, honest GO/NO-GO. + +## Scope of the delta (inventory in 001) + +Major landings since main: SelectedImage native vision (#1742), Bun 1.4 +stable bump + canary retirement, model-catalog refresh (Ox Alpha, DeepSeek +vision preview), release deploy-key push path (#2290), Windows restart +helper (#2293), senpi T01/T03/T05 (#2320/#2321/#2322), clean Connect +terminal (#2307), Auto [Tool Result] echo fix (#2318), H2 discovery pool +(#2332), credential router module (#2334, unwired), Z.AI quota (#2028), Pi +route sep fix (#2272), xai web-search normalize (#2312), merge-train units +(#2281 prompt_cache_key, #2270 custom-tool passthrough, #2289 docs locales, +#2296 subagent quota scope, #2294 release host hardening), T04 watchdog +(#2337), T07+shutdown (#2338), #2305 text-marker fix (#2341), bare-RE size +prior (#2342), round-2/3 devlog units. + +Audit-round additions (first plan audit caught these missing): xAI Fast / +Priority Processing enablement + pricing (f87698c0d, 1d7d8177a, 057f93ea5, +#2072 train), Claude Code thought-signature call_id replay (6c748663e, +b31f3dbed), zero-byte coordinator remnant recovery (6d5f0cf2c, #2295), +desktop pool affinity / reconnect binding (0e5a43459, 72df5e0de), vision +routed-backend sidecar incl. loopback describe executor (21aec549d.. +3ff19c33e, #2306/#2188), Windows service fail-closed installation state +(948fb5db1, 2df92a270). 001 must inventory from the ACTUAL log, not this +summary. + +## Audit lanes (WP4, read-only subagents; ox-alpha preferred) + +- L1 cursor adapter stack: vision, watchdog, terminal paths, error + classification interplay (esp. #2342 size prior vs #2320 mapping vs T04 + watchdog error paths), request-builder text channel rebuild. +- L2 providers/registry + quota: catalog refresh, noVision curation, Z.AI + windows, subagent quota scope, Ox Alpha entries. +- L3 release surface: deploy-key push path, scp-host rejection, release.ts + vs workflows, version/tag consistency. +- L4 GUI/dashboard + management API: sidebar/star routes, models API + parity with registry changes. +- L5 runtime/CI: Bun 1.4 bump fallout, workflow hardening test shape, + Windows shard skips, test-queue behavior. +- L6 responses-core + client adapters: prompt_cache_key normalization + (#2281), custom-tool passthrough (#2270), compaction body ordering / + apply_patch lowering, Claude Code thought-signature replay, vision routed + describe executor (server half of #2306), desktop pool affinity + + zero-byte coordinator recovery. + +Lane ownership rule: every commit in the 001 inventory is assigned to +exactly one lane in 002; unassigned commits fail the matrix (the first +audit found L1-L5 left responses-core uncovered). + +## Write-scope contract (WP boundaries) + +- WP1 (this cycle): docs-only. Probe TRANSCRIPT capture for the 210/290 + re-probe is allowed (read-only wire calls, redacted); no src/ edits. +- WP4: audit lanes are READ-ONLY subagents; ALL production fixes are + main-agent edits, each with a regression test, each its own commit. +- Promotion itself is out of scope (maintainer decision). + +## 210/290 correction contract (re-probe, not prose edit) + +The "fast is callable" correction REPLACES the 210 entitlement +interpretation, so it must carry its own probe transcript (already captured +live this session: opus-4-8-high-fast succeeded both maxMode arms; +4-7-low-fast RE persists; bare 4-7-fast not_found) AND must reopen the 290 +parity verdict: maxMode becomes provable, so 290's "unprovable on this plan +tier" row is amended to point at 310 (big-ctx A/B, billing approved) as the +deciding probe. 260's size-prior evidence stands, but its "entitlement +rejections share the shape" framing is softened to "non-overflow rejections +share the shape" since the tier-specific RE cause is now unknown. + +Each lane returns: findings ranked P0(release blocker)/P1(fix before +promote)/P2(note), each with file:line, repro or verifying command, and a +confidence tag. Main agent falsifies P0/P1 before fixing (no snippet-only +fixes). + +## Gates for GO + +- bun run typecheck + full bun run test green (local or ssh lidge). +- bun run privacy:scan green; lint:gui if gui touched. +- Cross-platform CI green on final head. +- No open P0/P1 from any lane. +- Security-review sign-off recorded for release-surface changes (#2290, + #2294, workflow edits) per MAINTAINERS.md — L3 lane must produce an + explicit security-review section, and its findings gate GO. +- Docs-sync check: user-facing behavior changes (catalog refresh, quota, + vision) verified against docs-site; locale parity spot-check beyond #2289. +- GO/NO-GO recorded in 090_go_verdict.md with evidence pointers. diff --git a/devlog/_plan/260822_dev_release_readiness/001_delta_inventory.md b/devlog/_plan/260822_dev_release_readiness/001_delta_inventory.md new file mode 100644 index 0000000000..f815562a95 --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/001_delta_inventory.md @@ -0,0 +1,121 @@ +# 001 — main..dev commit inventory (mechanical) + +Source: `git log origin/main..origin/dev --oneline --no-merges` at +planning head 1af7a1e26 (109 non-merge commits; merges excluded — +PR numbers appear in subject lines). + +``` +5eb56409c devlog: 290 post-landing status — 230/231 and 260 landed, final CI gate noted +f3a7cd4a1 fix(cursor): keep provably-small bare resource_exhausted on the 429 class +ab6a54e4d fix(cursor): fold display aliases in textual pseudo tool-call markers back to wire names +d6b8f8b5d devlog: round-3 live-probe evidence and lock (docs-only) +ce15bf9ff fix(cursor): fail OAuth polling on terminal statuses and shut the discovery H2 pool down at lifecycle exit +994e5ba87 fix(cursor): fail silent and heartbeat-only streams at the transport instead of the 300s bridge watchdog +6b889c36e devlog: round-2 Cursor stabilization research and roadmap lock (docs-only) +525568652 feat(cursor): add weighted credential router with cooldown failover (#2334) +d79b1b444 perf(cursor): add HTTP/2 session pool for discovery calls (#2332) +a69d291fb fix(cursor): stop native Auto from echoing [Tool Result] as chat (#2318) +b513a9142 fix(cursor): unknown exec replies with ExecClientThrow + streamClose instead of silence (#2322) +fd0605868 fix(cursor): close HTTP/2 after turnEnded so a held-open response cannot stall the turn (#2321) +b08ea715c fix(cursor): classify bare 0-token resource_exhausted as context overflow (#2320) +c836ffbff devlog: triage matrix — mark #2281 hardened and merged on the train +3b18d288b devlog: 2281 review rounds — both reviewers pass +bc6d6b516 fix(responses): normalize Claude Code prompt_cache_key through anthropicSessionKeyFromParts +0fb80bdeb devlog: 2281 cycle plan — merge-ref strategy with stacked normalization +c7f341a80 devlog: triage matrix — mark #2270 hardened and merged on the train +65c0fd362 devlog: 2270 review round — boundary pin added, re-verdict pass +ec32a8d52 test(responses): pin canonical forward custom-tool passthrough against explicit denial +3bbe4e411 devlog: 2270 cycle plan — PR-ref merge strategy for fork +5bbca70ab devlog: triage matrix — mark #2289 hardened and merged on the train +7957756ea devlog: 2289 review round — locale parity fixed, re-verdict pass +174f03b60 docs(lifecycle): sync Windows bare-service fail-closed caveat across all 7 locales +d846ad4e0 devlog: 2289 cycle plan — live-head scope after author rebase +c16d5ffde devlog: triage matrix — mark #2296 hardened and merged on the train +d83222154 devlog: 2296 security review round — major fixed, re-verdict pass +698228e40 fix(codex): derive subagent preview quota scope from the route model +c142cc72c devlog: 2296 cycle plan — live-head scope, inherited-model reviewer +f52de33f8 devlog: triage matrix — mark #2294 hardened and merged on the train +08bd08641 devlog: 2294 security review round — blocker fixed, re-verdict pass +2cdfba24d fix(release): reject credential-shaped scp-like hosts and colon-bearing userinfo +aea77b84c devlog: 2294 cycle plan — live-head scope and gate sequence +584a3e3e5 devlog: triage matrix — mark #2295 merged on the train +7f00202d4 devlog: 2295 cycle — full-suite rerun green after gui deps fix (14175 pass / 0 fail, lidge) +64cd6e5a9 fix(xai): normalize Responses web search tools +fcc3f5c05 fix(cursor): keep mixed tool terminals fail-closed +76166608f fix(cursor): preserve drained terminal on clean end +56bff341a test(cursor): harden clean terminal teardown +2df92a270 fix(service): fail closed on unknown installation state +948fb5db1 fix(service): restart existing installations without re-registering +0e5a43459 fix(codex): align Desktop affinity preview +72df5e0de fix(codex): bind Desktop reconnects to one pool account +c9c818d13 fix(cursor): settle clean Connect terminal without HTTP EOF +a228ed741 devlog: vision routed dropdown screenshot (PR evidence) +362377a03 test(vision): pin routed GET verbatim reporting (live-found regression) +a211e6d9e devlog: record vision routed-backend live delivery evidence (190) +3ff19c33e feat(vision): GUI/CLI routed surfaces + GET reports the routed describer verbatim +316190447 feat(vision): routed describe executor via loopback self-fetch (#2188 roadmap 180) +21aec549d feat(vision): routed describer backend — options, gates, namespaced ids (#2188 roadmap 170) +7317dde30 devlog: bug merge-train roadmap (260821) — triage, dependency analysis, audited disposition order +1d7099328 devlog: vision external-backend roadmap (160-190) under sidecar-selection unit +71598fa45 test(release): close SSH target log bypasses +4c7b3ceb8 fix(release): reject credential-bearing SSH remotes +6d5f0cf2c fix(codex): recover zero-byte coordinator remnants +6c33ea5dd devlog: record provider verification and PR fallback for restart helper +4430742f6 scripts: add Windows Codex desktop full-restart helper +569d0208c fix(test): compare terminal-guard rebuild content, not wall-clock stamps +25b0c11a9 fix(release): harden the deploy-key push path against three review findings +7a6d9c23f fix(release): derive the ssh push target from origin instead of hardcoding it +59d6367d4 fix(release): quote the deploy-key path in GIT_SSH_COMMAND +ed727d0e5 feat(release): push the version bump through a dedicated release deploy key +3e130d239 devlog: record the 260821 model-catalog-refresh unit +d23c3179f feat(providers): Ox Alpha (stealth 1M multimodal) and the DeepSeek vision preview across the catalog +27764f342 chore(runtime): move the bundled Bun to 1.4.0 stable and retire the canary channel +293276e0d docs(runtime): record the green full-suite run under Bun 1.4 canary +6889825bf fix(codex): keep multi_agent_v2 readable when the TOML parser rejects the document +68137e200 test(codex): stop relying on Bun 1.3.14 leaking PATH into children +876ebf320 docs(runtime): record what the Bun 1.4 canary lane found +d9ff528f9 test(codex): pin the datetime catalog contract across Bun TOML versions +8a3d43552 test(ci): teach the workflow hardening test the new CI shape +4cc735344 docs(runtime): README reflects the GitHub canary channel +90eabcc42 ci(runtime): qualify Bun 1.4 from the GitHub canary channel +d3ec5abd1 docs(runtime): add preview-dev branch README and upstream track pointer +1d76525eb docs(runtime): Bun 1.4 preview-dev roadmap with diff-level decade docs +a0fa018e7 ci(runtime): source Bun version from package.json and qualify preview-dev +aedc223c8 test(cursor): wait for RunSSE fetch instead of two microtasks +4729b37d6 test(quota): lock real Z.AI v2 and new-protocol responses as fixtures +d884d2c4a docs(providers): document the Z.AI GLM Coding Plan quota probe +10b3dee58 fix(quota): tighten zai window matching and legacy fallback gate +dcda7fa59 feat(quota): support GLM coding plan quota on z.ai and bigmodel.cn +e8c62a90d test(fastwire): expect xAI key-auth chat to forward Fast +4fbfb27d1 test(clients): assert the Pi override with join, not a POSIX separator +398b7ade4 test(responses): lower apply_patch on noncanonical forward destinations +2785aa29d test(responses): assert the terminal SSE marker on namespace replay +88ffe3272 fix(responses): build the routed compaction body last +df16e0a78 fix(responses): lower apply_patch for upstreams that reject custom tools +3124cb13d docs(cursor): use French typographic apostrophe in Vision omission wording +61ad6653e docs(cursor): describe history and omission markers in Vision sections +4e82029f5 fix(cursor): fail closed on untrusted sniff and soft-cap misses +c688bace5 fix(cursor): address post-rebase CodeRabbit nits on SelectedImage +40d096475 fix(cursor): avoid duplicate prepared binding in live transport +a0b96ec43 fix(cursor): reuse prepared SelectedImage bytes +e6a4a232c fix(cursor): keep image-only history in external root replay +e332aa2b6 docs(cursor): add glm-5.3 and French Vision section +43ad5ae87 fix(cursor): validate small JPEGs before passthrough +6097e60b4 fix(cursor): abort before image-count guard +2d703c89e fix(cursor): address second CodeRabbit pass on SelectedImage +0e5924366 fix(cursor): address CodeRabbit findings on native SelectedImage +82d2f32ff feat(cursor): native SelectedImage vision for verified models (data: only) +b31f3dbed test: cover Claude Code thought-signature replay scope +6c748663e fix: enable call_id thought-signature replay for Claude Code +d4023aedd docs(xai): separate the OAuth gateway row in the remaining locales +33e1c3e08 docs(xai): separate OAuth gateway rows +c13981b5a docs(xai): clarify API key transport +d887a4f2d fix(gui): translate estimated cost labels +1d7d8177a fix(xai): address B2 pricing review +057f93ea5 docs(devlog): capture the xAI Fast pricing UI evidence +f87698c0d feat(xai): enable Priority Processing on the API-key transport +``` + +Lane assignment for every commit lives in 002 (lane-ownership rule: exactly +one lane each; unassigned commits fail the matrix). + diff --git a/devlog/_plan/260822_dev_release_readiness/002_risk_matrix.md b/devlog/_plan/260822_dev_release_readiness/002_risk_matrix.md new file mode 100644 index 0000000000..0213841c5b --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/002_risk_matrix.md @@ -0,0 +1,95 @@ +# 002 — risk matrix (main..dev, head 1af7a1e26) + +Inventory source: `git log origin/main..origin/dev --oneline --no-merges` (109 commits, regenerated this audit). +Lane ownership rule satisfied: every commit assigned exactly one lane; counts sum to 109 (assertion at end). +Docs-only devlog commits inherit the lane of their subject unit; they carry no direct regression risk and are never ranked below. + +## L1 — cursor adapter stack (32 commits) + +Commits: 5eb56409c f3a7cd4a1 ab6a54e4d d6b8f8b5d ce15bf9ff 994e5ba87 6b889c36e 525568652 d79b1b444 a69d291fb b513a9142 fd0605868 b08ea715c fcc3f5c05 76166608f 56bff341a c9c818d13 569d0208c aedc223c8 3124cb13d 61ad6653e 4e82029f5 c688bace5 40d096475 a0b96ec43 e6a4a232c e332aa2b6 43ad5ae87 6097e60b4 2d703c89e 0e5924366 82d2f32ff + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | 82d2f32ff + 0e5924366 2d703c89e c688bace5 43ad5ae87 6097e60b4 a0b96ec43 40d096475 (SelectedImage train) | New native vision path: base64 data: gating, JPEG validation, image-count guard ordering, prepared-byte reuse — many interacting guards; a regression silently drops or corrupts images on verified models | `bun test tests/cursor*selected*image* -i` (nearest existing SelectedImage coverage; else `bun test --isolate tests/cursor-vision*.test.ts`) | H | +| 2 | 525568652 (#2334 weighted credential router) | New failover/cooldown state machine; cooldown misclassification could rotate away healthy credentials or pin dead ones | focused router test file covering cooldown/failover transitions (`bun test --isolate tests/*credential-router*`) | H | +| 3 | b08ea715c (#2320) × f3a7cd4a1 (#2342 size prior) × 994e5ba87 (T04 watchdog handoff) | Three overlapping resource_exhausted/silent-stream classifiers — error may be mapped twice (overflow then 429) or watchdog fires after transport already settled | `bun test --isolate tests/cursor-error-classification*.test.ts` (covers bare-RE mapping and 429-class prior) | H | +| 4 | ce15bf9ff | OAuth polling terminal-status failure + discovery H2 pool shutdown at lifecycle exit — wrong teardown order leaks sockets or hangs exit | `bun test --isolate tests/cursor-oauth*.test.ts` | M | +| 5 | fd0605868 (#2321) + d79b1b444 (#2332) | HTTP/2 session lifetime: close-after-turnEnded vs pooled discovery sessions — held-open response stalls turn or pool reuse returns a closed session | `bun test --isolate tests/cursor-h2*.test.ts` | M | +| 6 | fcc3f5c05 + 76166608f + 56bff341a + c9c818d13 | Terminal-state machine rework (mixed terminals fail-closed, drained-terminal preservation, clean Connect without EOF) — ordering bugs produce silent turn loss | `bun test --isolate tests/cursor-terminal*.test.ts tests/cursor-connect*.test.ts` | M | +| 7 | ab6a54e4d | Display-alias folding inside textual pseudo tool-call markers can over-fold legitimate user text containing alias strings | `bun test --isolate tests/cursor-text-marker*.test.ts` | M | + +## L2 — providers / registry + quota (17 commits) + +Commits: c16d5ffde d83222154 698228e40 c142cc72c 64cd6e5a9 3e130d239 d23c3179f 4729b37d6 d884d2c4a 10b3dee58 dcda7fa59 d4023aedd 33e1c3e08 c13981b5a 1d7d8177a 057f93ea5 f87698c0d + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | d23c3179f (Ox Alpha + DeepSeek vision preview catalog) | Registry-wide entries: wrong context/vision/pricing metadata propagates to routing, noVision curation, GUI cost estimates | `bun test --isolate tests/model-catalog*.test.ts` (or nearest registry contract test) | H | +| 2 | dcda7fa59 + 10b3dee58 (GLM coding-plan quota) | Window-matching rewrite affects legacy fallback gate — mis-window reports wrong remaining quota and could trigger false exhaustion routing | `bun test --isolate tests/quota-zai*.test.ts` (fixtures pinned in 4729b37d6) | H | +| 3 | 698228e40 (#2296 subagent preview quota scope) | Scope derived from route model — wrong derivation double-counts or bypasses subagent quota | `bun test --isolate tests/subagent-quota-scope*.test.ts` | M | +| 4 | f87698c0d + 1d7d8177a (xAI Priority Processing + B2 pricing) | Pricing-tier enablement gated on transport type; wrong gate bills priority rates on key-auth-less transports or misprices | `bun test --isolate tests/xai-pricing*.test.ts` | M | +| 5 | 64cd6e5a9 (xAI web-search tool normalize) | Tool-shape rewriting in request path — malformed normalize breaks every xAI search-enabled request | `bun test --isolate tests/xai-web-search*.test.ts` | M | + +## L3 — release surface (11 commits) + +Commits: f52de33f8 08bd08641 2cdfba24d aea77b84c 7317dde30 71598fa45 4c7b3ceb8 25b0c11a9 7a6d9c23f 59d6367d4 ed727d0e5 + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | ed727d0e5 + 59d6367d4 + 25b0c11a9 (#2290 deploy-key push) | Release automation now authenticates pushes via dedicated deploy key through GIT_SSH_COMMAND — token handling, quoting, and target derivation are release-blocker surface per AGENTS.md | `bun test --isolate tests/release-deploy-key*.test.ts` (plus targeted `bun x tsc --noEmit scripts/release.ts` if no dedicated file) | H | +| 2 | 2cdfba24d (#2294) + 4c7b3ceb8 + 71598fa45 (scp-host rejection) | Remote-string parsing that rejects credential-shaped scp hosts — over-rejection breaks legitimate remotes; under-rejection leaks credentials into logs/errors | `bun test --isolate tests/release-ssh-host*.test.ts` (covers log-bypass cases from 71598fa45) | H | +| 3 | 7a6d9c23f (push target from origin) | Deriving push target from origin instead of hardcoding — wrong remote parse pushes a version bump to an unintended host | same SSH-target test file as rank 2 | M | +| 4 | 7317dde30 (merge-train roadmap docs) | Planning artifact only — risk is process drift, not runtime | none (docs) | L | + +### SECURITY REVIEW — L3 (required per MAINTAINERS.md / AGENTS.md) + +Scope: #2290 deploy-key push path, #2294 scp-host rejection, workflow edits in range. + +- **#2290 deploy-key push** (ed727d0e5, 59d6367d4, 7a6d9c23f, 25b0c11a9) — **pass.** Token handling: key material stays in GIT_SSH_COMMAND env, not argv/logs after 59d6367d4 quoting; three review findings fixed in 25b0c11a9 and the blocker-fix round recorded (08bd08641, re-verdict pass). Push target now derived from origin (7a6d9c23f), eliminating the hardcoded-remote drift. No mutable third-party action refs introduced. Residual note (P2): confirm the deploy key is least-scope (single-repo write) in host config — outside code audit reach. Pointer: `scripts/release.ts` (deploy-key push section). +- **#2294 scp-host rejection** (2cdfba24d, 4c7b3ceb8, 71598fa45) — **pass.** Rejects credential-bearing scp-like hosts and colon-bearing userinfo before any spawn; log-bypass avenues closed by 71598fa45 tests. Secret-exposure check: rejection errors must render the sanitized host only — covered by the bypass tests; no raw remote echoed. Blocker found in review was fixed pre-merge (08bd08641 re-verdict pass). +- **Workflow edits** (90eabcc42 Bun canary qualification, a0fa018e7 version sourcing from package.json, 8a3d43552 hardening-test shape update) — **pass.** No new secrets, no pull_request_target expansion, no mutable third-party action refs added (canary channel is a runtime download, not an action ref; its integrity rests on Bun's release artifacts — P2 note: pin/checksum if this becomes a supply-chain concern). Permissions scope unchanged. + +Verdict summary: all three security-sensitive change sets **pass**; no needs-fix items. Findings above gate GO only via the two P2 operational notes. + +## L4 — GUI/dashboard + management API (5 commits) + +Commits: a228ed741 362377a03 a211e6d9e 3ff19c33e d887a4f2d + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | 3ff19c33e (GUI/CLI routed vision surfaces + GET verbatim) | Management GET must report the routed describer exactly — parity break between registry state and dashboard display misleads operators | `bun test --isolate tests/vision-routed-reporting*.test.ts` (pinned by 362377a03) | M | +| 2 | d887a4f2d (estimated cost labels translation) | Label i18n keyed off catalog entries changed in L2 — mismatch shows raw keys or wrong currency figures | `bun run lint:gui` + focused GUI i18n test if present | L | + +## L5 — runtime / CI (21 commits) + +Commits: 5bbca70ab 7957756ea 174f03b60 d846ad4e0 7f00202d4 2df92a270 948fb5db1 6c33ea5dd 4430742f6 27764f342 293276e0d 6889825bf 68137e200 876ebf320 d9ff528f9 8a3d43552 4cc735344 90eabcc42 d3ec5abd1 1d76525eb a0fa018e7 + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | 27764f342 (Bun 1.4 stable bump, canary retired) + a0fa018e7 (version from package.json) | Runtime version bump touches every subsystem; TOML/datetime/PATH behaviors differ across Bun versions (see 6889825bf, d9ff528f9, 68137e200 mitigations) | `bun run test` (full suite — shared-runtime change) | H | +| 2 | 2df92a270 + 948fb5db1 (Windows service install state) | Fail-closed on unknown installation state + restart-without-reregister — wrong state machine bricks existing installs on upgrade | `bun test --isolate tests/service-install*.test.ts` (Windows-skipped shards verified on a Windows CI run) | H | +| 3 | 4430742f6 (Windows desktop full-restart helper) | Script kills/relaunches desktop processes — overly broad match kills unrelated processes | manual dry-run review of script + `zsh -n`-equivalent syntax check | M | +| 4 | 90eabcc42 + 8a3d43552 (CI canary qualification + workflow-hardening test shape) | CI shape change invalidates the hardening test's assumptions; silent skip hides regressions | `bun test --isolate tests/workflow-hardening*.test.ts` | M | + +## L6 — responses-core + client adapters (23 commits) + +Commits: c836ffbff 3b18d288b bc6d6b516 0fb80bdeb c7f341a80 65c0fd362 ec32a8d52 3bbe4e411 584a3e3e5 0e5a43459 72df5e0de 316190447 21aec549d 1d7099328 6d5f0cf2c e8c62a90d 4fbfb27d1 398b7ade4 2785aa29d 88ffe3272 df16e0a78 b31f3dbed 6c748663e + +| Rank | Commit(s) | Why risky | Verify | Grade | +|---|---|---|---|---| +| 1 | bc6d6b516 (#2281 prompt_cache_key normalization) | anthropicSessionKeyFromParts normalization sits on every Claude Code request — bad split leaks or mangles session keys and breaks cache affinity | `bun test --isolate tests/responses-prompt-cache-key*.test.ts` | H | +| 2 | df16e0a78 + 88ffe3272 (apply_patch lowering + compaction-body-last ordering) | Request-body assembly reorder: lowering custom tools AND building compaction last interact — a rebuilt body that drops lowered tools or stale compaction sends malformed upstream requests | `bun test --isolate tests/responses-apply-patch*.test.ts tests/responses-compaction*.test.ts` | H | +| 3 | 6c748663e + b31f3dbed (thought-signature call_id replay) | Replay scope change alters what Claude Code sees mid-conversation; too-broad replay duplicates signatures, too-narrow drops them and upstream rejects | `bun test --isolate tests/thought-signature*.test.ts` | M | +| 4 | 316190447 + 21aec549d (routed describe executor, loopback self-fetch) | Server-side self-fetch creates a request path back into the proxy — deadlock/gate-bypass risk if loopback auth or gates are mishandled | `bun test --isolate tests/vision-describe-executor*.test.ts` | M | +| 5 | 0e5a43459 + 72df5e0de + 6d5f0cf2c (desktop pool affinity/reconnect binding + zero-byte remnant recovery) | Pool account binding and remnant recovery touch connection reuse — wrong binding splits sessions across accounts; recovery of zero-byte remnants may resurrect stale state | `bun test --isolate tests/desktop-pool*.test.ts` (+ coordinator remnant recovery test) | M | +| 6 | ec32a8d52 + 398b7ade4 + 2785aa29d + 4fbfb27d1 + e8c62a90d | Contract pins for custom-tool denial/passthrough, SSE namespace marker, Pi separator join, xAI fastwire — pins encode cross-version behavior; a drifted upstream fails these first | run each named test file with `bun test --isolate` | L | + +## Lane-coverage assertion + +Every commit in the regenerated 109-line inventory is assigned to exactly one lane. Counts: L1 = 32, L2 = 17, L3 = 11, L4 = 5, L5 = 21, L6 = 23. Sum = 109 ✓. No commit unassigned; no commit double-assigned. + +> Provenance: matrix produced by a read-only ox-alpha classification lane; L3 +> security verdicts rest on recorded review rounds (08bd08641, d83222154) plus +> commit evidence. WP4's L3 lane re-reads scripts/release.ts and workflows at +> head for file:line-grade confirmation before GO. + diff --git a/devlog/_plan/260822_dev_release_readiness/009_roadmap_lock.md b/devlog/_plan/260822_dev_release_readiness/009_roadmap_lock.md new file mode 100644 index 0000000000..d5c62ef9e9 --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/009_roadmap_lock.md @@ -0,0 +1,24 @@ +# 009 — WP roadmap lock (release readiness) + +Locked after the audited 000 plan (Faraday PASS), mechanical 001 inventory +(109 commits), and the 002 risk matrix (all lanes assigned, sum 109, +security-review section pass with 2 P2 notes). + +## Cycle order + +1. WP2 -> 300_opus_fast_catalog.md (senpi unit): catalog repair, tests, + live smoke on macmini. +2. WP3 -> 310_maxmode_bigctx.md: 2-run big-context A/B (billing approved); + conditional maxMode propagation or NOOP. +3. WP4 -> execute 002 matrix: read-only lanes L1-L6 verify their ranked + rows (run the named commands, falsify or confirm); main agent fixes + P0/P1 with regression tests; full suite + typecheck + privacy + (if gui) + lint. L3 lane re-reads release.ts + workflows at head for file:line + security confirmation. +4. WP5 -> 090_go_verdict.md: final CI green + GO/NO-GO with evidence. + +## Standing constraints + +Write scope per 000 (WP4 lanes read-only, main-agent fixes only); +promotion excluded; probe hygiene per senpi-unit doc 200. + diff --git a/devlog/_plan/260822_dev_release_readiness/010_wp4_findings.md b/devlog/_plan/260822_dev_release_readiness/010_wp4_findings.md new file mode 100644 index 0000000000..e1d2e0bcbf --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/010_wp4_findings.md @@ -0,0 +1,38 @@ +# 010 — WP4 audit lane findings (consolidated) + +Six read-only lanes executed the 002 matrix at head 67b5fa019 (inherited- +model fallback after ox-alpha 429'd on 6 parallel spawns — the stealth +model's rate pool cannot host 6 concurrent lanes). + +## Verdicts + +| Lane | Verdict | Notes | +|---|---|---| +| L1 cursor stack | 0 P0 / 0 P1 / 1 P2 | 351 tests green across 7 rows; classifier chain single-pass proven; watchdog disarm ordering verified | +| L2 registry/quota | CLEAN | 5 rows; noVision substring fear disproven (modelInList exact/colon match); quota display-only | +| L3 release surface | CLEAN | file:line security confirmation delivered: key path env-only (release.ts:244), fixed-string rejection errors, all 16 workflows SHA-pinned, release.yml permissions {} | +| L4 GUI/mgmt API | 0 P0 / 0 P1 / 1 P2 | GET/PUT/runtime parity proven; i18n keys typed-complete | +| L5 runtime/CI | CLEAN | Bun 1.4 mitigations individually green; Windows service state machine fail-closed; aggregate-gate derives needs from all jobs | +| L6 responses-core | CLEAN | 635 tests green; compaction ordering invariant honored; describe-executor recursion fenced at depth 1 | + +## P2 register (not promote blockers) + +1. **[L1] ~1MiB invalid_argument replay burn** — oversized single message + triggers one guaranteed-pointless fresh-conversation replay (~doubles + time-to-error). Fix sketch recorded (pre-flight size guard before + runOnce). Own cycle later. +2. **[L4] routed-vision GET display drift** — GET does not re-verify + targetVisible, so a later noVisionModels edit shows stale routed pair + while runtime falls through. Display-only; reachable only by hand-edit. +3. (carried from 002) deploy-key least-scope is host-config, outside code + audit; Bun canary pinning moot since stable bump. + +## Gates run this phase + +- bun x tsc --noEmit: clean. +- Full suite: 14264 pass / 10 skip / 0 fail (897 files, 613s — slow due to + parallel audit lanes on the same machine, not test regressions). +- privacy:scan: green (run in WP1/WP3 closes; re-run at WP5 close). +- Matrix note: several 002 "Verify" globs named nonexistent files; lanes + located and ran the real nearest coverage (recorded per lane report). + diff --git a/devlog/_plan/260822_dev_release_readiness/090_go_verdict.md b/devlog/_plan/260822_dev_release_readiness/090_go_verdict.md new file mode 100644 index 0000000000..83f7cb97df --- /dev/null +++ b/devlog/_plan/260822_dev_release_readiness/090_go_verdict.md @@ -0,0 +1,44 @@ +# 090 — GO/NO-GO verdict + +Head at close: 2b4ddf3b0 (docs-only merges above a012a460e; the last +code-bearing commit is the opus-fast catalog, PR #2346). + +## Verdict: GO (promotion-ready dev) + +## Evidence + +- **CI**: Cross-platform CI completed success on a012a460e (last code head) + and on 8f3ac5fe9 before it. Subsequent commits are devlog-only and skip CI + by path filter; no code differs between a012a460e and this head + (verify: git diff a012a460e..HEAD --stat -- ':!devlog'). +- **Full suite**: 14264 pass / 10 skip / 0 fail (897 files) at 67b5fa019 + content (code-identical to head); bun x tsc --noEmit clean. +- **Regression audit**: 6 lanes over the 002 matrix (109 commits, all + assigned) — ZERO P0/P1. Lane reports in 010. +- **Security review (GO gate)**: L3 file:line confirmation — deploy-key + path env-only (release.ts:244), fixed-string rejection errors, all 16 + workflows SHA-pinned, release.yml permissions {} + OIDC scoped to publish + job. Matrix + lane verdicts: pass. +- **privacy:scan**: green at every docs close in this loop. +- **Docs-sync**: catalog/vision/quota changes carry devlog units; locale + parity for cost labels verified in L4 (9 locales typed-complete). + +## Open items (not blockers, tracked) + +- P2: ~1MiB per-message pre-flight guard (L1, fix sketch in 010). +- P2: routed-vision GET display drift on post-write noVision edits (L4). +- P2 ops: deploy-key least-scope is host-side config (outside repo). +- NEEDS_HUMAN: #2334 CursorCredentialRouter wiring (product decision); + unwired module confirmed zero runtime reach (L1). +- Deferred probes: T02 rotation (unreproduced), maxMode propagation (NOOP + by 310 A/B), client-version bump (NOOP by 240). + +## What this loop landed since v2.29.0 relevant to release notes + +Opus Fast families with verified tiers (#2346), #2305 text-marker fix +(#2341), bare-RE size prior (#2342), T04 stream-health watchdog (#2337), +OAuth fail-fast + H2 pool shutdown (#2338), plus the senpi round-2/3 and +readiness research units. + +Promotion itself is a maintainer action (dev -> preview/main per +MAINTAINERS.md); this verdict only certifies dev's state. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md b/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md new file mode 100644 index 0000000000..75685484b4 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md @@ -0,0 +1,54 @@ +# 260822 — senpi Cursor transfer investigation + +Docs-only research unit. No production patches in this cycle. +Session `01a02665-e4c1-75a3-9660-c71284a1bba2`. Goalplan `investigate-whether-opencodex-can-adopt-any-curs`. + +## Loop spec + +- Loop archetype: satisfy-spec research (inventory + classify). Not an optimization loop. +- Trigger: user asked whether OpenCodex can take Cursor-runtime mechanisms from senpi, with unlimited explorer dispatch, no model-name overrides. +- Goal: evidence-bearing transfer verdict in this unit. Every comparison row cites OpenCodex `path:line` and senpi GitHub blob/commit. +- Non-goals: production `src/` edits; copying senpi protobuf wholesale; starring repos; live Cursor account mutation; spawning `cursor-agent` CLI; extracting secrets. +- Verifier: files exist under this unit; `git status` shows no production `src/` diffs from this loop; 090 table rows have both-codebase citations. +- Stop: 090 locked and wp0 criteria captured. Implementation is a later appended work-phase, not this cycle. +- Memory artifact: this directory. +- Terminal: DONE (research lock) / NOOP (no residual gaps) / NEEDS_HUMAN (ToS) / UNSAFE (native-app patching). +- Escalation: live Cursor probes, ToS/product-policy, or proto-regen risk. + +## Sources + +- OpenCodex tree: local checkout (explorers also cited `dev` `a228ed74` / GitHub `lidge-jun/opencodex`). +- senpi: `code-yeongyu/senpi` `main` SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717` (2026-08-21), files also fetched as current default-branch blobs. +- Explorer lanes (inherit parent model; no model field): Helmholtz (protocol), Planck (auth/catalog), Hypatia (exec), Leibniz (stream/usage), Pasteur (senpi protocol), Archimedes (senpi auth/catalog), Plato (exec-bridge + CLI), Ohm (overflow/RE). + +## Docs + +- 000 (this file) — unit map + later-implementation slice order. +- 001 — OpenCodex Cursor inventory. +- 002 — senpi Cursor inventory. +- 003 — protocol / transport compare. +- 004 — auth / catalog / effort / max-mode. +- 005 — exec / interactionQuery / tool pairing. +- 006 — stream completion / usage / overflow / rotation. +- 007 — CLI fallback lane. +- 090 — transfer verdict (ADOPT / ADAPT / REJECT / ALREADY-HAVE / NEEDS_HUMAN). + +## Work-phase map (dependency order, not effort) + +1. **wp0 (this cycle, docs-only):** inventories + 090 lock. Independent of later code. +2. **wp1 (010, later):** Cursor error mapping + 0-token `resource_exhausted` surface. Owner: `src/adapters/cursor/cursor-errors.ts`, `src/lib/errors.ts`, `src/adapters/cursor/transport-retry.ts`. +3. **wp2 (020, later):** `turnEnded` as application-complete + adapter stream-health. Owner: `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/protobuf-events.ts`. +4. **wp3 (030, later):** unknown-exec typed reply (`ExecClientThrow` + stream-close) and optional newer exec oneofs as refusals. Owner: `src/adapters/cursor/native-exec.ts`. Do not regenerate protobuf in the same cycle as error mapping. +5. **wp4 (040, later, optional):** live `GetUsableModels.maxMode` + richer catalog decode. Owner: `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/protobuf-request.ts`, `src/adapters/cursor/discovery.ts`. + +Do not implement two slices in one B. Do not start wp1 until this research cycle D-locks 090. + +## IN / OUT + +IN: this `devlog/_plan/260822_senpi_cursor_transfer/` directory. +OUT: `src/`, `tests/`, `gui/`, `docs-site/`; senpi vendored proto copy; CLI spawn of `cursor-agent`. + +## Already-have headline + +OpenCodex is not missing a Cursor provider. It already speaks `agent.v1.AgentService/Run` over Connect, answers `interactionQuery`, owns HTTP/1 `RunSSE` fallback, conversation-keyed `usedTokens` accounting, native-exec policy, and Responses-tool suspend. senpi's newer work is mostly overflow classification, turn-end close, exec-frame completeness, and a CLI fallback lane that OpenCodex deliberately does not have. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md b/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md new file mode 100644 index 0000000000..1f950e5026 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md @@ -0,0 +1,50 @@ +# 001 — OpenCodex Cursor inventory + +Research only. Local tree + explorer Helmholtz / Planck / Hypatia / Leibniz. + +## Layout + +`src/adapters/cursor/` owns the live protobuf adapter. Supporting files: + +- Transport: `live-transport.ts` (1443 lines), `transport.ts`, `transport-retry.ts`, `http1-bidi.ts`, `framing.ts` +- Request: `request-builder.ts`, `protobuf-request.ts`, `tool-definitions.ts` +- Events / usage: `protobuf-events.ts`, `checkpoint-store.ts`, `thread-continuity.ts`, `kv-store.ts` +- Exec: `native-exec.ts` + `native-exec-*.ts`, `exec-policy.ts`, `mcp-manager.ts`, `mcp-config.ts` +- Catalog: `discovery.ts`, `live-models.ts`, `effort-map.ts` +- Errors: `cursor-errors.ts` +- Generated proto: `gen/agent_pb.ts` +- OAuth: `src/oauth/cursor.ts` (not under adapters) +- Adapter entry: `src/adapters/cursor.ts` +- Tests: `tests/cursor-*.test.ts` (39 files) + +## Protocol + +OpenCodex posts `POST /agent.v1.AgentService/Run` as Connect proto, 5s `clientHeartbeat`, client version `cli-2026.07.08-0c04a8a`: + +```90:92:src/adapters/cursor/live-transport.ts +const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; +const HEARTBEAT_MS = 5_000; +``` + +HTTP/1 fallback exists: `RunSSE` + `BidiAppend` in `http1-bidi.ts:10-11`. First-frame timeout is 30s (`live-transport.ts:93`). After that, liveness is the Responses bridge stall watchdog (default 300s, `src/stall-timeout.ts:8`), kept alive by synthetic `heartbeat` events on swallowed progress frames (`live-transport.ts:1304-1309`). + +`turnEnded` maps to `finalizeTurnEvents` (`protobuf-events.ts:1327-1328`). Transport still waits for Connect EOF. If EOF arrives after assistant text without `turnEnded`, it synthesizes `done` (`live-transport.ts:1147-1150`). Client-tool Responses path **intentionally** ends turn 1 without waiting for `turnEnded` (`live-transport.ts:203-206`). + +Unknown `interactionQuery` replies empty with matching id so the server unblocks (`live-transport.ts:376-382`, issue #116). Web/exa queries are approved; askQuestion/switchMode rejected (`live-transport.ts:287-366`). + +## Auth / catalog + +Same Cursor PKCE poll as senpi: `loginDeepControl`, `auth/poll`, `exchange_user_api_key` (`src/oauth/cursor.ts:13-15`). After login, catalog uses stored tokens via `getValidAccessToken`. `GetUsableModels` is empty-body unary (`live-models.ts:12-14, 28`). Decode keeps **ids only** (`live-models.ts:115-131`). Static seed in `discovery.ts` is filtered by live ids; `stripCursorWirePrefix` at the comparison boundary (`discovery.ts:67-84`, issue #117). Effort is a static suffix table (`effort-map.ts`). `RequestedModel.maxMode` is hardcoded `false` (`protobuf-request.ts:963-966`). + +## Exec + +Known proto cases end at `writeShellStdinArgs` (`gen/agent_pb.ts:6886+`). Dispatcher: `native-exec.ts:550-609`. Default `nativeLocalExec` is **off**; only `"on"` authorizes local fs/shell/fetch (`exec-policy.ts:17-44`). Unknown exec returns `[]` to keep the stream alive (`native-exec.ts:605-609`). Responses `mcpArgs` are **not** executed locally (`live-transport.ts:226-246, 1236-1246`). Native exec emits `local_side_effect` before running so `invalid_argument` remint cannot replay (`live-transport.ts:1248-1252`). + +## Usage / overflow + +Checkpoint `usedTokens` is absolute context, not an output delta (`protobuf-events.ts:1233-1238`). Conversation-keyed cache: 200 entries / 60 minutes (`protobuf-events.ts:21-22`). Generated `TurnEndedUpdate` is empty (`gen/agent_pb.ts:3083-3085`), so billed cacheRead is not ingested. Generic `resource_exhausted` classifies as 429 unless an explicit size phrase wins (`cursor-errors.ts:131-163`). Transport retry never retries RE (`transport-retry.ts:25`). Conversation remint exists only for external-model `invalid_argument` (`src/adapters/cursor.ts:231-247`). Compaction uses an isolated conversation and does not store its checkpoints (`request-builder.ts:397, 443-444`; `src/server/responses/core.ts:2247-2249`). + +## OpenCodex-only keepers + +HTTP/1 RunSSE; interactionQuery matrix; fail-closed nativeLocalExec; Responses-tool suspend; JWT-sub multiauth; classified discovery errors; bounded blob KV / checkpoint store; `createTerminalSettler`. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md b/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md new file mode 100644 index 0000000000..90589612c0 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md @@ -0,0 +1,43 @@ +# 002 — senpi Cursor inventory + +Research only. senpi `main` SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. Explorers Pasteur / Archimedes / Plato / Ohm. + +## Layout + +Cursor is a first-class builtin provider, not an OpenCodex-style proxy adapter. + +- Provider: [packages/ai/src/providers/cursor.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts) — OAuth, empty static catalog, `fetchModels` = live `GetUsableModels` +- Run client: [packages/ai/src/api/cursor-agent.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts) (~4439 lines, Node http2) +- Lazy load: `cursor-agent.lazy.ts`; Bun static register: `cursor-agent-provider.ts` +- Catalog grouping: `packages/ai/src/cursor/catalog-grouping.ts`, `model-capabilities.ts`, `selection-descriptor.ts`, `store-migration.ts` +- OAuth: [packages/ai/src/auth/oauth/cursor.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts) +- Rotation: `packages/ai/src/api/cursor-conversation-rotation.ts` +- Overflow: `packages/ai/src/utils/overflow.ts` +- Host exec-bridge: `packages/coding-agent/src/core/cursor-exec-bridge.ts` +- CLI fallback: `packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/` +- PRs: [#905](https://github.com/code-yeongyu/senpi/pull/905) OAuth, [#910](https://github.com/code-yeongyu/senpi/pull/910) protocol, [#921](https://github.com/code-yeongyu/senpi/pull/921) CLI, [#948](https://github.com/code-yeongyu/senpi/pull/948) reasoning levels, [#1013](https://github.com/code-yeongyu/senpi/pull/1013) ANTML skip, [#1015](https://github.com/code-yeongyu/senpi/pull/1015) compact-before-rotate, [#1062](https://github.com/code-yeongyu/senpi/pull/1062) turnEnded completion + +## Protocol + +Same `AgentService/Run` Connect path, 5s client heartbeat, client version `cli-2026.07.23-e383d2b`. HTTP/2 only; ALPN-stripping proxy is fatal (no h1 fallback). `turnEnded` is the application completion signal: drain exec ≤5s, then close the client HTTP/2 stream ([cursor-agent.ts L249-254, L698-704](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L249-L254)). HTTP close without `turnEnded` is an error. Stream-health: 30s no inbound frames, 90s heartbeat/checkpoint-only. + +Unknown exec is `ExecClientThrow` + `streamClose` so the server is never left blocked. Per-exec 3s heartbeat while a handler runs (`exec-lifecycle.ts`). + +`handleServerMessage` has **no `interactionQuery` case** (open [#1026](https://github.com/code-yeongyu/senpi/issues/1026)). + +## Auth / catalog + +Same PKCE poll. Fail-fast on poll 400/401/403/410; 429 does not burn the transient budget. Catalog is fully dynamic: `models: []`, live GetUsableModels, then `normalizeCursorCatalog` grouping with `thinkingLevelMap` / `cursorReasoning` / `cursorMaxMode`. Live `maxMode` is copied onto `RequestedModel` ([reasoning-params.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20)). + +## Exec + +Host-injected `CursorExecHandlers` map frames onto senpi tools (`read`/`bash`/`edit`/`write`/`grep`/`find`/`ls` + MCP). Exec-synthesized tool calls are stamped `kCursorExecResolved` so the agent loop does not re-run them. Pi exec family (proto 45–51) is dispatched. Computer-use / canvas / subagents / conversation-search are typed refusals (PR 910). CLI lane is a **separate** spawn of official `cursor-agent -p --output-format stream-json`; tools are display-only; `--force` needs `noApprovalAcknowledgedAt`; kill switch is verbatim `enabled: false`. + +## Overflow + +0-token `resource_exhausted` is payload overflow for compact-before-rotate (`overflow.ts` `isCursorPayloadResourceExhausted`). First 0-token RE is **surfaced** so session compaction can run; later ones rotate the wire id up to 3 times (`cursor-conversation-rotation.ts`). Billed `turnEnded` cacheRead that dwarfs checkpoint `usedTokens` (>3×) is ignored ([cursor-agent.ts L3544](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3544)). ANTML text-tool recovery is skipped for `api === "cursor-agent"` (PR #1013). Compact while a Cursor Run is live is skipped (#984). Open: [#1043](https://github.com/code-yeongyu/senpi/issues/1043) compact-reload restores full toolResult bodies. + +## Deliberately not ported (senpi) + +Computer use, subagents, Cursor-managed background shells (typed refuse; OpenCodex actually implements bg shell when native exec is on), canvas, smart-mode classifier, conversation search, Kimi-K3 thinking replay, proxy tunneling (PR 910). + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md new file mode 100644 index 0000000000..c6883b3513 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md @@ -0,0 +1,35 @@ +# 003 — Protocol / transport compare + +Helmholtz + Pasteur. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. + +## Same + +Both speak `agent.v1.AgentService/Run` over HTTP/2 Connect (`application/connect+proto`, `connect-protocol-version: 1`, Bearer, `x-ghost-mode: true`, `x-cursor-client-type: cli`). Both write a 5s `clientHeartbeat`. Both rebuild `rootPromptMessagesJson` as the model prompt and treat `turns[]` as display metadata. Both implement blob KV `getBlobArgs`/`setBlobArgs`. + +OpenCodex: + +```90:92:src/adapters/cursor/live-transport.ts +const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; +const HEARTBEAT_MS = 5_000; +``` + +senpi: [cursor-agent.ts L522-547, L746-747](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L522-L547). + +## Different + +| Topic | OpenCodex | senpi | +|---|---|---| +| Client version | `cli-2026.07.08-0c04a8a` | `cli-2026.07.23-e383d2b` | +| HTTP/1 | `RunSSE` + `BidiAppend` (`http1-bidi.ts:10-11`) | HTTP/2-only; ALPN strip is fatal | +| Session header | sends `x-session-id` | does not | +| Completion | `turnEnded` finalizes mapper; transport waits for EOF; may synthesize `done` | `turnEnded` closes client HTTP/2 after ≤5s exec drain | +| Mid-turn health | 30s first-frame only; then 300s bridge stall | 30s silence / 90s heartbeat-only inside the adapter | +| Abort owner | `failAndClear` + `createTerminalSettler` | `settleH2` | +| Exec heartbeat | none (types exist) | 3s per-exec heartbeat | +| Blob store | TTL / 4096 / 64MiB | unbounded per-conversation Map | + +## Transfer suspicion + +High: close HTTP/2 on `turnEnded` (frozen turns until bridge 300s). Medium: heartbeat-only stall fail. Low: bump client version without a live probe. Do not copy senpi's unbounded blob Map. Keep OpenCodex HTTP/1 fallback. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md new file mode 100644 index 0000000000..00e50f16ad --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md @@ -0,0 +1,29 @@ +# 004 — Auth / catalog / effort / max-mode + +Planck + Archimedes. + +## Auth — ALREADY-HAVE + +Same three URLs and PKCE params (`challenge`, `uuid`, `mode=login`, `redirectTarget=cli`). + +OpenCodex `src/oauth/cursor.ts:13-15, 78-85`. senpi [oauth/cursor.ts L17-19, L123-130](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts#L17-L19). + +Delta worth a small adapt: senpi fail-fasts poll 400/401/403/410 and does not spend the transient budget on 429. OpenCodex retries any non-ok as consecutive errors up to 3 (`src/oauth/cursor.ts:121-148`). OpenCodex-only keepers: JWT `sub`/`email` multiauth, 15s refresh timeout, 429/5xx refresh retry. + +Login catalog refresh: senpi auto `fetchModels` after `/login cursor`. OpenCodex clears model cache and tells the operator to `ocx sync` (`src/oauth/index.ts:1234`, `src/oauth/login-cli.ts:95`). + +## Catalog — different-shape + +OpenCodex: static seed + live id filter + `stripCursorWirePrefix` (`discovery.ts:67-84`). Decode keeps ids only (`live-models.ts:4-6`). Empty 0-byte GetUsableModels body is a Bun HTTP/2 requirement (`live-models.ts:12-14`). + +senpi: no static baseline; live GetUsableModels is the catalog; grouping produces `thinkingLevelMap` / `cursorReasoning` / `legacyAliases` ([providers/cursor.ts L10-17](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts#L10-L17), [catalog-grouping.ts L19-31](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/cursor/catalog-grouping.ts#L19-L31)). Decode keeps `maxMode`, display name, `thinkingDetails` ([cursor-agent.ts L4354-4369](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L4354-L4369)). + +## Effort / max-mode + +OpenCodex flattens Codex effort onto a static suffix table (`effort-map.ts:96-108`). Grok Fast is parameterized (`request-builder.ts:182-204`). `RequestedModel.maxMode` is always `false` (`protobuf-request.ts:963-966`; the 934-937 window is debug logging, not maxMode). + +senpi copies live `cursorMaxMode` onto the wire ([reasoning-params.ts L8-20](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20)). Family-specific parameters (Claude thinking/context/effort, GPT extra-high, etc.) come from a captured AvailableModels capability table, not from GetUsableModels fields. + +## Transfer suspicion + +Medium-high: honor live `maxMode` instead of hardcoding false (proto field already exists at `gen/agent_pb.ts:2617`). Medium: fail-fast OAuth poll. Low/product: replace static seed with fully dynamic catalog (OpenCodex still needs logged-out fallback and `auto-{cost,balance,intelligence}` router ids). Do not copy senpi's 204-id alias JSON wholesale. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md new file mode 100644 index 0000000000..f196a95bfb --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md @@ -0,0 +1,29 @@ +# 005 — Exec / interactionQuery / tool pairing + +Hypatia + Plato + Pasteur. + +## Architecture mismatch (do not ignore) + +senpi is a **host**. Exec frames map onto senpi tools via `CursorExecHandlers`, then the agent loop skips `kCursorExecResolved` blocks ([cursor-exec-bridge.ts L1-16](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/cursor-exec-bridge.ts#L1-L16), [block-symbols.ts L40-49](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/block-symbols.ts#L40-L49)). + +OpenCodex is a **Responses proxy**. Exec either runs locally inside the adapter (only if `nativeLocalExec: "on"`) or is rejected. Codex-owned tools travel as `opencodex-responses` MCP and are **not** executed on the exec channel (`live-transport.ts:226-246`). Copying senpi's host-tool bridge would invert OpenCodex's trust model. + +## Frames + +OpenCodex known cases end at `writeShellStdinArgs` (`gen/agent_pb.ts:6886+`). Dispatcher `native-exec.ts:550-609`. Default policy off (`exec-policy.ts:17-44`). + +senpi additionally dispatches Pi family 45–51 and answers newer oneofs with typed refusals (mcpState, hooks, subagents, canvas, conversation search). Unknown/unset: `ExecClientThrow` + `streamClose` ([cursor-agent.ts L1288-1316](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1288-L1316)). OpenCodex unknown: empty `[]` (`native-exec.ts:605-609`, #116). That is the stall class senpi refused. + +OpenCodex-only: real background shell / fetch / optional computer-use when native exec is on. senpi refuses those. + +## interactionQuery + +OpenCodex answers immediately (`live-transport.ts:287-382, 1256-1269`): createPlan success; ask/switch reject; web/exa approve; setupVm + unknown empty. senpi has **no** interactionQuery branch ([cursor-agent.ts L922-946](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L922-L946), issue #1026). Do not copy senpi here. + +## Pairing / double-exec + +OpenCodex: `local_side_effect` before native exec (`live-transport.ts:1248-1252`); `completedToolCalls` for Responses mapper idempotency (`protobuf-events.ts:1052-1056`). senpi: `kCursorExecResolved` so the **agent loop** does not re-run host tools. Different layer. Only needed if OpenCodex starts synthesizing native exec as Codex-visible tool calls. + +## Transfer suspicion + +High: unknown-exec typed reply + stream-close (without enabling local fs). Medium: proto refresh to name Pi/mcpState/hook frames **as typed refusals**, not as implementations. Reject: host-tool bridge, enabling nativeLocalExec by default, copying senpi's missing interactionQuery. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md new file mode 100644 index 0000000000..91887fd0f3 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md @@ -0,0 +1,40 @@ +# 006 — Stream completion / usage / overflow / rotation + +Leibniz + Ohm. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. + +## usedTokens — ALREADY-HAVE + +Both treat checkpoint `usedTokens` as absolute conversation window, not additive output. + +OpenCodex `protobuf-events.ts:1233-1238`. senpi [cursor-agent.ts L3566-3582](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3566-L3582). OpenCodex tests lock 10000→10300 not 20300 (`tests/cursor-protobuf-events.test.ts`). + +OpenCodex cache: 200 entries / 60 minutes (`protobuf-events.ts:21-22`). Older memory said 30m/256; current code wins. + +## cacheRead — senpi-only billed split + +senpi reads billed `turnEnded` fields and drops cacheRead when `cacheRead > liveUsed * 3` ([cursor-agent.ts L3516-3547](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3516-L3547); [cursor-usage.test.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/test/cursor-usage.test.ts)). Compact threshold uses local estimate if billed > 8× and estimate ≥ 50k. + +OpenCodex generated `TurnEndedUpdate` is `{}` (`gen/agent_pb.ts:3083-3085`), so billed cacheRead cannot spike totals. Do not add billed fields without the 3×/8× guards. Live wire still emitting those int64s is **unverified** this cycle (client versions differ). + +## 0-token resource_exhausted — inverted + +OpenCodex: generic RE is 429 unless an explicit size phrase wins (`cursor-errors.ts:131-163`; `tests/cursor-errors.test.ts:15-17` expects bare `Connect error resource_exhausted: Error` → rate limit). Retry layer never retries RE (`transport-retry.ts:25`). + +senpi: 0-token RE is payload overflow for compact-before-rotate (`overflow.ts` `isCursorPayloadResourceExhausted`, [L211-222](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L211-L222)). First failure is **surfaced** so session compact can run; later ones rotate wire id ≤3 ([cursor-conversation-rotation.ts L34-46](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-conversation-rotation.ts#L34-L46), [cursor-agent.ts L789-812](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L789-L812)). Stale senpi comment still says 0-token RE is rate-limit; code does the opposite. + +OpenCodex remint is only external-model `invalid_argument` (`src/adapters/cursor.ts:231-247`). Compaction is client-driven and isolated (`request-builder.ts:397, 443-444`). Architectural bound: OpenCodex cannot copy senpi `AgentSession._runPrePromptCompaction`. Transfer is **HTTP mapping** so Codex compact can fire, plus optional remint after that, not an in-adapter compact loop. + +## turnEnded hang — senpi newer + +#1062: Cursor can leave HTTP/2 open after content is done. senpi closes the client stream on `turnEnded`. OpenCodex waits for EOF / 300s bridge stall. First-frame 30s is not a mid-turn health watchdog. + +OpenCodex-only: synthesize `done` on clean EOF after assistant text without `turnEnded` (`live-transport.ts:1147-1150`). senpi fails that case. Comment/test tension: `tests/cursor-eof-terminal.test.ts` vs hardening tests vs transport `settleFinish`. + +## ANTML / interactionQuery + +ANTML skip is senpi-only because senpi has Claude-name text-tool recovery. OpenCodex has zero ANTML hits — already-have by absence. interactionQuery is OpenCodex-only (senpi gap #1026). + +## #1043 toolResult reload + +senpi compact reloads full jsonl bodies (still open). OpenCodex truncates toolResult blobs for **external-model replay budget** 512KiB / 192 roots (`protobuf-request.ts:64-72, 140-143`), not as a post-compact native admission pass. Medium residual if native-model full replay after Codex compact still ships verbatim tool results. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md b/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md new file mode 100644 index 0000000000..fcc97cf194 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md @@ -0,0 +1,20 @@ +# 007 — CLI fallback lane + +Plato. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. PR [#921](https://github.com/code-yeongyu/senpi/pull/921). + +## What senpi added + +`cursor-cli-oauth` is a **documented fallback**, never a replacement for native `cursor` ([AGENTS.md L1-5](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/AGENTS.md#L1-L5)). + +It spawns official `cursor-agent -p --output-format stream-json --stream-partial-output --trust` ([spawn-args.ts L18-34](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/spawn-args.ts#L18-L34)). CLI tools are display-only. `--force` requires `noApprovalAcknowledgedAt` ([guardrails.ts L136-154](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/guardrails.ts#L136-L154)). Kill switch: verbatim `enabled: false` outranks stored accounts. Implicit fallback is refused while force-ack is pending (`index.ts:77-85`). File-store HOMEs, `AGENT_CLI_CREDENTIAL_STORE=file`, 130 KB prompt cap, process-group kill. senpi remains context owner for usage numbers. + +## What OpenCodex has + +Native protobuf only. OAuth comment: no dependency on a local Cursor IDE/CLI (`src/oauth/cursor.ts:1-4`). Repo `rg` has no `cursor-agent` spawn, `stream-json`, or `cursor-cli-oauth`. Native-exec kill is `nativeLocalExec` default off (`exec-policy.ts:17-45`) — different layer. + +## Transfer class + +**REJECT for OpenCodex core.** OpenCodex is a Codex/Claude proxy. Spawning Cursor's own agent CLI would fork tool execution out of Codex sandbox/approvals, add a binary dependency, and spend Cursor quota through a second harness. If a fallback is ever wanted, it is a separate opt-in product surface (NEEDS_HUMAN), not an adapter default. + +Do not confuse this with native protobuf hardening. Native-first is the senpi recommendation too. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md b/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md new file mode 100644 index 0000000000..1b4c080938 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md @@ -0,0 +1,52 @@ +# 090 — Transfer verdict + +Locked from explorer reports + local reads. senpi `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. No production code in this cycle. + +Class keys: ADOPT (port the mechanism), ADAPT (same idea, OpenCodex-shaped), REJECT (wrong product/trust model), ALREADY-HAVE, NEEDS_HUMAN (policy), UNSAFE (do not recommend). + +## Table + +| ID | Mechanism | Class | OpenCodex owner | senpi source | Residual risk | +|---|---|---|---|---|---| +| T01 | Bare 0-token `resource_exhausted` mapped as 429 | **ADAPT** | `src/adapters/cursor/cursor-errors.ts:131-163`, `src/lib/errors.ts`, `tests/cursor-errors.test.ts:15-17` | [overflow.ts L211-222](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L211-L222), issues #1009/#1036 | Must not reclassify quota RE as overflow. Codex compact must actually fire; if not, remint is a second step. | +| T02 | Surface-first then rotate conversationId | **ADAPT** | `src/adapters/cursor.ts:231-247` (today only external invalid_argument) | [cursor-conversation-rotation.ts L34-46](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-conversation-rotation.ts#L34-L46) | Do not persist unbounded maps. Cap + migrate usage cache via existing `rekey`. | +| T03 | Close HTTP/2 on `turnEnded` after exec drain | **ADOPT** | `src/adapters/cursor/live-transport.ts:1132-1154`, `protobuf-events.ts:1327-1328` | [cursor-agent.ts L249-254, L698-704](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L249-L254) PR #1062 | Must preserve Responses client-tool path that **intentionally** ends without turnEnded (`live-transport.ts:203-206`). | +| T04 | Adapter heartbeat-only stall fail (30s/90s) | **ADAPT** | `live-transport.ts:92-93` first-frame only; `src/stall-timeout.ts:8` 300s | [cursor-agent.ts L592-610](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L592-L610) | Do not fight synthetic progress heartbeats that keep the bridge alive. Scope to inbound-frame silence, not "no assistant text". | +| T05 | Unknown exec empty `[]` vs throw+close | **ADAPT** | `native-exec.ts:605-609` | [cursor-agent.ts L1288-1316](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1288-L1316) | Empty reply was the #116 stream-kill fix. Prefer typed `ExecClientThrow` + stream-close **without** re-throwing into `failAndClear`. Live stall vs empty is unverified. | +| T06 | Live `GetUsableModels.maxMode` on the wire | **ADAPT** | `live-models.ts:115-131` decode keeps ids only; `gen/agent_pb.ts:2617` is catalog `ModelDetails.maxMode`; wire field is `RequestedModel.maxMode` at `gen/agent_pb.ts:2665-2667`; hardcode `protobuf-request.ts:963-966` | [reasoning-params.ts L8-20](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20) | Product: 1M windows / quota. Needs a live probe before claiming user-visible gain. Keep static seed + auto router ids. | +| T07 | OAuth poll fail-fast 400/401/403/410 | **ADAPT** | `src/oauth/cursor.ts:121-148` | [oauth/cursor.ts L165-178](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts#L165-L178) PR #905 | Small. Keep OpenCodex refresh retry / JWT accountId. | +| T08 | Per-exec 3s heartbeat | **ADAPT** | `ExecClientHeartbeat` exists in `gen/agent_pb.ts`; stream-close bytes at `native-exec-common.ts:41-49`; no heartbeat writer in `native-exec.ts` | [exec-lifecycle.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/exec-lifecycle.ts) | Only if long native-exec stays enabled. Default native exec is off. | +| T09 | Billed turnEnded cacheRead 3× clamp | **ADAPT** (only with proto decode) | `TurnEndedUpdate` is `{}` `gen/agent_pb.ts:3083-3085` | [cursor-agent.ts L3516-3547](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3516-L3547) PR #985 | Do not add billed fields without the clamp. Live wire unverified vs OCX client version. | +| T10 | Newer exec oneofs as typed refusals | **ADAPT** | `gen/agent_pb.ts:6886+` oneof ends at `writeShellStdinArgs`; dispatcher `native-exec.ts:550-609` | [cursor-agent.ts L1655-2010](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1655-L2010) PR #910 | Proto regen is its own unit. Until then, T05 covers unknown frames. Do not implement Pi tools in the proxy. | +| T11 | Host-tool exec-bridge onto Codex tools | **REJECT** | `native-exec.ts` + `exec-policy.ts:17-44` fail-closed | [cursor-exec-bridge.ts L1-16](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/cursor-exec-bridge.ts#L1-L16) | Wrong architecture. OpenCodex already surfaces Responses tools; native fs default-off is the trust gate. | +| T12 | `cursor-agent` CLI fallback lane | **REJECT** (core) / **NEEDS_HUMAN** (optional product) | none; `src/oauth/cursor.ts:1-4` | [cursor-cli-oauth/AGENTS.md](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/AGENTS.md) PR #921 | Binary dep, `--force` spends Cursor tools outside Codex sandbox. | +| T13 | Fully dynamic catalog, drop static seed | **REJECT** | `discovery.ts:76-88` seed filter; `discovery.ts:90-104` router ids; `src/codex/catalog/provider-fetch.ts:1197` live gather | [providers/cursor.ts L10-17](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts#L10-L17) | OpenCodex needs logged-out catalog and `auto-*` router models. T06 is the live-field adapt. | +| T14 | thinkingLevelMap / 204-id grouping | **REJECT** for now | `effort-map.ts:96-108` static tiers; `request-builder.ts:187-204` suffix flatten | [catalog-grouping.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/cursor/catalog-grouping.ts) PR #948 | Codex picker already maps effort. Revisit only if live ids stop matching suffixes. | +| T15 | ANTML skip on cursor-agent | **ALREADY-HAVE** (by absence) | no ANTML in `src/` | [tool-call-middleware/index.ts L48-54](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/tool-call-middleware/index.ts#L48-L54) PR #1013 | Only if OCX later adds Claude-name text-tool recovery on Cursor models. | +| T16 | interactionQuery replies | **ALREADY-HAVE** (OpenCodex ahead) | `live-transport.ts:287-382` | missing; [#1026](https://github.com/code-yeongyu/senpi/issues/1026) | Do not copy senpi. | +| T17 | Absolute `usedTokens` cache | **ALREADY-HAVE** | `protobuf-events.ts:1233-1238` | [cursor-agent.ts L3566](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3566) | Keep. | +| T18 | Compact isolation / skip mid-run compact | **ALREADY-HAVE** (different-shape) | `request-builder.ts:397, 443-444`; `responses/core.ts:2247-2249` | [agent-session.ts L1293-1296](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/agent-session.ts#L1293-L1296) #984 | Keep OCX isolated-conversation approach. | +| T19 | HTTP/1 RunSSE fallback | **ALREADY-HAVE** (OpenCodex-only) | `http1-bidi.ts:10-11` | [cursor-agent.ts L378-381](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L378-L381) | Keep. | +| T20 | Native-app / Safe Storage patching | **UNSAFE** | n/a | n/a | Out of scope. Prior ocx-cursor probe already forbade this. | +| T21 | Unofficial Cursor protocol ToS | **NEEDS_HUMAN** | whole adapter | whole provider | Both projects already ship it. No new disclosure in this unit. | +| T22 | Copy senpi protobuf / unbounded blob maps | **REJECT** | bounded KV/checkpoint (`native-exec.ts:81-92`, `checkpoint-store.ts:30`) | [cursor-agent.ts L314](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L314); [#1024](https://github.com/code-yeongyu/senpi/issues/1024) | Keep OCX bounds. | +| T23 | Overflow compact `keepRecentTokens: 0` | **REJECT** for adapter | Codex owns compact | [overflow.ts L244-251](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L244-L251) | Only relevant if Codex compact keeps a large tail; that is a Codex-side setting, not ocx Cursor. | +| T24 | Fail EOF without `turnEnded` | **ADAPT** (careful) | `live-transport.ts:1147-1150` synthesizes done | [cursor-agent.ts L477-478](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L477-L478) | Conflicts with OCX client-tool suspend and some hardening tests. Fold into T03, do not land as a blanket fail. | + +## Recommended later implementation order + +Matches `000_plan.md` wp1–wp4: + +1. T01 error mapping (highest user-visible: overflow vs 429). +2. T03 + T04 turn-end / stream health (protocol hang). +3. T05 unknown-exec typed reply; T10 only with a dedicated proto unit. +4. T06 maxMode + T07 poll fail-fast (catalog/auth polish). + +Do not start T12. Do not start T11. + +## Residual unknowns (not blockers for this research lock) + +- Whether live `api2.cursor.sh` still emits billed `turnEnded` int64s against OCX client `cli-2026.07.08-0c04a8a`. +- Whether mapping 0-token RE to overflow/400 makes Codex auto-compact, or still needs remint (T02). +- Whether empty unknown-exec replies currently stall modern Pi frames on OCX's proto. +- Native-model toolResult size after Codex compact (#1043 analogue). diff --git a/devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md b/devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md new file mode 100644 index 0000000000..251529c72d --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md @@ -0,0 +1,71 @@ +# 100 — Stabilization round 2 (research + implementation loop) + +Continuation of the 090 verdict. T01/T03/T05 landed (#2320/#2321/#2322). This +round re-inventories what REMAINS transferable from senpi and +yelixir-dev/cursor-ai-proxy-bridge (and any other public Cursor bridge found +during the swarm), locks a new decade-doc roadmap (110+), then implements the +top candidates as separate cycles, each pushed to dev. + +## Inputs + +- origin/dev head at research time: `525568652` (weighted credential router, unwired). +- Selected remaining 090 verdict candidates: T02 (conversation rotation), T04 + (heartbeat stall), T06 (maxMode), T07 (OAuth poll fail-fast), T09 (cacheRead + clamp), T24 (EOF-without-turnEnded fold into T03). Unlanded ADAPT rows T08 + (per-exec heartbeat — conditional on long native-exec staying enabled; + default off, so deferred unless research contradicts) and T10 (dedicated + proto unit prerequisite) are dispositioned in 190, not silently dropped. +- New-in-dev artifacts needing follow-up regardless of senpi: #2334 + CursorCredentialRouter is dead code (only tests import it); cursorH2Pool has + no shutdown hook wiring. + +## Security / ToS boundary (binding, per AGENTS.md) + +- No pre-disclosure security material in this public devlog: if research + surfaces an unfixed weakness (in Cursor, senpi, or OpenCodex), the analysis + goes to `.tmp/` scratch and the devlog records only a neutral + "handled out-of-band" pointer once resolved. +- Excluded transfer classes regardless of source value: leaked/private + artifacts, credential extraction, auth bypass, Safe Storage / native-app + patching (090 T20 stays UNSAFE), live account mutation. +- ToS/product-policy questions (e.g. new endpoints whose use may be + policy-sensitive) are NEEDS_HUMAN, not merely "needs live probe". +- Reference clones live in gitignored scratch (`.tmp/chase/`), matching the + `devlog/_chase/` license rule: third-party source never enters this + repository's history. + +## Research lanes (Luna swarm, candidates only — main agent proves) + +1. senpi delta since a5eed44536f3 (commits/releases): new Cursor mechanisms. +2. senpi issues/PRs: open stability reports naming Cursor adapter defects. +3. yelixir-dev/cursor-ai-proxy-bridge full file inventory beyond + h2-session-pool.ts / credentials.ts. +4. Other public Cursor-protocol bridges/proxies (GitHub sweep). +5. Cursor upstream changes (client version strings, api2 endpoints, protocol + deprecations) that could break the adapter soon. +6. Local-clone deep read (main agent, .tmp/chase/senpi + + .tmp/chase/cursor-ai-proxy-bridge): git history, issues-referenced diffs, + and rationale not visible in file inventories. +7. OpenCodex's own Cursor issue/PR/test delta on GitHub since 090 lock, so + locally-reported regressions rank alongside external candidates. + +## Verification lane (sol-medium, read-only) + +Audit backlog items (a)-(f) from the goal objective against origin/dev head +with file/line evidence: wired-or-dead status of #2334, shutdown hook absence, +T04/T06/T07/T24 current state in live-transport.ts / oauth/cursor.ts / +live-models.ts. Every NEW candidate from lanes 1-7 gets the same falsification +pass against the current tree before it may enter a decade doc — no candidate +is roadmapped on snippet evidence alone. + +## Output contract + +- Decade docs 110, 120, ... — one per implementation cycle, diff-level + (target files, function names, test names, expected diff shape). +- 190_roadmap_lock.md — ranked order, rejected/deferred candidates with + reasons, NEEDS_HUMAN items (live-probe-only) explicitly marked. +- No production code in this cycle. +- Gate: implementation cycles may not start until 190 is locked (the D of + this docs-only cycle). "Pushed to dev" in the header describes those later + cycles, each separately gated by typecheck + full tests; the docs-only + cycle pushes documentation only. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md b/devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md new file mode 100644 index 0000000000..ef82190220 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md @@ -0,0 +1,67 @@ +# 110 — Inbound stream-health watchdog (T04, senpi #1062 second half) + +## Why now + +OpenCodex issue #2210 reports Cursor/Grok turns dying with +`upstream_stall_timeout` after a silent stream — the 300s bridge default +(`src/stall-timeout.ts:8`) is the only guard after the first frame. senpi +PR #1062 pairs the turnEnded close (already landed as #2321) with an +inbound-frame watchdog we did NOT take: 30s of total inbound silence, or 90s +of heartbeat/checkpoint-only traffic, fails the turn instead of waiting for +the bridge. + +## Current state (verified 525568652) + +- `live-transport.ts:93` `CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000` — armed + once, cleared permanently by the FIRST raw chunk (`onData` calls + `clearFirstFrameTimer()` unconditionally, live-transport.ts:1133). +- The 5s HEARTBEAT_MS at :92 is OUTBOUND client traffic, not a detector. +- No transport-level watchdog exists after the first chunk; sol audit lane + confirmed GAP (c) with file/line refs. + +## Design (ADAPT, not copy) + +senpi resets `lastInboundFrameAt` on every decoded frame and +`lastMeaningfulFrameAt` only when the frame is not liveness-only +(heartbeat / conversationCheckpointUpdate), then arms one timer at +`min(lastInbound+30s, lastMeaningful+90s)` (cursor-agent.ts:589-673 in the +.tmp/chase clone). OpenCodex differences to respect: + +- Our decode path is `handleFrame` inside live-transport.ts, protobuf event + mapping in protobuf-events.ts; liveness classification must happen where + the AgentServerMessage case is visible, not on raw chunks — raw-chunk + resets would let TLS keepalive noise defeat the watchdog. +- Client-tool suspend (live-transport.ts:203-206) intentionally ends without + turnEnded: the watchdog must disarm when the transport is settling or a + client-tool suspend is in progress, mirroring the #2321 grace-timer guards + (expectedClose). +- Long native-exec turns emit synthetic progress; those count as inbound + frames already (they arrive as real server frames), so no special case — + 090's warning about "not fighting synthetic progress heartbeats" is + satisfied by the meaningful/liveness split. +- Timeout action: fail the turn through the SAME error path a transport + error takes today (failAndClear with a typed message naming the stall + class), so bridge mapping and tests stay uniform. + +## Diff shape + +- `src/adapters/cursor/live-transport.ts`: two constants + (`CURSOR_STREAM_SILENCE_FAIL_MS = 30_000`, + `CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS = 90_000`), fields + `lastInboundFrameAt` / `lastMeaningfulFrameAt` / `streamHealthTimer`, + arm/reset/disarm helpers; reset hooks in the decoded-frame path; disarm in + finalize/cleanup paths alongside firstFrameTimer/turnEndedCloseTimer. +- Optional input knobs on CursorTransportFactoryInput mirroring + `firstFrameTimeoutMs` for tests. +- Tests: `tests/cursor-stream-health.test.ts` — (1) silent stream after + first frame fails at ~30s (fake timers); (2) heartbeat-only stream + survives 30s but fails at 90s; (3) meaningful frames keep resetting both; + (4) client-tool suspend path never trips the watchdog; (5) turnEnded + disarms it. + +## Risks + +- False positives on genuinely slow models: thresholds are senpi-live-tested + but our traffic mix differs; keep knobs overridable and document defaults. +- Interaction with #2307 clean-terminal settle: watchdog must check the + settler state before firing (same guard the grace timer uses). diff --git a/devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md b/devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md new file mode 100644 index 0000000000..883aac9aaf --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md @@ -0,0 +1,51 @@ +# 120 — Small hardening pair: OAuth poll fail-fast (T07) + H2 pool shutdown + +Two independent, low-risk fixes small enough to share one cycle; neither +depends on 110. + +## 120a — OAuth poll fail-fast on terminal statuses (T07) + +### Current state (verified 525568652) + +`src/oauth/cursor.ts:108-149` `pollCursorAuth`: 404 = pending, 200 = done, +EVERY other status throws into the generic catch and retries until +3 consecutive errors. A denied/expired login (400/401/403/410) costs three +extra round-trips and surfaces as "Too many consecutive errors" instead of +the real reason. senpi oauth/cursor.ts L165-178 (PR #905) fails immediately +on 400/401/403/410. + +### Diff shape + +- `src/oauth/cursor.ts`: inside the status dispatch, add + `if ([400, 401, 403, 410].includes(response.status)) throw new CursorAuthTerminalError(...)` + where the error carries the status and is NOT retried by the catch block + (rethrow when `err instanceof CursorAuthTerminalError`). +- Keep 5xx/network on the existing 3-strike retry path (OpenCodex keeps its + refresh retry / JWT accountId handling — 090 T07 note). +- Tests: extend `tests/cursor-oauth.test.ts` — 401 fails on FIRST attempt + with status in message; 500 still retries 3x; 404→200 still succeeds. + +## 120b — cursorH2Pool shutdown registration + +### Current state + +`cursorH2Pool.shutdown()` (`src/adapters/cursor/h2-pool.ts:41`) has no +caller. The core-owned seam exists: `src/lib/optional-shutdown-hooks.ts:32` +registry, invoked by `src/server/lifecycle.ts:454`. Lab registers teardown +at activation (orchestrator.ts:109). The seam's hook contract must be +checked: if it is sync-only, register `() => { void cursorH2Pool.shutdown(); }` +or extend the seam if it already awaits promises (verify before coding). + +### Diff shape + +- Registration at the point the pool first activates — lazily inside + `h2-pool.ts` on first `request()` (keeps core free of adapter imports, + matching the optional-subsystem doctrine) via + `registerOptionalShutdownHook("cursor-h2-pool", ...)`. +- Also correct the pool doc comment: it claims "GetUsableModels / Run + requests" reuse, but the Run path dials its own session + (live-transport.ts:928); comment must say discovery-only until Run-path + integration is a separate, deliberate cycle (deferred — see 190). +- Tests: `tests/cursor-h2-pool.test.ts` (or extend existing) — after + registration, invoking the registered hook closes sessions (pool.size 0) + and is idempotent. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md b/devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md new file mode 100644 index 0000000000..207f50978d --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md @@ -0,0 +1,72 @@ +# 190 — Round-2 roadmap lock + +Locked from: 5 Luna research lanes (senpi delta, senpi issues, yelixir +inventory, other bridges, upstream), local clones under .tmp/chase/ (senpi +@041bb5e64, cursor-ai-proxy-bridge @main), sol read-only code audit of +origin/dev 525568652, and OpenCodex open Cursor issues (#1527, #2210, #2300, +#2305). + +## Implementation order (this loop) + +1. **110 — inbound stream-health watchdog (T04)**. Directly addresses open + issue #2210 (silent stream → upstream_stall_timeout at 300s). Verified + GAP: only a first-frame timer exists (live-transport.ts:93,1133). senpi + constants live-verified in clone (cursor-agent.ts:250-252, 589-673). +2. **120 — OAuth poll fail-fast (T07) + cursorH2Pool shutdown hook**. + Verified GAPs: cursor.ts:117-147 retries terminal 4xx thrice; + h2-pool.ts:41 shutdown() has no caller; hook seam is sync-only + (optional-shutdown-hooks.ts:23) so the registration wraps the async + shutdown in a void fire-and-forget. + +## Deferred / rejected this round (with reasons) + +- **#2334 CursorCredentialRouter wiring — NEEDS_HUMAN.** Natural seam is the + OAuth snapshot-selection boundary (oauth/index.ts:463 → + responses/core.ts:2615), but wiring weighted rotation there overrides the + user's explicit activeAccountId choice. That is a product decision + (multi-account rotation semantics), not a stabilization patch. Until + decided, the module stays test-covered but unwired; its doc comment + already says "complements" rather than "replaces". +- **H2 pool Run-path integration — deferred.** Run streams are long-lived + bidi; pooling them changes lifecycle/EOF semantics that #2307/#2321 just + stabilized. Discovery-only stays. 120b fixes the overclaiming comment. +- **T02 conversation rotation — deferred.** senpi #998 persists rotated ids + under its own agent dir; OpenCodex equivalent needs checkpoint-store + migration via existing rekey and evidence that Codex compact does not + already recover (090 residual unknown still unresolved; #1527 may be this + class — needs a live reproduction first). +- **T06 maxMode — deferred (live probe).** GAP confirmed (hardcoded false, + protobuf-request.ts:970; discovery drops ModelDetails.maxMode, + live-models.ts:116), but 090 requires a live probe to show user-visible + gain and billing semantics before flipping a wire flag. +- **T08 per-exec heartbeat — deferred.** Long native exec remains + default-off; senpi's 3s ExecClientHeartbeat only matters with it enabled. +- **T09 cacheRead clamp — deferred.** Needs live billed turnEnded int64 + evidence (090 residual unknown). +- **T10 protobuf regen — deferred.** Requires a dedicated proto unit per + 090; touching gen/ ad hoc is not stabilization. +- **senpi #1020 suffix-alias — NOOP for OpenCodex.** Our effort-map already + flattens suffix variants (090 T14 kept static tiers; request-builder + suffix flatten at :187-204 on the audited head). +- **senpi #1016 stop-with-pending-tools, #1002 exec run ownership — out of + adapter scope here.** Both live in senpi's agent loop; OpenCodex's + analogues are the bridge/Responses layer. Issue #2305 (tool-call-like + text to Pi on client-tool continuation) is the closest local symptom and + deserves its own unit with a reproduction, not a blind port. +- **yelixir retry.ts / auto-runtime failover — partially rejected.** The + transport-code retry table overlaps cursor-errors.ts mapping already + landed (T01). The API→CLI backend failover is a product architecture + OpenCodex does not have (no CLI backend); single useful residue is the + non-retryable Cursor errorType detail sniffing, folded as a candidate + into a future cursor-errors extension if live reports justify it. +- **cursor/sdk-bridge (official SDK) — tracked, not actioned.** A future + migration study unit; policy-sensitive surface questions are NEEDS_HUMAN + per the 100 boundary. +- **api2direct host migration reports — watch only.** Forum-level evidence, + no reproducible breakage against our pinned client version yet. + +## Gate + +This lock is the D of the docs-only cycle. Implementation cycles 110 → 120 +follow, one decade doc per PABCD cycle, each gated by focused tests + +typecheck + full suite before its dev push. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md b/devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md new file mode 100644 index 0000000000..4b896ecf6c --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md @@ -0,0 +1,58 @@ +# 200 — Round-3 live-probe plan + +Round 2 (100-190) landed T04/T07/shutdown. The 190 lock deferred five rows +for lack of live evidence (T06 maxMode, T02 rotation, #2305 external +continuation, client version watch, T09 cacheRead clamp); T08/T10 stay +deferred for non-live reasons and #2334 stays NEEDS_HUMAN by design. A live +Cursor account now exists on the probe host (macmini, ocx preview), so this +cycle buys the evidence. + +## Probes + +1. **P-1 maxMode (T06).** Dump GetUsableModels with full ModelDetails — + which models report maxMode=true, and what contextTokenLimit pairs with + it. Compare a Run with RequestedModel.maxMode=true vs false on one + maxMode-capable model: does the server accept it, and does the reported + context window / usage change? Wire flag only lands if this shows a real + user-visible gain. +2. **P-2 rotation (T02 / #1527 suspect).** Drive a conversation toward the + bare 0-token resource_exhausted shape (large-context turns on a pinned + conversationId). If the server pins the rejection to the conversationId + (fresh id succeeds with identical payload), T02 rotation is justified; + implement bounded rotation + checkpoint rekey. If not reproducible within + quota bounds, record and keep deferred. +3. **P-3 issue #2305.** Reproduce the client-tool continuation returning + tool-call-like assistant text to Pi: drive a client-tool turn through the + external continuation path and capture what text frames come back. + Root-cause lives in rootPromptMessages / userMessageAction continuation + (the a69d291fb fix covered native Auto; #2305 is the external path). +4. **P-4 client version.** GetUsableModels + one Run with the current pinned + cli-2026.07.08-0c04a8a vs a newer senpi-observed string + (cli-2026.07.23-e383d2b): any catalog or behavior delta? Bump only if + probe shows the new string is accepted and changes nothing adverse. +5. **P-5 billed usage / cacheRead (T09).** Capture the billed turnEnded + usage int64s (inputTokens / outputTokens / cacheRead*) from the SAME live + Runs P-1 and P-4 already make (no extra quota): decode and record whether + cacheRead exceeds 3x input the way senpi's clamp assumes, and whether our + protobuf-events usage mapping already reports these fields sanely. Verdict + IMPLEMENT (clamp justified) / NOOP (values sane, clamp unnecessary) / + BLOCKED (fields absent on this plan tier). + +## Probe hygiene (binding, extends doc 100 boundary) + +- All transcripts REDACTED before entering devlog: no bearer tokens, no + account ids, no email, no checksum headers. Raw dumps stay in .tmp/ on the + probe host and are deleted after the docs lock. +- Quota respect: P-2 large-context attempts are capped (<= 5 runs); if the + account rate-limits, stop and record BLOCKED for that probe. +- No Safe Storage access, no client patching, no endpoints beyond what the + adapter already ships (Run, GetUsableModels, RunSSE fallback). + +## Outputs + +- 210_maxmode.md, 220_rotation.md, 230_issue2305.md, 240_client_version.md — + each with verdict IMPLEMENT / NOOP / BLOCKED / NEEDS_HUMAN and, for + IMPLEMENT, diff-level shape. +- 250_billed_usage.md — P-5 verdict for T09 (same contract). +- 290_round3_lock.md — ranked implementation order + updated senpi + superiority verdict. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md b/devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md new file mode 100644 index 0000000000..0288e6b34e --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md @@ -0,0 +1,37 @@ +# 210 — P-1 maxMode probe (T06) + +## Evidence (macmini live, 2026-08-22, redacted) + +- GetUsableModels decoded: 204 entries; ModelDetails keys = + modelId, displayModelId, displayName, displayNameShort, aliases, maxMode. +- maxMode=true on exactly 28 ids — ALL of them opus "-fast" variants + (claude-opus-5-*-fast, claude-opus-4-8-*-fast, claude-opus-4-7-*-fast). + No contextTokenLimit field is present in this response shape. +- Run A/B on claude-opus-4-7-low-fast, tiny prompt: + - RequestedModel.maxMode=false -> bare Connect resource_exhausted. + - RequestedModel.maxMode=true -> same bare resource_exhausted. + The server ACCEPTED the flag both ways (no invalid_argument); the model is + plan-gated for this account regardless. + +## Verdict: BLOCKED (plan tier) — WITHDRAWN by re-probe (see below) + +Original interpretation: the account cannot run -fast at all. Wire flag +stayed hardcoded false pending an entitled account. + +## Correction (same-day re-probe, supersedes the interpretation above) + +A follow-up probe with a different tier disproved the entitlement story: +- claude-opus-4-8-high-fast -> SUCCESS ("FP-OK"); BOTH maxMode arms succeed. +- claude-opus-4-7-low-fast -> bare resource_exhausted persists + (tier-specific; cause unknown — not account-wide). +- claude-opus-4-7-fast (bare) -> not_found (wire has only suffixed forms). +The original probe sampled ONLY 4-7-low-fast and over-generalized. -fast IS +callable on this account; maxMode therefore IS provable — the deciding +probe is 310 (big-context A/B). Catalog repair: 300. + +## Side finding (feeds 260) + +A TINY prompt on a plan-gated model returns the same bare 0-token +resource_exhausted shape that #2320 (T01) now classifies as CONTEXT OVERFLOW. +Live proof that bare RE != always overflow: entitlement rejections share the +shape. See 260_re_classification_refinement.md. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md b/devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md new file mode 100644 index 0000000000..d6f573bf34 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md @@ -0,0 +1,15 @@ +# 220 — P-2 rotation probe (T02 / #1527 suspect) + +## Evidence + +4 consecutive ~101K-token turns on one pinned conversationId +(composer-2.5-fast) all completed (OK1..OK4, usage.totalTokens ~101,111-147). +No 0-token resource_exhausted, no conversation poisoning within the capped +attempt budget (probe cap <= 5 runs, quota hygiene doc 200). + +## Verdict: NOT REPRODUCED — T02 stays deferred + +The senpi #998 pathology (server pinning a rejection to a conversationId) did +not manifest at this size on this plan. #1527 remains open without a local +reproduction; rotation-with-persistence stays deferred until a live +reproduction exists. No implementation this round. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md b/devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md new file mode 100644 index 0000000000..62f0aadce3 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md @@ -0,0 +1,27 @@ +# 230 — P-3 issue #2305: display-alias leak in assistant text + +## Root cause (code-grounded, ox-alpha lane + main-agent verification) + +OpenCodex has NO text-mode tool-call parser; assistant text passes through +verbatim: protobuf-events.ts textDelta (~:1245) -> message-mapper.ts -> +bridge.ts text_delta -> chat-completions client. Pi parses +"[TOOL_CALL]name[ARGS]{...}" text itself, so when a Cursor model emits the +textual pseudo-frame with the DISPLAY name (mcp_opencodex-responses_grep), +Pi sees an undeclared tool and the turn dies. Real tool-call FRAMES are +already normalized via mcpWireNameFromArgs -> normalizeCursorWireName +(protobuf-events.ts:278-281); text deltas bypass that. + +## Verdict: IMPLEMENT + +## Diff shape + +- protobuf-events.ts textDelta case: scrub via marker-scoped regex + \[TOOL_CALL\](mcp_opencodex-responses_[^\[\]]+)\[ARGS\] -> + normalizeCursorWireName inside markers only. Prose mentions stay untouched; + scope-guarded to the exact OCX_RESPONSES_TOOL_PROVIDER prefix. +- Streaming caveat: a marker can straddle two deltas. Start WITHOUT tail + buffering; add only if live traces show split markers (recorded risk). +- Tests: tests/cursor-protobuf-events.test.ts — marker normalized, prose + untouched, real frames unaffected. +- Precedent: a69d291fb (request-side [Tool Result] envelope strip) — same + failure family, response-side analogue. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md b/devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md new file mode 100644 index 0000000000..45b304c659 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md @@ -0,0 +1,14 @@ +# 240 — P-4 client version probe + +## Evidence + +GetUsableModels accepted all three version strings with byte-identical +catalogs (204 entries): cli-2026.07.08-0c04a8a (ours), cli-2026.07.23-e383d2b +(senpi), cli-2026.02.13-41ac335 (our discovery pin). Live Run on the 07.08 +pin works (P-5 turns completed). + +## Verdict: NOOP (no forced bump) + +No behavioral delta proven. Optional freshness bump to 07.23 is safe by this +probe but buys nothing measurable; keep the pin, keep the drift watch from +190 (api2direct reports). diff --git a/devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md b/devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md new file mode 100644 index 0000000000..06a59f8c8e --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md @@ -0,0 +1,13 @@ +# 250 — P-5 billed usage / cacheRead (T09) + +## Evidence + +Two live proxy turns (composer-2.5-fast) report sane Responses usage: +input_tokens 11085/11162, output 11/10, cached_tokens 0, no inflation, no +cacheRead > 3x input pathology. Transport-level runs report estimated usage +consistently (~101K totals on the big turns, matching payload size). + +## Verdict: NOOP for the clamp + +No evidence of senpi's billed-int64 pathology on this plan tier. T09 clamp +stays unimplemented; revisit only if live usage reports regress. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md b/devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md new file mode 100644 index 0000000000..a32bf650a3 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md @@ -0,0 +1,34 @@ +# 260 — bare resource_exhausted refinement (T01 follow-up, live-evidenced) + +## Problem + +#2320 classifies a bare 0-token resource_exhausted (no quota cue, no size +phrase) as CONTEXT OVERFLOW -> 400-class so Codex compacts. Live probe 210 +found a counterexample: a ~20-token prompt on claude-opus-4-7-low-fast +returns the SAME bare shape. (Re-probe note: the cause of that RE is +tier-specific and unknown — the entitlement story was withdrawn — but the +evidence stands as-is: NON-OVERFLOW rejections share the bare shape, so the +shape alone cannot justify compaction.) Misclassifying a tiny turn as +overflow makes Codex compact it — wrong remedy, and the retry can never +succeed. + +## Design + +Classification needs a size prior: only classify bare RE as overflow when the +REQUEST was plausibly large relative to the model's context window; small +requests keep the 429-class quota/entitlement mapping. The adapter already +computes an input-token estimate (prepareCursorRunRequest +estimateInputTokens; estimateTokens lib). Shape: + +- cursor-errors.ts: classifyCursorError gains an optional context + { estimatedInputTokens?, contextWindow? }. +- live-transport/adapter passes the estimate it already has for the turn. +- Rule: bare RE + estimate >= OVERFLOW_MIN_FRACTION (0.5) * contextWindow -> + overflow (current behavior); otherwise -> existing rate-limit mapping. + Unknown estimate/window -> keep current overflow mapping (fail toward + compaction, today's behavior) so the refinement only ever REDUCES + false overflows it can prove. +- Tests: tests/cursor-errors.test.ts — tiny-estimate bare RE -> 429 class; + large-estimate -> overflow; no-estimate -> overflow (unchanged). + +## Verdict: IMPLEMENT (beyond-senpi refinement; senpi T01 shares this bug) diff --git a/devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md b/devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md new file mode 100644 index 0000000000..1f59d7cb50 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md @@ -0,0 +1,50 @@ +# 290 — Round-3 lock + +Probes executed on macmini (live account, redacted transcripts in 210-250; +raw dumps deleted from probe host after lock per 200 hygiene). + +## Implementation order (this loop) + +1. **230 — #2305 text-marker normalization** (IMPLEMENT; clear defect, open + issue, code-grounded fix point). +2. **260 — bare-RE size prior** (IMPLEMENT; live-evidenced false-overflow + class; a refinement senpi's own T01 lacks). + +## Closed by probe (no code) + +- 210 maxMode: BLOCKED (plan tier) — flag accepted but -fast entitlement + absent; NEEDS_HUMAN to provision a -fast-capable account for re-probe. +- 220 rotation: NOT REPRODUCED at 4x101K; T02 stays deferred. +- 240 client version: NOOP — three version strings byte-identical catalogs. +- 250 billed usage: NOOP — no cacheRead pathology on this plan. + +## Updated senpi verdict + +With 230+260 landed, remaining senpi-ahead rows shrink to: rotation +persistence (unreproducible here), maxMode (plan-gated for both projects +without entitlement), agent-loop-level stop/exec ownership (out of adapter +scope; #2305's actual defect is ours to fix and is fixed). OpenCodex keeps +its unique-side advantages (interactionQuery, HTTP/1 fallback, SelectedImage +vision, bounded memory, T04 watchdog with senpi-matching thresholds, typed +exec errors, EOF fail-closed tests). Verdict: at parity or ahead on every +row that is provable on this plan tier; the two rows senpi still leads +require entitlement or a reproduction neither project can show today. + +## Post-landing status (locked after implementation) + +- 230 landed: PR #2341 (896cb5720), closes #2305 — 4 regression tests. +- 260 landed: PR #2342 (8f3ac5fe9) — size prior with 5 regression tests; + strictly narrowing (unknown context keeps the #2320 overflow mapping). +- Final gate: Cross-platform CI on the resulting dev head (see PR checks); + the verdict above stands as written — no remaining provable senpi-ahead + row on this plan tier. + +## Amendment (re-probe reopens the maxMode row) + +The 210 entitlement interpretation was withdrawn by a same-day re-probe +(claude-opus-4-8-high-fast works; only 4-7-low-fast RE persists). maxMode is +therefore PROVABLE on this plan tier: the parity claim's "unprovable" basis +for that row no longer holds, and the row is reopened pending 310 (big- +context A/B, billing approved). The 300 catalog repair also supersedes the +"no remaining provable row" phrasing: the static catalog itself under- +exposed working -fast families, which is our defect, now roadmapped. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md b/devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md new file mode 100644 index 0000000000..70a5dcc0e8 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md @@ -0,0 +1,45 @@ +# 300 — opus-fast catalog repair (from live re-probe) + +## Corrected evidence (supersedes 210's entitlement interpretation) + +Live wire probes (this session, macmini): +- claude-opus-4-8-high-fast -> SUCCESS ("FP-OK"); both maxMode arms succeed. +- claude-opus-4-7-low-fast -> bare resource_exhausted (tier-specific; cause + unknown — NOT account-wide entitlement). +- claude-opus-4-7-fast (bare) -> not_found: the wire only has suffixed forms. +- Proxy-side cursor/claude-opus-4-7-fast -> not_found today because the + static catalog sends the bare id (discovery.ts:239-240 "tiers unverified"). + +GetUsableModels dump (204 entries) lists the -fast families as +{base}-{effort}-fast, matching effort-map.ts:130-131's existing suffix rule. +maxMode=true rides exactly these 28 opus -fast ids. + +## Diff shape + +- src/adapters/cursor/discovery.ts CURSOR_STATIC_MODELS: + - claude-opus-4-7-fast: add supportsReasoningEffort: true (tiers now + live-verified); keep CONTEXT_200K. + - add claude-opus-4-8-fast and claude-opus-5-fast entries + (supportsReasoningEffort: true, CONTEXT_200K) so the routed catalog + exposes the working families. +- src/adapters/cursor/effort-map.ts CURSOR_EFFORT_TIERS: + - "claude-opus-4-7-fast": from dump: low/medium/high (+ thinking variants + are separate wire ids — out of scope; only non-thinking tiers). + - "claude-opus-4-8-fast": low/medium/high/xhigh/max per dump. + - "claude-opus-5-fast": tiers per dump (verify exact list from the + transcript at implementation P). + - The -fast suffix rule at :130-131 already produces + {base-without-fast}-{effort}-fast — verify it yields e.g. + claude-opus-4-8-high-fast (it did live). +- CURSOR_NO_VISION_MODELS: opus families are Claude-hosted (vision-capable); + no curation change. +- Tests: tests/cursor-static-catalog.test.ts + effort-map tests — pin the + new ids, tier ladders, and wire-id derivation for one example per family. +- Live smoke after merge: macmini proxy turn on cursor/claude-opus-4-8-fast + (effort high) expecting text output. + +## Risk + +4-7-low-fast RE stays unexplained; the catalog change only ADDS working +families and upgrades 4-7-fast from bare (broken) to suffixed. Worst case a +tier 404s -> same not_found class as today, no regression. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md b/devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md new file mode 100644 index 0000000000..298d6855f5 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md @@ -0,0 +1,60 @@ +# 310 — big-context maxMode A/B (billing approved) + +## Question + +Does RequestedModel.maxMode=true actually EXTEND usable context on a +maxMode-capable model (claude-opus-4-8-high-fast, static window 200K)? +Small-turn A/B showed the server accepts both values with no delta; the +decisive test is a payload ABOVE the normal window. + +## Design (2 runs, billing approved by user) + +- Payload: ~230K tokens of filler text + a needle question (verify the + needle to prove the context was actually consumed, not truncated). +- Run A: maxMode=false -> expect bare RE (overflow) or truncation. +- Run B: maxMode=true -> if it completes AND answers the needle, maxMode + extends context: IMPLEMENT propagation (discovery retains maxMode per + model; protobuf-request sets RequestedModel.maxMode for capable ids; + registry context window bump gated on the flag). +- If B fails identically: NOOP — flag is cosmetic on this plan; record and + keep hardcoded false. + +## Hygiene + +Transcripts redacted; raw dumps in probe-host scratch, deleted after +verdict. Cost cap: exactly 2 runs (~460K input tokens total). Abort rule: +if run A errors before body completes upload, do not burn run B; record +BLOCKED-transport. + +## Executed results (260822, claude-opus-4-8-high-fast) + +Round 1 (single ~230K-token message): BOTH arms failed identically with +Connect invalid_argument (~16-19s in). Not overflow, not maxMode: a +PER-MESSAGE BYTE CAP. + +Round 2 (cap bisection + multi-message): +- single ~150K tokens (~1.06MB) -> invalid_argument. +- single ~120K tokens (~850KB) -> SUCCESS, needle answered + ("TANGERINE-4471"). Cap sits between ~0.85MB and ~1.06MB — consistent + with a 1 MiB UserMessage blob limit. +- multi-message history summing well past the window, needle in EARLY + history: model answers "no launch code" on BOTH maxMode arms — server + keeps recent context and drops old history; maxMode does not change + retention. + +## Verdict: NOOP for maxMode propagation + +maxMode=true produced no behavioral difference in any shape (small turn, +oversize single message, over-window history). The flag stays hardcoded +false. Re-open only if Cursor documents maxMode semantics or a Max-mode +plan shows different retention. + +## Side findings (feed the readiness audit) + +1. Single messages over ~1MiB fail as invalid_argument. The adapter's + invalid_argument handling includes a fresh-conversation replay fallback — + an oversized message could burn a pointless replay. P2: consider a + pre-flight size guard with a clear client error before the wire call. +2. Over-window history is silently truncated server-side (old turns + dropped). Matches the checkpoint/context-usage design assumption; no + action. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 59381edd60..40ee8021f1 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -115,7 +115,7 @@ ocx logout | Fournisseur | Adaptateur | URL de base | Remarques | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Catalogue Grok découvert en direct en priorité ; `grok-4.5` est le modèle de repli par défaut. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth utilise la passerelle d'abonnement Grok CLI distincte. Le remplacement par clé API utilise `https://api.x.ai/v1` et peut injecter Priority Processing. Catalogue Grok découvert en direct en priorité ; `grok-4.5` est le modèle de repli par défaut. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Modèles Claude ; liste des modèles récupérée en direct depuis `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Modèles de programmation Kimi K2.7/K2.6/K2.5. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Passerelle d'abonnement Nous Research (le même service en amont que celui utilisé par Hermes Agent). Connexion par autorisation d'appareil auprès de `portal.nousresearch.com` ; le jeton d'accès est le JWT d'inférence envoyé avec chaque requête. Le catalogue mixte de modèles payants et `:free` (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) est découvert en direct pour le compte connecté. Les jetons d'actualisation sont à usage unique et renouvelés à chaque actualisation. | diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index f677871a28..c63d3065a6 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -146,15 +146,16 @@ Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit rec ## Service d’arrière-plan -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Exécute opencodex comme service d’arrière-plan géré à l’ouverture de session — **launchd** sous macOS, **unité utilisateur systemd** sous Linux et **Task Scheduler** sous Windows — qui démarre automatiquement à la connexion et redémarre après un plantage. Les services définissent `OCX_SERVICE=1` afin qu’un redémarrage ne réécrive pas inutilement la configuration Codex. | Sous-commande | Action | | --- | --- | -| aucune | Crée ou met à jour le service, puis le démarre. | +| aucune | Installe et démarre le service s’il est absent ; sinon, actualise et redémarre le service existant sans le réenregistrer. | | `install` | Crée et démarre le service. L’enregistrement exige une élévation sous Windows. | | `repair` | Actualise sur place un service installé et le redémarre, sans le réenregistrer. | +| `restart` | Alias de `repair`. | | `start` | Démarre un service installé. | | `stop` | Arrête le service et rétablit le fonctionnement natif de Codex. | | `status` | Affiche les diagnostics du service et du proxy, ainsi que les chemins des journaux. | @@ -165,10 +166,13 @@ Exécute opencodex comme service d’arrière-plan géré à l’ouverture de se ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Sous Windows, un `ocx service` nu n'exécute le chemin d'installation qu'après avoir prouvé l'absence à la fois du Task Scheduler et de WinSW. Si l'une des requêtes de statut est inconcluante, il refuse d'enregistrer quoi que ce soit et demande d'exécuter `ocx service status` ; n'utilisez un `ocx service install` explicite qu'après avoir confirmé l'absence. + Avant de signaler une réussite, `install`, `start` et `repair` vérifient, sur les trois plateformes, qu’un proxy répond effectivement sur le port inscrit dans le service installé. Elles attendent jusqu’à 20 secondes, puis affichent le port utilisé : ```text diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 336627de70..feedf5ad0c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -284,6 +284,15 @@ les paramètres de modèle propres à Cursor : Les variantes explicites envoient le modèle `default` de Cursor avec son paramètre `optimization`, ce qui préserve la sélection à chaque requête. Elles restent disponibles lorsque la découverte en direct omet `default`. +### Vision + +La vision native Cursor utilise `SelectedImage` (plafond JPEG souple + `blobIdWithData`) pour les modèles +qui voient les images nativement — Claude, Gemini, GPT, Kimi et Grok notamment — à partir des images +`data:` du tour actif uniquement. Les images des tours précédents rejouent comme marqueurs texte +`[image attached]` ; les images distantes ou indécodables deviennent des marqueurs d’omission. +Auto, la famille Composer et GLM (`glm-5.2`, `glm-5.3`) restent +sur la liste curatée `noVisionModels` et passent par le sidecar de description d'images. + Les outils locaux pilotés par le serveur Cursor sont désactivés par défaut. Codex continue d'utiliser ses propres outils tels que `apply_patch` et `exec_command` avec sa propre politique d'approbation et de bac à sable : @@ -413,7 +422,7 @@ avec un contexte de `922000` et une entrée maximale de `922000` ; OpenRouter i "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "glm-5.3", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1485d49b2c..80e1c152dd 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -203,6 +203,31 @@ Routed catalog entries also get their GPT-5 identity rewritten to the real upstr Reasoning controls come from provider/model metadata across Codex's `low | medium | high | xhigh | max | ultra` ladder; unsupported values are mapped or clamped before the upstream request. +### Coordinator diagnosis and recovery + +Native config/history writes use a per-user SQLite coordinator keyed by the canonical `CODEX_HOME`. +If a process terminates in SQLite's initial creation window, a zero-byte coordinator can remain even +though it contains no authoritative transition row. `ocx doctor` reports the exact coordinator path +and distinguishes zero-byte, unversioned, rowless, valid, unsafe, and unreadable states without +creating SQLite sidecars. Automatic sync tolerates only an identity-stable zero-byte file that has +settled for at least one second and whose immutable SQLite snapshot has version zero with no tables; +a newly created zero-byte file remains on the locked coordinator path. + +For a state that doctor proves is a zero-byte creation remnant, stop the OpenCodex proxy/service +and run: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +Recovery moves the still-identical zero-byte file to a same-directory `.zero-byte-backup-*` path; +it does not delete the evidence or adopt legacy routed state. It refuses a running proxy, lock +contention, symlinks/reparse points, foreign ownership, changed files, every non-empty database, +and any coordinator that already has an authoritative row. Desktop renderer filtering is a +separate layer: a correct catalog and coordinator do not by themselves bypass the Codex App model +allowlist. + ### Routed local tools Non-native routed catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expose its official diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d1eec12e1a..e58af8360c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -110,7 +110,7 @@ ocx logout | Provider | Adapter | Base URL | Notes | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Live-first Grok catalog; `grok-4.5` is the fallback default. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth uses the separate Grok CLI subscription gateway. The API-key override uses `https://api.x.ai/v1` and may inject Priority Processing. Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | @@ -585,3 +585,15 @@ does not follow redirects. The response's rolling, weekly, and monthly `percent` already-consumed utilization: rolling maps to the 5-hour bar, while weekly and monthly keep their matching bars. OpenCodex does not reconstruct dollar caps from local usage logs, and a provider using a non-canonical `baseUrl` is never sent the key for this probe. + +**Z.AI GLM Coding Plan quota.** The `zai`, `glm`, `glm-cn`, and `zhipu-bigmodel-coding` +presets read `GET /api/monitor/usage/quota/limit` with the configured key as a Bearer token +and do not follow redirects. The probe runs against the region the provider points at: +`api.z.ai` (bare or `/api/coding/paas/v4`) or `open.bigmodel.cn` (bare, +`/api/coding/paas/v4`, or the OpenAI Responses endpoint `/api/v1`). The response's `limits` +rows fill the utilization bars: `TOKENS_LIMIT` / `CREDIT_LIMIT` rows with `unit` 3 / +`number` 5 fill the 5-hour bar and `unit` 6 / `number` 1 the weekly bar, while +`TIME_LIMIT` rows fill the monthly MCP bar. The v2 coding-plan protocol reports the +monthly MCP row; the newer protocol does not, so the monthly bar renders only when that +row is present. A provider using a non-canonical `baseUrl` is never sent the key for this +probe. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 81a19e5e5b..f99bf35aec 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -105,7 +105,7 @@ ocx logout | プロバイダー | アダプター | ベース URL | 備考 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth は独立した Grok CLI サブスクリプションゲートウェイを使用します。API キーのオーバーライドは `https://api.x.ai/v1` を使用し、Priority Processing を注入する場合があります。ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 1f8c593e91..0ee91c6351 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -150,15 +150,16 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 ## バックグラウンドサービス -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` opencodex を、ログイン時に自動起動し、クラッシュ時に自動再起動するログイン管理バックグラウンド サービス (macOS **launchd**、Linux **systemd ユーザー ユニット**、Windows **タスク スケジューラ**) として実行します。サービスは `OCX_SERVICE=1` を設定して実行されるため、再起動によって Codex 設定が変更されることはありません。 |サブコマンド |アクション | | --- | --- | -|なし |サービスを作成/更新して開始します。 | +|なし |未インストールなら作成して開始し、既存なら再登録せずに更新して再起動します。 | | `install` |サービスを作成して開始します。 | | `repair` | 既存のサービスを再登録せずに更新して再起動します。 | +| `restart` | `repair` の別名です。 | | `start` |インストールされているサービスを開始します。 | | `stop` |サービスを停止し、ネイティブ Codex を復元します。 | | `status` |サービスとプロキシの診断とログ パスをレポートします。 | @@ -169,10 +170,13 @@ opencodex を、ログイン時に自動起動し、クラッシュ時に自動 ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Windows では、bare `ocx service` は、タスク スケジューラと WinSW の両方について不在が確認された後にのみ、インストール パスを実行します。どちらかのステータス照会が不確実な場合、何も登録せず、`ocx service status` の実行を案内します。不在を確認した後にのみ、明示的な `ocx service install` を使用してください。 + Windows では、`ocx service status` は、ID 検証済みの OpenCodex プロキシの到達可能性とは別に、タスク スケジューラの登録を報告します。ローカライズされた `schtasks` テーブルは出力されないため、概要は Windows コード ページ間で読み取れるままです。 Windows では、タスク スケジューラ エントリを作成するには昇格が必要です。認識されたローカライズされたアクセス拒否テキストは、既存のガイダンス パスを維持します。そのテキストが判読できない場合、フォールバックには、所有されているコマンド形状 `/create /tn opencodex-proxy /xml /f`、ステータス 1、および確認済みの非昇格トークンが必要です。ダッシュボードのスタートアップ セーフティ アクションは、UAC を自動的に要求できるようになります。そのフォールバックがトークンの状態を判断できない場合、元のスケジューラ エラーが保持されます。外部タスクおよび操作は、自動昇格マーカーを発行することはできません。ダッシュボードの UAC プロンプトを承認するか、管理者特権の PowerShell ウィンドウで `ocx service install` を再実行します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4f57dab7cc..b24e17f3fc 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -104,7 +104,7 @@ ocx logout | 프로바이더 | 어댑터 | 베이스 URL | 비고 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth는 별도의 Grok CLI 구독 게이트웨이를 사용합니다. API 키 오버라이드는 `https://api.x.ai/v1`을 사용하며 Priority Processing을 주입할 수 있습니다. 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 14d8db2cf0..1444cdb2be 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -193,7 +193,7 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ## 백그라운드 서비스 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 로그인 관리형 백그라운드 서비스로 opencodex를 실행합니다(macOS **launchd**, Linux **systemd** 사용자 유닛, Windows **Task Scheduler**). 로그인 시 자동 시작하고 충돌 시 자동 재시작합니다. 서비스 실행은 @@ -201,9 +201,10 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 | 하위 명령 | 동작 | | --- | --- | -| 없음 | 서비스를 생성/업데이트하고 시작합니다. | +| 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 재등록하지 않고 새로 고쳐 재시작합니다. | | `install` | 서비스를 생성하고 시작합니다. | | `repair` | 설치된 서비스를 다시 등록하지 않고 제자리에서 새로 고친 뒤 재시작합니다. | +| `restart` | `repair`의 별칭입니다. | | `start` | 설치된 서비스를 시작합니다. | | `stop` | 서비스를 중지하고 기본 Codex를 복원합니다. | | `status` | 서비스와 프록시 진단, 로그 경로를 보고합니다. | @@ -214,10 +215,15 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Windows에서는 bare `ocx service`가 Task Scheduler와 WinSW 양쪽 모두 부재가 입증된 후에만 설치 +경로를 실행합니다. 상태 조회 중 하나라도 불확실하면 아무것도 등록하지 않고 `ocx service status` +실행을 안내합니다. 부재를 확인한 뒤에만 명시적인 `ocx service install`을 사용하세요. + Windows에서는 `ocx service status`가 Task Scheduler 등록 상태를 ID가 검증된 OpenCodex 프록시 도달 가능성과 별도로 보고합니다. 로컬라이즈된 `schtasks` 표는 출력하지 않으므로, 요약은 Windows 코드 페이지에서도 읽기 쉽습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 486d10321e..bcc3a340ff 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -162,6 +162,23 @@ unreachable; and 64 for invalid arguments. ### `ocx doctor` +The default report includes the native-write coordinator state and exact path using immutable +read-only SQLite inspection. Zero-byte, empty-unversioned, and rowless states are shown separately +from catalog/app-server health, so a successful catalog refresh is not mistaken for successful +Codex config injection. + +After stopping the OpenCodex proxy/service, explicitly preserve and move a proven non-authoritative +coordinator, then retry sync: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +The recovery accepts only a proven zero-byte remnant. It refuses every non-empty, valid, unknown, +changed, unsafe, or busy database and creates a same-directory `.zero-byte-backup-*` file instead +of deleting anything. + Run read-only environment and connectivity diagnostics: state paths and filesystem type, WSL dual installs, proxy environment/config, ChatGPT reachability, Codex plugin and project-config warnings, and pending history migration. The Codex app-home targeting section also detects the narrow Windows @@ -198,7 +215,7 @@ same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx ## Background service -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Run opencodex as a login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash. Service runs set @@ -211,19 +228,25 @@ run `ocx service repair` to refresh the task with the restored package paths. | Subcommand | Action | | --- | --- | -| none | Create/update and start the service. | +| none | Install and start when absent; otherwise refresh and restart the existing service without re-registering it. | | `install` | Create and start the service. Registers it, which on Windows needs elevation. | | `repair` | Refresh an installed service in place and restart it, without re-registering it. | +| `restart` | Alias of `repair`. | | `start` | Start an installed service. | | `stop` | Stop the service and restore native Codex. | | `status` | Report service and proxy diagnostics plus log paths. | | `uninstall` | Remove the service and restore native Codex. | | `remove` | Alias of `uninstall`. | +On Windows, a bare `ocx service` runs the install path only after both Task Scheduler and WinSW are +proven absent. If either status query is inconclusive, it refuses to register anything and asks you +to run `ocx service status`; use explicit `ocx service install` only after confirming absence. + ```bash ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 135f8f9632..c44b628714 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -87,7 +87,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | -| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → jawcode catalog → expected-price overlay → model-level vendor fallback, and an all-zero entry falls through to the next source in that sequence. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an all-zero entry falls through to the next source in that sequence. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | @@ -152,6 +152,25 @@ contract; existing configurations see these migration deltas: Explicit capability `false` and Responses caller-tier forwarding retain their existing contracts. +### xAI Priority Processing + +The built-in `xai` preset advertises and injects Fast only when its effective transport uses +`authMode: "key"`. API-key mode targets `https://api.x.ai/v1` through the `openai-chat` adapter and +sends `service_tier: "priority"` through Chat Completions. `ocx login xai` +instead stores OAuth credentials for the separate Grok CLI subscription-gateway flow, so OAuth +remains unclassified: its catalog rows do not advertise Fast and the proxy does not inject a tier. + +xAI charges Priority Processing at 2× the standard token price for input, output, cached, and +reasoning tokens; cache discounts are applied before the multiplier. Cost estimates use that premium +only when xAI's response confirms `service_tier: "priority"`. A missing or unparsed response tier is +not confirmation, and an echoed `default` is a downgrade; all three stay at the standard price. + +For `grok-4.6`, the standard rate per 1M tokens is $2.00 input, $0.50 cached input, and $6.00 +output. A prompt of at least 200,000 tokens reprices the whole request at $4.00 / $1.00 / $12.00. +xAI has not published how that long-context band combines with Priority Processing. When a +long-context response confirms `priority`, the dashboard therefore shows the published long-context +cost with a `≥` marker and a lower-bound explanation; it never invents a stacked multiplier. + ### OpenRouter Fast The canonical `https://openrouter.ai/api/v1` preset advertises Fast only for these exact @@ -345,6 +364,14 @@ Cursor-specific model parameters: Explicit variants send Cursor's `default` model with its `optimization` parameter, preserving the selection on every request. They remain available when live discovery omits `default`. +### Vision + +Native Cursor vision uses `SelectedImage` (JPEG soft-cap + `blobIdWithData`) for models that can +see images natively — Claude, Gemini, GPT, Kimi, and Grok among them — using active-turn `data:` +images only. Earlier-turn images replay as `[image attached]` text markers; remote or undecodable +images become omission markers. Auto, the Composer family, and GLM (`glm-5.2`, `glm-5.3`) stay on +the curated `noVisionModels` list and use the vision describe sidecar instead. + Cursor server-driven local tools are disabled by default. Codex continues using its own tools such as `apply_patch` and `exec_command` with its own approval and sandbox policy: @@ -474,7 +501,7 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "glm-5.3", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1966d9db63..1dfe171a58 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -114,7 +114,7 @@ ocx logout | Провайдер | Адаптер | Базовый URL | Примечания | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth использует отдельный шлюз подписки Grok CLI. Переопределение с API-ключом использует `https://api.x.ai/v1` и может добавлять Priority Processing. Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 2696370cc5..ed4a42a785 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -209,7 +209,7 @@ opencodex. Предупреждение о stale-`app-server` и optional `--res ## Фоновая служба -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Запустить opencodex как login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**), которая автоматически стартует при логине и сама @@ -218,9 +218,10 @@ unit**, Windows **Task Scheduler**), которая автоматически | Подкоманда | Действие | | --- | --- | -| none | Создать/обновить и запустить службу. | +| none | Установить и запустить службу, если её нет; иначе обновить и перезапустить существующую службу без повторной регистрации. | | `install` | Создать и запустить службу. | | `repair` | Обновить установленную службу на месте и перезапустить её без повторной регистрации. | +| `restart` | Псевдоним команды `repair`. | | `start` | Запустить уже установленную службу. | | `stop` | Остановить службу и восстановить native Codex. | | `status` | Показать диагностику службы и прокси, а также пути к логам. | @@ -231,10 +232,13 @@ unit**, Windows **Task Scheduler**), которая автоматически ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +На Windows bare `ocx service` выполняет путь установки только после того, как отсутствие подтверждено и для Task Scheduler, и для WinSW. Если любой из запросов статуса не даёт определённого ответа, он отказывается что-либо регистрировать и предлагает выполнить `ocx service status`; явный `ocx service install` используйте только после подтверждения отсутствия. + На Windows `ocx service status` отдельно показывает регистрацию в Task Scheduler и identity-проверенную достижимость прокси OpenCodex. Он не печатает локализованную таблицу `schtasks`, чтобы сводка оставалась читаемой на любых code page Windows. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index ee153a0780..15e2ab3cf4 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -129,7 +129,7 @@ ocx logout | Sağlayıcı | Adaptör | Temel URL | Notlar | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | Canlı öncelikli Grok kataloğu; `grok-4.5` geri dönüş varsayılanıdır. | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth ayrı Grok CLI abonelik ağ geçidini kullanır. API anahtarı geçersiz kılması `https://api.x.ai/v1` kullanır ve Priority Processing ekleyebilir. Canlı öncelikli Grok kataloğu; `grok-4.5` geri dönüş varsayılanıdır. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude modelleri; canlı model listesi `/v1/models` üzerinden getirilir. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 kodlama modelleri. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research abonelik ağ geçidi (Hermes Agent'ın kullandığı aynı arka uç). `portal.nousresearch.com`'a karşı cihaz yetkilendirmesi girişi; erişim belirteci istek başına çıkarım JWT'sidir. Oturum açmış hesaptan canlı olarak keşfedilen karışık ücretli + `:free` model kataloğu (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...). Yenileme belirteçleri tek kullanımlıktır ve her yenilemede döndürülür. | diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 134442a2cb..624c4174fb 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -232,7 +232,7 @@ ve isteğe bağlı `--restart-codex` davranışı geçerlidir. ## Arka plan servisi -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` opencodex'i oturum açmada otomatik başlayan ve çökmede otomatik yeniden başlayan oturumla yönetilen bir arka plan servisi (macOS **launchd**, Linux **systemd @@ -242,9 +242,10 @@ yapılandırmasını dalgalandırmaz. | Alt komut | Eylem | | --- | --- | -| none | Servisi oluşturun/güncelleyin ve başlatın. | +| none | Servis yoksa kurup başlatın; varsa yeniden kaydetmeden yenileyip yeniden başlatın. | | `install` | Servisi oluşturun ve başlatın. Kaydeder, bu da Windows'ta yükseltme gerektirir. | | `repair` | Kurulu bir servisi yerinde yenileyin ve yeniden kaydetmeden yeniden başlatın. | +| `restart` | `repair` komutunun takma adıdır. | | `start` | Kurulu bir servisi başlatın. | | `stop` | Servisi durdurun ve yerel Codex'i geri yükleyin. | | `status` | Servis ve proxy tanılamalarını artı günlük yollarını bildirin. | @@ -255,10 +256,13 @@ yapılandırmasını dalgalandırmaz. ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +Windows'ta bare `ocx service`, yükleme yolunu ancak Task Scheduler ve WinSW'nin her ikisinin de yok olduğu kanıtlandıktan sonra çalıştırır. Durum sorgularından herhangi biri belirsizse hiçbir şey kaydetmeyi reddeder ve `ocx service status` çalıştırmanızı ister; yalnızca yokluk doğrulandıktan sonra açık `ocx service install` kullanın. + `install`, `start` ve `repair`, başarı bildirmeden önce kurulu servise yerleştirilmiş portta bir proxy'nin gerçekten yanıt verdiğini onaylar — her üç platformda da. 20 saniyeye kadar beklerler ve ardından sunulan portu @@ -435,5 +439,3 @@ ocx update --tag preview Yeni sürümler, [Sürüm iş akışı](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) bunları npm'de yayınladığında kullanılabilir hale gelir. - - diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 4e924458ee..a73676e496 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -95,7 +95,7 @@ ocx logout | 提供商 | Adapter | 基础 URL | 备注 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | 优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth 使用独立的 Grok CLI 订阅网关。API 密钥覆盖模式使用 `https://api.x.ai/v1`,并可能注入 Priority Processing。优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 4d103f0f06..964172ec9a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -147,15 +147,16 @@ ocx status --json ## 后台服务 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 将 opencodex 作为登录管理的后台服务运行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登录时自动启动,在崩溃时自动重启。服务运行会设置 `OCX_SERVICE=1`,因此重启时不会反复改动 Codex 配置。 | 子命令 | 操作 | | --- | --- | -| none | 创建/更新并启动服务。 | +| none | 服务不存在时安装并启动;已存在时不重新注册,直接刷新并重启。 | | `install` | 创建并启动服务。 | | `repair` | 就地刷新已安装的服务并重启,不重新注册。 | +| `restart` | `repair` 的别名。 | | `start` | 启动已安装的服务。 | | `stop` | 停止服务并恢复原生 Codex。 | | `status` | 报告服务和代理诊断信息及日志路径。 | @@ -166,10 +167,13 @@ ocx status --json ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +在 Windows 上,bare `ocx service` 只有在 Task Scheduler 和 WinSW 两者的缺失都得到证实后才会走安装路径。如果任一状态查询结果不确定,它会拒绝任何注册并提示运行 `ocx service status`;只有在确认缺失之后才使用显式的 `ocx service install`。 + 在 Windows 上,`ocx service status` 会单独报告 Task Scheduler 注册状态和已身份验证的 OpenCodex 代理可达性。它不会打印本地化的 `schtasks` 表格,因此在不同 Windows 代码页下摘要仍然可读。 在 Windows 上,创建 Task Scheduler 条目需要提升权限。识别到本地化的访问被拒绝文本时,会沿用现有的指导路径。如果该文本不可读,则回退要求命令形态为 `/create /tn opencodex-proxy /xml /f`,状态为 1,并且令牌明确为非提升权限;这时仪表盘的 Startup Safety 操作可以自动请求 UAC。如果该回退无法判断令牌状态,它会保留原始调度器错误。外部任务和操作绝不会发出自动提升标记。请批准仪表盘的 UAC 提示,或在提升权限的 PowerShell 窗口中重新运行 `ocx service install`。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index ee7d709880..28298c768a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -103,7 +103,7 @@ ocx logout | 供應商 | Adapter | Base URL | 備註 | | --- | --- | --- | --- | -| `xai` | `openai-chat` | `https://api.x.ai/v1` | 優先使用即時 Grok catalog;fallback 預設為 `grok-4.5`。 | +| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth 使用獨立的 Grok CLI 訂閱 gateway。API key 覆寫使用 `https://api.x.ai/v1`,並可能注入 Priority Processing。優先使用即時 Grok catalog;fallback 預設為 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;即時模型列表從 `/v1/models` 取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding 模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 訂閱 gateway(Hermes Agent 使用相同 backend)。透過 `portal.nousresearch.com` 做 device-grant 登入;access token 是每次請求使用的 inference JWT。混合付費與 `:free` 模型 catalog(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)會從已登入帳號即時探索。Refresh token 為單次使用,每次 refresh 都會輪換。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 02983cff71..c5c13cb623 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -141,15 +141,16 @@ ocx status --json ## 背景服務 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 將 opencodex 作為登入管理的背景服務執行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登入時自動啟動並在崩潰時自動重啟。服務執行時設定 `OCX_SERVICE=1`,使重啟不會折騰 Codex 設定。 | 子指令 | 動作 | | --- | --- | -| 無 | 建立/更新並啟動服務。 | +| 無 | 服務不存在時安裝並啟動;已存在時不重新註冊,直接重新整理並重啟。 | | `install` | 建立並啟動服務。註冊它,在 Windows 上需要提高權限。 | | `repair` | 就地重新整理已安裝的服務並重啟它,而不重新註冊。 | +| `restart` | `repair` 的別名。 | | `start` | 啟動已安裝的服務。 | | `stop` | 停止服務並還原原生 Codex。 | | `status` | 回報服務與代理診斷及日誌路徑。 | @@ -160,10 +161,13 @@ ocx status --json ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` +在 Windows 上,bare `ocx service` 只有在 Task Scheduler 和 WinSW 兩者的缺失都得到證實後才會走安裝路徑。如果任一狀態查詢結果不確定,它會拒絕任何註冊並提示執行 `ocx service status`;只有在確認缺失之後才使用明確的 `ocx service install`。 + `install`、`start` 與 `repair` 會確認代理實際在已安裝服務內建的連接埠上回應,之後才回報成功——在三種平台上皆如此。它們等待最多 20 秒,然後印出伺服連接埠: ``` diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8e64e29b97..41ae5d02e5 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -717,7 +717,7 @@ export const de: Record = { "logs.detail.estimate.cache_detail_missing": "Cache-Details fehlen; Eingabe ist als Obergrenze geschätzt.", "logs.detail.estimate.expected_price_overlay": "Ein verifizierter Expected-Listenpreis wurde verwendet.", "logs.detail.estimate.provider_cost_overlay": "Ein vom Anbieter konfiguriertes Preis-Overlay wurde verwendet.", - "logs.detail.estimate.priority_lower_bound": "Der bestätigte OpenRouter-Priority-Preis ist nicht verfügbar; die angezeigte Standardpreisschätzung ist eine bekannte Untergrenze.", + "logs.detail.estimate.priority_lower_bound": "Der bestätigte Priority-Preis ist nicht verfügbar; die angezeigte Schätzung ist eine bekannte Untergrenze.", "logs.col.error": "Fehler", "logs.col.upstreamReason": "Upstream-Grund", "logs.col.duration": "Dauer", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 262e913162..01d69f0fd1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -750,7 +750,7 @@ export const en = { "logs.detail.estimate.cache_detail_missing": "Cache details were unavailable; input is an upper-bound estimate.", "logs.detail.estimate.expected_price_overlay": "A verified expected list price was used.", "logs.detail.estimate.provider_cost_overlay": "A provider-configured price overlay was used.", - "logs.detail.estimate.priority_lower_bound": "The confirmed OpenRouter priority price is unavailable; the displayed standard-price estimate is a known lower bound.", + "logs.detail.estimate.priority_lower_bound": "The confirmed Priority price is unavailable; the displayed estimate is a known lower bound.", "logs.col.error": "Error", "logs.col.upstreamReason": "Upstream reason", "logs.col.duration": "Duration", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 147773fd93..3765f4d95f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -731,7 +731,7 @@ export const fr: Record = { "logs.detail.estimate.cache_detail_missing": "Les détails du cache n’étaient pas disponibles ; l’entrée est une estimation de la limite supérieure.", "logs.detail.estimate.expected_price_overlay": "Un tarif catalogue attendu et vérifié a été utilisé.", "logs.detail.estimate.provider_cost_overlay": "Un remplacement de tarif configuré pour le fournisseur a été utilisé.", - "logs.detail.estimate.priority_lower_bound": "Le tarif Priority OpenRouter confirmé n’est pas disponible ; l’estimation au tarif standard affichée est une borne inférieure connue.", + "logs.detail.estimate.priority_lower_bound": "Le tarif Priority confirmé n’est pas disponible ; l’estimation affichée est une borne inférieure connue.", "logs.col.error": "Erreur", "logs.col.upstreamReason": "Motif en amont", "logs.col.duration": "Durée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 11a85127ea..63e20efd3b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -693,7 +693,7 @@ export const ja: Record = { "logs.detail.estimate.cache_detail_missing": "キャッシュの詳細が利用できませんでした; 入力は上限の推定です。", "logs.detail.estimate.expected_price_overlay": "検証済みの予想定価が使用されました。", "logs.detail.estimate.provider_cost_overlay": "プロバイダー設定の価格オーバーレイが使用されました。", - "logs.detail.estimate.priority_lower_bound": "確認済みの OpenRouter Priority 価格は取得できないため、表示される標準価格の見積もりは既知の下限です。", + "logs.detail.estimate.priority_lower_bound": "確認済みの Priority 価格を利用できないため、表示される見積もりは既知の下限です。", "logs.col.error": "エラー", "logs.col.upstreamReason": "上流の理由", "logs.col.duration": "所要時間", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5bbc14ae7d..b13cd141ff 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -736,7 +736,7 @@ export const ko: Record = { "logs.detail.estimate.cache_detail_missing": "캐시 상세가 없어 입력 전액을 상한으로 추정했습니다.", "logs.detail.estimate.expected_price_overlay": "검증된 expected 정가를 사용했습니다.", "logs.detail.estimate.provider_cost_overlay": "프로바이더 구성 가격 오버레이를 사용했습니다.", - "logs.detail.estimate.priority_lower_bound": "확인된 OpenRouter Priority 가격을 사용할 수 없어 표시된 표준 가격 추정치는 알려진 하한입니다.", + "logs.detail.estimate.priority_lower_bound": "확인된 Priority 가격을 사용할 수 없어 표시된 추정치는 알려진 하한입니다.", "logs.col.error": "오류", "logs.col.upstreamReason": "업스트림 원인", "logs.col.duration": "소요 시간", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 396ccf3ed0..05cf793779 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -734,7 +734,7 @@ export const ru: Record = { "logs.detail.estimate.cache_detail_missing": "Детализация кэша недоступна; входные токены оценены по верхней границе.", "logs.detail.estimate.expected_price_overlay": "Использована подтверждённая ожидаемая цена из прайс-листа.", "logs.detail.estimate.provider_cost_overlay": "Использован ценовой оверлей провайдера.", - "logs.detail.estimate.priority_lower_bound": "Подтверждённая цена OpenRouter Priority недоступна; показанная оценка по стандартной цене является известной нижней границей.", + "logs.detail.estimate.priority_lower_bound": "Подтверждённая цена Priority недоступна; показанная оценка является известной нижней границей.", "logs.col.error": "Ошибка", "logs.col.upstreamReason": "Причина от провайдера", "logs.col.duration": "Длительность", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f460bbeb36..c98421183e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -741,7 +741,7 @@ export const tr: Record = { "logs.detail.estimate.cache_detail_missing": "Önbellek detayları eksik.", "logs.detail.estimate.expected_price_overlay": "Doğrulanmış liste fiyatı kullanıldı.", "logs.detail.estimate.provider_cost_overlay": "Kullanıcı tarafından yapılandırılan bir sağlayıcı fiyat katmanı kullanıldı.", - "logs.detail.estimate.priority_lower_bound": "Doğrulanan OpenRouter Priority fiyatı kullanılamıyor; gösterilen standart fiyat tahmini bilinen bir alt sınırdır.", + "logs.detail.estimate.priority_lower_bound": "Doğrulanan Priority fiyatı kullanılamıyor; gösterilen tahmin bilinen bir alt sınırdır.", "logs.col.error": "Hata", "logs.col.upstreamReason": "Yukarı akış nedeni", "logs.col.duration": "Süre", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 942e21e61f..d35a96bd34 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1818,7 +1818,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.emptyCompletion": "空白完成重試", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", - "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 OpenRouter Priority 價格;目前顯示的標準價格估算是已知下限。", + "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 Priority 價格;目前顯示的估算是已知下限。", "pws.cockpitImportDescription": "從此裝置匯入 Cockpit Tools Antigravity JSON 匯出檔。不會顯示檔案內容。", "pws.cockpitImportFileLabel": "Cockpit Tools Antigravity JSON 匯出檔", "pws.cockpitImportChooseFile": "選擇 JSON 檔案", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b9cd4a3573..dc749ac530 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -729,7 +729,7 @@ export const zh: Record = { "logs.detail.estimate.cache_detail_missing": "缺少缓存明细;输入费用按上限估算。", "logs.detail.estimate.expected_price_overlay": "使用了已验证的 Expected 标价。", "logs.detail.estimate.provider_cost_overlay": "使用了用户配置的提供方价格覆盖。", - "logs.detail.estimate.priority_lower_bound": "暂无已确认的 OpenRouter Priority 价格;当前显示的标准价估算是已知下界。", + "logs.detail.estimate.priority_lower_bound": "暂无已确认的 Priority 价格;当前显示的估算是已知下界。", "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", diff --git a/gui/src/pages/claude-code-sidecar.ts b/gui/src/pages/claude-code-sidecar.ts index c7861adc4e..14772f4f62 100644 --- a/gui/src/pages/claude-code-sidecar.ts +++ b/gui/src/pages/claude-code-sidecar.ts @@ -4,12 +4,12 @@ * trimmed model is present. */ -import type { SidecarBackend, SidecarOverride } from "./claude-manual-env"; +import type { SidecarOverride, VisionOverrideBackend } from "./claude-manual-env"; -export type SidecarSelectValue = "inherit" | "auto" | SidecarBackend; +export type SidecarSelectValue = "inherit" | "auto" | VisionOverrideBackend; export type PersistedSidecarOverride = { - backend: SidecarBackend | null; + backend: VisionOverrideBackend | null; model: string; }; diff --git a/gui/src/pages/claude-manual-env.ts b/gui/src/pages/claude-manual-env.ts index 59f3360c83..5f22e37165 100644 --- a/gui/src/pages/claude-manual-env.ts +++ b/gui/src/pages/claude-manual-env.ts @@ -6,7 +6,9 @@ import { AUTO_COMPACT_WINDOW_DEFAULT } from "./claude-code-types"; export type SidecarBackend = "openai" | "anthropic"; -export interface SidecarOverride { backend?: SidecarBackend; model?: string } +/** Vision override may carry "routed" (proxy-router describer, #2188). */ +export type VisionOverrideBackend = SidecarBackend | "routed"; +export interface SidecarOverride { backend?: VisionOverrideBackend; model?: string } export interface ClaudeManualEnvState { /** diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 809d08c04f..e528e66260 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -60,9 +60,18 @@ export interface SettingsData { }; } export type SidecarBackend = "openai" | "anthropic"; +/** + * Vision's union is wider than web-search's legacy pair but different from its + * executor set (web has xai/gemini/exa; vision's third arm is "routed" — the + * proxy's own router describing through any provider). Server provenance is + * authoritative; this type exists so a routed option row round-trips without + * being collapsed to a legacy backend. + */ +export type VisionBackend = SidecarBackend | "routed"; export type VisionReasoning = "low" | "medium" | "high" | "xhigh" | "max"; export interface SidecarSetting { - backend?: SidecarBackend; + // Shared by the web-search and vision cards; vision may carry "routed". + backend?: VisionBackend; model: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean; @@ -70,7 +79,7 @@ export interface SidecarSetting { maxDescriptionsPerTurn?: number; timeoutMs?: number; } -export interface VisionModelOption { value: string; label: string; backend: SidecarBackend; baseline?: boolean } +export interface VisionModelOption { value: string; label: string; backend: VisionBackend; baseline?: boolean } export interface WebSearchModelOption { value: string; label: string; @@ -99,7 +108,7 @@ export interface SidecarData { export interface SidecarPatch { webSearch?: { backend?: SidecarBackend | null; model?: string; streamRoutedModelOutput?: boolean }; vision?: { - backend?: SidecarBackend | null; + backend?: VisionBackend | null; model?: string; reasoning?: VisionReasoning; enabled?: boolean; @@ -189,7 +198,7 @@ export function updateJobLabel(status: UpdateJobStatus, t: (key: TKey) => string export function mergeSidecarSetting( current: SidecarSetting, update?: { - backend?: SidecarBackend | null; + backend?: VisionBackend | null; model?: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean; @@ -357,8 +366,8 @@ export function visionModelOptions( serverOptions: VisionModelOption[] | undefined, models: ModelInfo[], current: string | undefined, - currentBackend?: SidecarBackend, -): Array<{ value: string; label: string; backend?: SidecarBackend }> { + currentBackend?: VisionBackend, +): Array<{ value: string; label: string; backend?: VisionBackend }> { const options = serverOptions ? serverOptions.map(option => ({ value: option.value, label: option.label, backend: option.backend })) : sidecarModelOptions(models); @@ -392,13 +401,22 @@ export function webSearchSidecarSelectionForModel( }; } -/** Server eligibility is authoritative; catalog inference only supports legacy picker entries. */ +/** + * Server eligibility is authoritative; catalog inference only supports legacy + * picker entries. A namespaced value ("provider/model") is the routed-backend + * option shape and must never collapse to a legacy backend — the openai + * executor would POST the namespaced string verbatim (the failure the file + * comment above warns about, in the other direction). + */ export function visionSidecarBackendForModel( models: ModelInfo[], - options: Array<{ value: string; backend?: SidecarBackend }>, + options: Array<{ value: string; backend?: VisionBackend }>, modelId: string, -): SidecarBackend { - return options.find(option => option.value === modelId)?.backend ?? sidecarBackendForModel(models, modelId); +): VisionBackend { + const fromServer = options.find(option => option.value === modelId)?.backend; + if (fromServer) return fromServer; + if (modelId.includes("/")) return "routed"; + return sidecarBackendForModel(models, modelId); } let lastInputWasKeyboard = false; diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 9c63d417ce..9e2c4cda3a 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -466,11 +466,14 @@ export function useDashboardData(apiBase: string) { }, [grouped, modelQuery]); const sidecarModels = useMemo(() => { // Server-computed runnable set when present (#2188); legacy union otherwise. + // The shared SidecarSetting type admits vision's "routed", which the + // web-search picker cannot carry — narrow it away for this card. + const webBackend = sidecar?.webSearch.backend; return webSearchModelOptionsForPicker( sidecar?.webSearchModels, models, sidecar?.webSearch.model, - sidecar?.webSearch.backend, + webBackend === "routed" ? undefined : webBackend, ); }, [models, sidecar?.webSearchModels, sidecar?.webSearch]); const visionModels = useMemo( diff --git a/gui/tests/logs-cost-lower-bound.test.ts b/gui/tests/logs-cost-lower-bound.test.ts new file mode 100644 index 0000000000..60759219fa --- /dev/null +++ b/gui/tests/logs-cost-lower-bound.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { DICTS } from "../src/i18n/catalogs"; +import { interpolate, type Locale, type TFn } from "../src/i18n/shared"; +import { + formatEstimatedUsd, + formatEstimatedUsdValue, + summarizeEstimatedCosts, +} from "../src/pages/logs-cost-format"; + +function translator(locale: Locale): TFn { + return (key, vars) => interpolate(DICTS[locale][key], vars); +} + +test("ordinary dashboard costs retain the estimate marker", () => { + expect(formatEstimatedUsdValue(0.77, translator("en"), "en-US", false)).toBe("~$0.7700"); +}); + +test("priority long-context lower bounds render with a greater-than-or-equal marker", () => { + expect(formatEstimatedUsdValue(0.77, translator("en"), "en-US", true)).toBe("≥$0.7700"); +}); + +test("USD placement and separators follow a non-English locale", () => { + expect(formatEstimatedUsdValue(0.77, translator("de"), "de-DE", false)).toBe("ca. 0,7700\u00a0$"); + expect(formatEstimatedUsd({ kind: "unavailable" }, translator("de"), "de-DE")).toBe("nicht verfügbar"); +}); + +describe("conversation cost lower-bound aggregation", () => { + const priced = (total: number, lowerBound: boolean) => ({ + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value" as const, + estimate: { cost: { total }, priorityLowerBound: lowerBound }, + }, + }, + }); + + test("marks a total only when every included priced estimate is a lower bound", () => { + expect(summarizeEstimatedCosts([priced(0.77, true), priced(1.23, true)])).toMatchObject({ + estimatedCostUsd: 2, + priorityLowerBound: true, + }); + expect(summarizeEstimatedCosts([priced(0.77, true), priced(1.23, false)])).toMatchObject({ + estimatedCostUsd: 2, + priorityLowerBound: false, + }); + }); + + test("preserves unpriced and unsupported exclusions without minting a lower bound", () => { + expect(summarizeEstimatedCosts([ + { usageStatus: "reported", displayMetrics: { cost: { kind: "unavailable" } } }, + { usageStatus: "unsupported" }, + ])).toEqual({ + estimatedCostUsd: 0, + priorityLowerBound: false, + unpricedRequests: 1, + unmeteredRequests: 1, + }); + }); +}); diff --git a/package.json b/package.json index ecb4a81645..6793a45f48 100644 --- a/package.json +++ b/package.json @@ -62,11 +62,11 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.3.14", + "bun": "1.4.0", "zod": "4.4.3" }, "devDependencies": { - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "typescript": "7.0.2" }, "overrides": { diff --git a/scripts/ci/run-bun-test-batches.sh b/scripts/ci/run-bun-test-batches.sh index fb61977568..bfa6ee4457 100644 --- a/scripts/ci/run-bun-test-batches.sh +++ b/scripts/ci/run-bun-test-batches.sh @@ -5,6 +5,11 @@ readonly SHARD_SPEC="${1:-}" readonly BATCH_SIZE="${BUN_TEST_BATCH_SIZE:-12}" readonly BATCH_TIMEOUT_SECONDS="${BUN_TEST_BATCH_TIMEOUT_SECONDS:-120}" readonly BATCH_KILL_GRACE_SECONDS="${BUN_TEST_BATCH_KILL_GRACE_SECONDS:-15}" +# Runtime under test. Defaults to whatever `bun` PATH resolves to; the Bun 1.4 +# qualification lane sets OPENCODEX_BUN_PATH so the batches actually execute on +# the candidate binary. Without this the lane would export an override, run the +# bundled stable runtime anyway, and report a qualification it never performed. +readonly BUN_BIN="${OPENCODEX_BUN_PATH:-bun}" usage() { echo "usage: $0 " >&2 @@ -106,7 +111,7 @@ run_test_once() { set +e timeout --signal=TERM --kill-after="${BATCH_KILL_GRACE_SECONDS}s" \ "${BATCH_TIMEOUT_SECONDS}s" \ - bun test --isolate --timeout 60000 "${files[@]}" 2>&1 | tee "$log_file" + "$BUN_BIN" test --isolate --timeout 60000 "${files[@]}" 2>&1 | tee "$log_file" status="${PIPESTATUS[0]}" set -e diff --git a/scripts/release.ts b/scripts/release.ts index e61bc22a8e..c8cc524a21 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -15,6 +15,13 @@ * bun scripts/release.ts 0.1.0 --publish # actually publish 0.1.0 * * Requires: gh CLI (authed). Publishing is tokenless via Trusted Publishing (OIDC) — no NPM_TOKEN. + * + * Protected-branch push: `main` and `preview` carry rulesets that require a pull request, and the + * admin bypass is `bypass_mode: "pull_request"` — enough to merge a PR, not enough to push. Set + * `OCX_RELEASE_SSH_KEY` to the private key of the dedicated write deploy key registered as a + * `DeployKey` bypass actor on those rulesets, and the version-bump push (and only that push) uses + * it. Override the SSH remote with `OCX_RELEASE_SSH_REPO` when releasing a fork. Unset, the push + * behaves exactly as before. */ import { commandInvocation } from "../src/lib/win-exec"; @@ -86,12 +93,13 @@ async function capture(command: string[]): Promise { } /** Run a command with its output attached to this terminal; abort on failure. */ -async function runLoud(command: string[]): Promise { +async function runLoud(command: string[], env?: Record): Promise { const [bin, ...rest] = command; const invocation = commandInvocation(bin ?? "", rest); const proc = Bun.spawn([invocation.file, ...invocation.args], { stdout: "inherit", stderr: "inherit", + ...(env ? { env: { ...process.env, ...env } } : {}), ...(invocation.options.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), }); const exitCode = await proc.exited; @@ -101,6 +109,142 @@ async function runLoud(command: string[]): Promise { } } +/** + * Release-key push target for a protected branch. + * + * `main` and `preview` are covered by branch-protection rulesets that require a pull request, + * so the maintainer's own credential cannot push the version-bump commit even with admin rights: + * the admin bypass is `bypass_mode: "pull_request"`, which permits merging a PR but not a direct + * push. The v2.29.0 release died exactly there. + * + * The carve-out is a dedicated write deploy key registered as a `DeployKey` bypass actor on both + * rulesets. It is deliberately NOT a runtime toggle of the ruleset itself: flipping protection off + * around the push and back on afterwards is crash-open — a SIGKILL, a lost network, or a hung push + * between the two calls leaves the branch unprotected with no lease to expire it, and while the + * window is open the bypass applies to every holder of the admin role, not just this release. A + * key fails closed instead: if the process dies, protection was never weakened, and revoking one + * credential closes the carve-out without touching repository configuration. + * + * Opt-in by path: without `OCX_RELEASE_SSH_KEY` the push runs exactly as before over the configured + * remote, so a contributor or CI clone is unaffected. The key is used for this one push and nothing + * else; ordinary git operations keep the maintainer's normal credential. + */ +/** + * Quote one argument for `GIT_SSH_COMMAND`. + * + * Git does not exec this variable directly — it parses it with shell-style word splitting, so a + * bare interpolation breaks on any key path containing a space (`C:\Users\Jun Kim\.ssh\key` splits + * into two words and ssh reads `Kim...` as its next flag). Double quotes are the form both POSIX + * shells and Git's own Windows parser accept, and unlike single quotes they do not mangle a + * backslash path. Escape the characters that stay special inside double quotes so a path can never + * introduce a second word or a substitution. + */ +function quoteSshArgument(value: string): string { + return `"${value.replace(/(["\\`$])/g, "\\$1")}"`; +} + +/** + * Derive the SSH push target from the configured `origin` URL. + * + * Deliberately derived rather than hardcoded: a hardcoded `git@host:owner/repo.git` literal is + * indistinguishable from an email address to `privacy:scan`, and it would also silently push a + * fork's release to the upstream repository. `OCX_RELEASE_SSH_REPO` still wins when a maintainer + * needs an explicit target. + */ +function sshTargetFromOrigin(originUrl: string): string | undefined { + const trimmed = originUrl.trim(); + if (!trimmed) return undefined; + // Reject a credential-bearing remote outright rather than transplanting it. A URL like + // https://user:TOKEN@host/o/r.git would otherwise fold the userinfo into the SSH target, and + // runLoud() prints the failing command — putting the token on the terminal and in the release + // log. The host capture below therefore excludes '@' as well as '/'. + const https = /^https?:\/\/([^/@]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed); + if (https) return `${SSH_USER}@${https[1]}:${https[2]}.git`; + if (/^https?:\/\//.test(trimmed)) { + console.error("✗ origin carries credentials in its URL; refusing to build a release push target from it."); + process.exit(1); + } + // Already an SSH remote (either scp-like or ssh://): reuse it verbatim. + if (isSshRemote(trimmed)) return trimmed; + return undefined; +} + +/** + * `ssh://host/owner/repo` or the scp-like `user@host:owner/repo`. + * + * This check is also a log boundary: the accepted value is printed before the push and appears in + * the failure command. Parse URL userinfo instead of treating any `ssh://` string as safe, and + * reject the scp-like `user:password@host:path` lookalike before either sink can observe it. + */ +function isSshRemote(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) return false; + + if (trimmed.startsWith("ssh://")) { + // WHATWG URL collapses an empty password ("git:@host" -> password ""), so the parsed fields + // cannot distinguish it from a credential-free principal. Reject any ':' in the raw userinfo + // segment instead: a colon there is always credential-shaped. + const authority = trimmed.slice("ssh://".length); + const userinfoEnd = authority.indexOf("@"); + if (userinfoEnd !== -1 && authority.slice(0, userinfoEnd).includes(":")) return false; + try { + const parsed = new URL(trimmed); + let decodedUsername: string; + try { + decodedUsername = decodeURIComponent(parsed.username); + } catch { + return false; + } + return parsed.protocol === "ssh:" + && parsed.hostname.length > 0 + && parsed.pathname.length > 1 + && parsed.password === "" + // The release deploy key uses GitHub's fixed SSH principal. Treat any other userinfo as + // credential-shaped rather than trying to distinguish a harmless username from a token. + && (decodedUsername === "" || decodedUsername === SSH_USER) + && parsed.search === "" + && parsed.hash === ""; + } catch { + return false; + } + } + + // scp-like syntax has no parser-level query/fragment boundary. Reject those delimiters and any + // second '@' in the host segment rather than allowing a credential-shaped suffix to reach the + // target log or failed-command output. + return /^git@[^:@\s/?#]+:[^?#]+$/.test(trimmed); +} + +/** Split out so the scp-like SSH target is assembled rather than written as an address literal. */ +const SSH_USER = "git"; + +async function releasePushCommand(branch: string): Promise<{ command: string[]; env?: Record }> { + const keyPath = process.env.OCX_RELEASE_SSH_KEY?.trim(); + if (!keyPath) return { command: ["git", "push", "origin", branch] }; + const configured = process.env.OCX_RELEASE_SSH_REPO?.trim(); + // An unvalidated override outranking origin means a stale exported value from a fork session can + // silently retarget a production release. Check the shape, and print the resolved target either + // way so the destination is visible before the push rather than inferred afterwards. + if (configured && !isSshRemote(configured)) { + console.error("✗ OCX_RELEASE_SSH_REPO is not a credential-free ssh:// or git@host:owner/repo remote; refusing to push."); + process.exit(1); + } + const slug = configured || sshTargetFromOrigin(await capture(["git", "remote", "get-url", "origin"])); + if (!slug) { + console.error("✗ OCX_RELEASE_SSH_KEY is set but no SSH push target could be derived from origin; set OCX_RELEASE_SSH_REPO."); + process.exit(1); + } + console.log(`→ release push target: ${slug}`); + return { + // Push to the SSH URL explicitly rather than rewriting the `origin` remote: the remote stays + // HTTPS for every other command, so nothing outside this call inherits the key. + command: ["git", "push", slug, `HEAD:${branch}`], + // IdentitiesOnly stops ssh from offering the agent's other keys first, which would authenticate + // as the maintainer and get rejected by the ruleset again. + env: { GIT_SSH_COMMAND: `ssh -i ${quoteSshArgument(keyPath)} -o IdentitiesOnly=yes` }, + }; +} + async function readPackageName(): Promise { try { const pkg = JSON.parse(await Bun.file("package.json").text()) as { name?: unknown }; @@ -406,7 +550,9 @@ if (pendingBump) { const releaseSha = await capture(["git", "rev-parse", "HEAD"]); if (pendingBump) { console.log(`→ push origin ${branch}`); - await runLoud(["git", "push", "origin", branch]); + const push = await releasePushCommand(branch); + if (push.env) console.log("→ using the release deploy key for the protected push"); + await runLoud(push.command, push.env); } else { console.log(`→ release commit ${releaseSha.slice(0, 9)} already pushed; reusing it`); } diff --git a/scripts/restart-codex-desktop-app.ps1 b/scripts/restart-codex-desktop-app.ps1 new file mode 100644 index 0000000000..8675f69418 --- /dev/null +++ b/scripts/restart-codex-desktop-app.ps1 @@ -0,0 +1,102 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Fully restarts the Windows Codex desktop app (MSIX package) so the model + picker re-reads the on-disk catalog after ocx sync. +.NOTES + Run this from an external terminal. Running it from inside a Codex + conversation kills the app hosting that conversation. +#> +[CmdletBinding()] +param( + [switch]$DryRun, + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +$PackageFamily = "OpenAI.Codex_2p2nqsd0c76g0" +$Aumid = "OpenAI.Codex_2p2nqsd0c76g0!App" + +Import-Module Appx -ErrorAction SilentlyContinue +$pkg = Get-AppxPackage -Name OpenAI.Codex | Where-Object { $_.PackageFamilyName -eq $PackageFamily } +if (-not $pkg -or -not $pkg.InstallLocation) { + Write-Error "MSIX package $PackageFamily was not found; nothing to restart." + exit 1 +} +$InstallLoc = $pkg.InstallLocation + +$nameFilter = "Name='ChatGPT.exe' OR Name='codex.exe' OR Name='codex-code-mode-host.exe'" +$all = @(Get-CimInstance -ClassName Win32_Process -Filter $nameFilter) +$targets = @($all | Where-Object { + $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallLoc, [System.StringComparison]::OrdinalIgnoreCase) +}) + +if ($targets.Count -eq 0) { + Write-Host "Codex desktop app is not running." + exit 0 +} + +$targetIds = @{} +foreach ($t in $targets) { $targetIds[[uint32]$t.ProcessId] = $t } + +# Roots are targets whose parent is outside the package tree; killing each +# root with taskkill /T cascades to codex.exe and its code-mode-host child. +$roots = @($targets | Where-Object { -not $targetIds.ContainsKey([uint32]$_.ParentProcessId) }) + +# Self-kill guard: never target our own ancestry. Skipped under -DryRun so the +# report stays useful when Codex itself launched this script. +$ancestry = @{} +if (-not $DryRun) { + $cursor = $PID + while ($cursor) { + $ancestry[[uint32]$cursor] = $true + $parent = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$cursor").ParentProcessId + if ($parent -and -not $ancestry.ContainsKey([uint32]$parent)) { $cursor = $parent } else { break } + } + foreach ($r in $roots) { + if ($ancestry.ContainsKey([uint32]$r.ProcessId)) { + Write-Error "Refusing to restart: selected root PID $($r.ProcessId) is an ancestor of this script." + exit 1 + } + } +} + +Write-Host ("Targets ({0}):" -f $targets.Count) +foreach ($t in $targets) { + Write-Host (" PID {0} {1} parent={2}" -f $t.ProcessId, $t.Name, $t.ParentProcessId) +} +Write-Host ("Root(s) to stop: {0}" -f (($roots | ForEach-Object { $_.ProcessId }) -join ", ")) +Write-Host ('Relaunch command: Start-Process "shell:AppsFolder\{0}"' -f $Aumid) + +if ($DryRun) { + Write-Host "Dry run: nothing was stopped or launched." + exit 0 +} + +foreach ($r in $roots) { + $rootPid = [uint32]$r.ProcessId + $stopped = $false + if (-not $Force) { + $proc = Get-Process -Id $rootPid -ErrorAction SilentlyContinue + if ($proc -and $proc.MainWindowHandle -ne 0) { + Write-Host "Sending graceful close to PID $rootPid..." + [void]$proc.CloseMainWindow() + for ($i = 0; $i -lt 15; $i++) { + Start-Sleep -Seconds 1 + if (-not (Get-Process -Id $rootPid -ErrorAction SilentlyContinue)) { $stopped = $true; break } + } + if (-not $stopped) { + Write-Host "PID $rootPid survived graceful close (close-to-tray suspected); forcing." + } + } + } + if (-not $stopped) { + Write-Host "Force-stopping process tree at PID $rootPid..." + & "$env:SystemRoot\System32\taskkill.exe" /PID $rootPid /T /F | Out-Null + } +} + +Start-Sleep -Seconds 1 +Start-Process "shell:AppsFolder\$Aumid" +Write-Host "Codex desktop app restarted." diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index a6cb5cbf9d..4089e81ed3 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,8 +3,8 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors"; -import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery"; +import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { @@ -25,6 +25,7 @@ import { invalidateCursorCheckpoint, } from "./cursor/checkpoint-store"; import { debugProviderDiagnostic } from "../lib/debug"; +import { estimateTokens } from "../lib/token-estimate"; import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { @@ -53,16 +54,29 @@ export interface CursorAdapterDeps { rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void; } -function safeCursorTransportError(err: unknown): string { +function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string { if (err instanceof CursorTransportDisabledError) return CURSOR_TRANSPORT_DISABLED_MESSAGE; if (err instanceof CursorMissingCredentialError) { return "Cursor live transport is enabled, but no Cursor access token is configured. Set provider.apiKey or OPENCODEX_CURSOR_TEST_TOKEN."; } const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined; - if (message) return safeCursorErrorMessage(message); + if (message) return safeCursorErrorMessage(message, sizeContext); return "Cursor upstream error: transport failed before completion."; } +/** + * Size prior for bare resource_exhausted classification (devlog 260): a rough input + * estimate over the outgoing text vs the model's context window. Only used to keep + * SMALL requests on the 429 class — unknown/large stays on the overflow mapping. + */ +function cursorRequestSizeContext(request: { modelId: string; system: string[]; messages: { content: string }[] }): CursorSizeContext { + const text = [...request.system, ...request.messages.map(message => message.content)].join("\n"); + return { + estimatedInputTokens: estimateTokens(text, request.modelId), + contextWindow: inferCursorContextWindow(request.modelId), + }; +} + export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAdapterDeps = {}): ProviderAdapter { return { name: "cursor", @@ -88,6 +102,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda emit({ type: "error", message: "Cursor turn was aborted before start." }); return; } + // Captured after createCursorRequest so the catch block can apply the bare-RE + // size prior (devlog 260) even though `request` is scoped inside the try. + let requestSizeContext: CursorSizeContext | undefined; try { const makeTransport = deps.createTransport ?? createLiveCursorTransport; const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget); @@ -110,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = createCursorRequest(_parsed); + requestSizeContext = cursorRequestSizeContext(request); // The builder may derive a stable provider id from the client thread when Responses state // is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn, // and isolated helper/compaction turns must never inherit or donate the parent's usage state. @@ -292,7 +310,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda type: "error", message: isTranslatorBudgetExceededError(err) ? "upstream translation buffer exceeded the safe limit" - : safeCursorTransportError(err), + : safeCursorTransportError(err, requestSizeContext), ...(isTranslatorBudgetExceededError(err) ? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" } : {}), diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index f2e13c578c..33ded8dc7c 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -112,6 +112,58 @@ export function isCursorInvalidArgumentError(value: unknown): boolean { } const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit", "throttl"]; +/** + * A bare `resource_exhausted` end-stream with no detail beyond a generic error wrapper + * ("Error" or empty tail) and zero tokens billed is the shape Cursor's backend emits when + * the request payload exceeded its context window — not when quota ran out (senpi #1009, + * #1036: same wording, two causes). Quota rejections always carry an explicit rate cue + * ("too many requests", "quota exhausted"), so the ABSENCE of those cues plus the + * absence of a size phrase means payload overflow. Classifying it as 429 makes Codex + * back off instead of compacting, which burns retries on an unfixable-by-retry failure. + */ +const BARE_RE_TAILS = new Set(["error", "", "resource_exhausted", "resource exhausted"]); + +/** + * Size prior for bare resource_exhausted classification (devlog 260, live probe 210): + * a plan-gated model returns the SAME bare RE shape on a ~20-token prompt that a real + * payload overflow produces, so the message alone cannot separate "compact and retry" + * from "this account cannot use this model". When the caller can supply how large the + * request actually was relative to the model's window, a small request keeps the + * 429-class mapping; only a plausibly-large one classifies as overflow. Unknown + * sizes keep today's overflow mapping so the prior only ever REMOVES false overflows + * it can prove. + */ +export interface CursorSizeContext { + estimatedInputTokens?: number; + contextWindow?: number; +} + +const OVERFLOW_MIN_FRACTION = 0.5; + +function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean { + if (!context) return true; + const { estimatedInputTokens, contextWindow } = context; + if (estimatedInputTokens === undefined || contextWindow === undefined || contextWindow <= 0) return true; + return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow; +} + +export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean { + if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false; + // Any explicit quota/rate cue wins: this is a real 429. + if (QUOTA_RATE_CUES.some(cue => lowerMessage.includes(cue))) return false; + // An explicit size phrase also wins (already handled by the existing classifier). + if (isCursorRequestTooLargeDetail(lowerMessage)) return false; + // Extract the tail after the resource_exhausted marker. If it names a specific + // non-quota, non-size cause, this is NOT bare overflow. + const idx = Math.max( + lowerMessage.indexOf("resource_exhausted"), + lowerMessage.indexOf("resource exhausted"), + ); + const tail = lowerMessage.slice(idx + "resource_exhausted".length).trim().replace(/^[:\s]+/, "").trim(); + if (!BARE_RE_TAILS.has(tail)) return false; + return true; +} + const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [ "tool catalog too large", "tool registration too large", @@ -144,7 +196,7 @@ export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean { * The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords, * so bridge-level error mapping produces the right Codex error type (rate_limit, auth, etc.). */ -export function classifyCursorError(message: string): string { +export function classifyCursorError(message: string, sizeContext?: CursorSizeContext): string { const lower = message.toLowerCase(); if (isCursorBenignCancelError(message)) return "Cursor stream suspended"; @@ -158,9 +210,16 @@ export function classifyCursorError(message: string): string { // client-fixable 400; everything else surfaces as a 429 so Codex backs off // instead of hammering retries (live evidence: 6x 400 retry storm, devlog // 260723_cursor_context_continuity/000_plan.md). - return isCursorRequestTooLargeDetail(lower) - ? "Cursor resource limit exceeded" - : "Cursor rate limit exceeded"; + if (isCursorRequestTooLargeDetail(lower)) return "Cursor resource limit exceeded"; + // A bare resource_exhausted with no quota cue and no size phrase is payload + // overflow, not rate limiting. Classifying it as 429 makes Codex back off on a + // failure that only compaction can fix (senpi #1009 / #1036; research unit T01). + // Refinement (devlog 260): plan-gated models emit the same bare shape on tiny + // requests — when the caller proves the request was small, keep the 429 class. + if (isCursorZeroTokenResourceExhausted(lower)) { + return bareReLooksLikeOverflow(sizeContext) ? "Cursor context limit exceeded" : "Cursor rate limit exceeded"; + } + return "Cursor rate limit exceeded"; } if ( @@ -220,8 +279,8 @@ export function classifyCursorError(message: string): string { * Produce a user-facing, secret-safe Cursor error message with an actionable category prefix. * Mirrors `safeKiroErrorMessage` / `safeKiroHttpErrorMessage` in kiro-errors.ts. */ -export function safeCursorErrorMessage(rawMessage: string): string { - const prefix = classifyCursorError(rawMessage); +export function safeCursorErrorMessage(rawMessage: string, sizeContext?: CursorSizeContext): string { + const prefix = classifyCursorError(rawMessage, sizeContext); const detail = sanitize(rawMessage) .replace(/resource[_ ]exhausted/gi, "resource limit exceeded") .slice(0, 500); diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index a530df2d94..119827dc6a 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -103,6 +103,28 @@ export const CURSOR_ROUTER_MODEL_IDS = [ ...CURSOR_ROUTING_LEVELS.map(level => `${CURSOR_AUTO_MODEL_ID}-${level}`), ] as const; +/** + * Cursor models that cannot see images natively. OpenCodex routes them through the vision + * sidecar (the catalog still advertises image so Codex can attach). Evidence: + * - Composer family: Cursor staff — text-only; "Model does not support images" + * - Auto / router modes: Cursor docs omit Images for Auto Cost; staff — pick Claude/GPT for images + * - glm-5.2: Cursor docs omit Images; Z.ai GLM-5.2 is text-only (vision is GLM-5V) + * - glm-5.3: same family; seeded as text-only ahead of Cursor's lineup update + * + * Composer ids are enumerated explicitly — prefix wildcard matching is deliberately out of + * scope here; a live-discovered new Composer slug stays native-path until curated. Everyone + * else in the static seed (Claude, Gemini, GPT, Kimi, Grok) takes SelectedImage. Other + * live-discovered ids stay unclassified (native path) until curated. + */ +export const CURSOR_NO_VISION_MODELS = [ + ...CURSOR_ROUTER_MODEL_IDS, + "composer-1", + "composer-2.5", + "composer-2.5-fast", + "glm-5.2", + "glm-5.3", +] as const; + /** Wire id Cursor Connect expects for the auto-router (GetUsableModels returns `default`, not `auto`). */ export const CURSOR_AUTO_WIRE_MODEL_ID = "default"; @@ -214,10 +236,15 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM { id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - // opus-4-7-fast: effort-suffix tiers unverified -> no tier picker; sent bare like live-only ids. - { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K }, + // Opus Fast families: live GetUsableModels (260822) lists ONLY effort-suffixed wire ids + // ({base-without-fast}-{effort}-fast; the bare id returns not_found), so every entry + // carries a tier picker. Live-verified: claude-opus-4-8-high-fast completed a turn. + // Tiers per the 260822 dump (devlog 260822_senpi_cursor_transfer/300). + { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "composer-1", contextWindow: CONTEXT_200K }, diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index 346ee4ef2c..979c34e7c5 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -24,8 +24,14 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // against Anthropic's effort ladder docs and Cursor's live model lineup. "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + // Opus Fast tiers from the 260822 GetUsableModels dump (devlog .../300): the wire + // exposes {base-without-fast}-{effort}-fast only; suffix derivation at the bottom of + // this file produces those ids. opus-5-fast has no xhigh/max (non-thinking) yet. + "claude-opus-4-7-fast": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8-fast": ["low", "medium", "high", "xhigh", "max"], "claude-opus-5": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-5-fast": ["low", "medium", "high"], "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], "glm-5.2": ["high", "max"], // 260814 preemptive: glm-5.3 seeded ahead of Cursor's lineup update. Unlike 5.2, Z.AI folds diff --git a/src/adapters/cursor/h2-pool.ts b/src/adapters/cursor/h2-pool.ts new file mode 100644 index 0000000000..36e94e7566 --- /dev/null +++ b/src/adapters/cursor/h2-pool.ts @@ -0,0 +1,123 @@ +import http2 from "node:http2"; +import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks"; + +const DEFAULT_MAX_SESSIONS = 8; +const SESSION_CLOSE_TIMEOUT_MS = 2_000; + +interface PoolEntry { + readonly session: http2.ClientHttp2Session; + readonly streams: Set; + usable: boolean; +} + +/** + * HTTP/2 connection pool for Cursor Connect DISCOVERY calls (GetUsableModels). + * Sessions are keyed by origin (scheme+host+port) and reused to avoid fresh + * TCP+TLS per call. The Run path deliberately dials its own session: Run + * streams are long-lived bidi whose lifecycle/EOF semantics are owned by + * live-transport (see devlog 260822_senpi_cursor_transfer/190 — Run-path + * pooling is a separate, deliberate unit if ever taken). + */ +export class CursorH2SessionPool { + private readonly entries = new Map(); + private closed = false; + + constructor(private readonly maxSessions = DEFAULT_MAX_SESSIONS) {} + + /** + * Lazily registered on first use so a process that never talks to Cursor registers + * nothing (optional-subsystem doctrine). The seam is synchronous and best-effort; + * shutdown() is fire-and-forget there because lifecycle's drainAndShutdown runs + * under its own absolute deadline. + */ + private armShutdownHook: (() => void) | undefined = () => { + this.armShutdownHook = undefined; + registerOptionalShutdownHook("cursor-h2-pool", () => { void this.shutdown(); }); + }; + + request( + url: string, + headers: http2.OutgoingHttpHeaders, + ): http2.ClientHttp2Stream { + if (this.closed) throw new Error("Cursor H2 session pool is closed"); + this.armShutdownHook?.(); + const origin = new URL(url).origin; + const entry = this.usableEntry(origin) ?? this.createEntry(origin); + try { + const stream = entry.session.request(headers); + entry.streams.add(stream); + stream.once("close", () => { entry.streams.delete(stream); }); + return stream; + } catch (error) { + this.drain(entry, true); + throw error; + } + } + + async shutdown(): Promise { + if (this.closed) return; + this.closed = true; + const pending: Promise[] = []; + for (const entry of [...this.entries.values()]) { + for (const stream of [...entry.streams]) stream.destroy(); + entry.session.close(); + if (entry.session.destroyed) continue; + pending.push(new Promise(resolve => { + const timer = setTimeout(resolve, SESSION_CLOSE_TIMEOUT_MS); + timer.unref?.(); + entry.session.once("close", () => { clearTimeout(timer); resolve(); }); + })); + } + this.entries.clear(); + await Promise.all(pending); + } + + get size(): number { return this.entries.size; } + + private usableEntry(origin: string): PoolEntry | undefined { + const entry = this.entries.get(origin); + if (!entry) return undefined; + if (entry.usable && !entry.session.closed && !entry.session.destroyed) return entry; + this.drain(entry, false); + return undefined; + } + + private createEntry(origin: string): PoolEntry { + const session = http2.connect(origin); + const entry: PoolEntry = { + session, + streams: new Set(), + usable: true, + }; + this.entries.set(origin, entry); + session.once("goaway", () => { this.drain(entry, true); }); + session.on("error", () => { this.drain(entry, false); }); + session.once("close", () => { + // Identity check: a stale close event from an old session must not evict + // a healthy replacement entry that was created after drain() removed the old one. + if (this.entries.get(origin) === entry) this.entries.delete(origin); + }); + // Enforce bound: evict oldest when over capacity. + while (this.entries.size > this.maxSessions) { + const oldest = this.entries.keys().next().value; + if (!oldest || oldest === origin) break; + const old = this.entries.get(oldest); + if (old) this.drain(old, true); + } + return entry; + } + + private drain(entry: PoolEntry, closeSession: boolean): void { + entry.usable = false; + for (const stream of [...entry.streams]) stream.destroy(); + entry.streams.clear(); + if (closeSession) entry.session.close(); + // Remove from map by finding the matching key. + for (const [key, value] of this.entries) { + if (value === entry) { this.entries.delete(key); break; } + } + } +} + +/** Shared singleton pool for all Cursor adapter H2 traffic. */ +export const cursorH2Pool = new CursorH2SessionPool(); diff --git a/src/adapters/cursor/images.ts b/src/adapters/cursor/images.ts new file mode 100644 index 0000000000..84bb303f04 --- /dev/null +++ b/src/adapters/cursor/images.ts @@ -0,0 +1,704 @@ +import { randomUUID } from "node:crypto"; +import { create } from "@bufbuild/protobuf"; +import type { OcxContentPart, OcxImageContent, OcxMessage } from "../../types"; +import { + SelectedContextSchema, + SelectedImageSchema, + SelectedImage_BlobIdWithDataSchema, + SelectedImage_DimensionSchema, + type SelectedContext, + type SelectedImage, +} from "./gen/agent_pb"; +import { + storeCursorBlob, + type CursorBlobRequestScopeToken, +} from "./native-exec"; + +/** Final per-image byte cap after prep (OmniRoute / composer-api style). */ +export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024; + +/** + * Inbound decode/fetch bomb ceiling before JPEG prep. Large clipboard PNGs may exceed + * {@link MAX_CURSOR_IMAGE_BYTES} raw but shrink under the wire cap after re-encode. + */ +export const MAX_CURSOR_IMAGE_DECODE_BYTES = 16 * 1024 * 1024; + +/** + * Soft target for Cursor vision hydration. Live A/B: ~430 KiB PNG failed ("gray"/wrong UI) + * while the same visual as ~75 KiB JPEG succeeded. Prefer JPEG at or under this size. + */ +export const CURSOR_VISION_SOFT_MAX_BYTES = 100 * 1024; + +/** Soft target when the client requests `detail: original` or `high`. */ +export const CURSOR_VISION_SOFT_MAX_BYTES_HIGH = 256 * 1024; + +/** Longest edge after Cursor vision prep (Cursor staff guidance: ≤ 2000 px). */ +export const CURSOR_VISION_MAX_EDGE = 2000; + +/** + * Decode bomb: reject images whose sniffed longest edge exceeds this before Bun.Image. + * Separate from {@link CURSOR_VISION_MAX_EDGE} (output resize target). + */ +export const MAX_CURSOR_IMAGE_DECODE_EDGE = 8192; + +/** Decode bomb: reject images whose sniffed pixel count exceeds this before Bun.Image. */ +export const MAX_CURSOR_IMAGE_PIXELS = 25_000_000; + +const CURSOR_VISION_JPEG_QUALITIES_DEFAULT = [85, 70, 55, 40] as const; +const CURSOR_VISION_JPEG_QUALITIES_HIGH = [90, 80, 65, 50] as const; +/** Stop shrinking below this longest edge when chasing the soft byte cap. */ +const CURSOR_VISION_SOFT_MIN_EDGE = 256; +const CURSOR_VISION_SOFT_SHRINK = 0.85; + +const CURSOR_VISION_PASSTHROUGH_MIME = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +]); + +/** Upper bound on images attached to one Cursor turn. */ +export const MAX_CURSOR_IMAGES = 12; + +/** Marker when an image cannot be prepared for the Cursor vision wire. */ +export const CURSOR_VISION_IMAGE_OMITTED = + "[image omitted: undecodable or unsupported type]"; + +/** Short text-only stand-in for an image part on replayed (historical) turns. Never includes bytes. */ +export const CURSOR_VISION_IMAGE_HISTORY_MARKER = "[image attached]"; + +export class CursorImageError extends Error { + readonly status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "CursorImageError"; + this.status = status; + } +} + +export interface ResolvedCursorImage { + data: Uint8Array; + mimeType: string; + uuid: string; + /** Codex/OpenAI image detail hint; affects JPEG soft-cap tier. */ + detail?: string; +} + +export type PrepareCursorImageOutcome = + | { status: "ready"; image: ResolvedCursorImage } + | { status: "omitted"; reason: string }; + +function isImagePart(part: OcxContentPart): part is OcxImageContent { + return part.type === "image"; +} + +function estimatedBase64DecodedBytes(payload: string): number { + return Math.floor((payload.length * 3) / 4); +} + +function isHighDetail(detail: string | undefined): boolean { + const normalized = (detail ?? "").trim().toLowerCase(); + return normalized === "original" || normalized === "high"; +} + +function softMaxBytesForDetail(detail: string | undefined): number { + return isHighDetail(detail) ? CURSOR_VISION_SOFT_MAX_BYTES_HIGH : CURSOR_VISION_SOFT_MAX_BYTES; +} + +function jpegQualitiesForDetail(detail: string | undefined): readonly number[] { + return isHighDetail(detail) ? CURSOR_VISION_JPEG_QUALITIES_HIGH : CURSOR_VISION_JPEG_QUALITIES_DEFAULT; +} + +export function decodeCursorImageDataUrl(url: string): { data: Uint8Array; mimeType: string } { + const comma = url.indexOf(","); + if (comma < 0) throw new CursorImageError("Image data URL is malformed."); + const header = url.slice(5, comma); + const payload = url.slice(comma + 1); + const isBase64 = /;base64/i.test(header); + const mimeType = (header.split(";")[0] || "").trim().toLowerCase() || "application/octet-stream"; + + if (!mimeType.startsWith("image/")) { + throw new CursorImageError("Image data URL must have an image/* media type."); + } + if (!isBase64) { + throw new CursorImageError("Image data URL must be base64-encoded."); + } + if (payload.length > MAX_CURSOR_IMAGE_DECODE_BYTES * 2) { + throw new CursorImageError("Image input is too large to process safely."); + } + + const normalized = payload.replace(/\s/g, ""); + if (normalized.length === 0) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + // Reject lenient Buffer.from acceptances (wrong alphabet, bad padding, truncated groups). + if (normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (estimatedBase64DecodedBytes(normalized) > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); + } + + let data: Uint8Array; + try { + data = Buffer.from(normalized, "base64"); + } catch { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (data.byteLength === 0) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + // Round-trip guard: Node/Bun can silently drop trailing garbage. + if (Buffer.from(data).toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (data.byteLength > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); + } + return { data, mimeType }; +} + +function throwIfImagePhaseAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const err = new Error("Cursor image phase aborted"); + err.name = "AbortError"; + throw err; +} + +/** Magic-byte format sniff (independent of declared MIME). */ +export function sniffCursorImageFormat( + data: Uint8Array, +): "png" | "jpeg" | "gif" | "webp" | undefined { + if ( + data.byteLength >= 8 + && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 + && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a + ) { + return "png"; + } + if ( + data.byteLength >= 6 + && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38 + ) { + return "gif"; + } + if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) return "jpeg"; + if ( + data.byteLength >= 12 + && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 + && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50 + ) { + return "webp"; + } + return undefined; +} + +/** Collect image URLs from one message's content parts, preserving order. */ +export function extractCursorImageUrls(content: string | readonly OcxContentPart[]): string[] { + return extractCursorImageParts(content).map(part => part.imageUrl); +} + +export interface CursorImagePartRef { + imageUrl: string; + detail?: string; +} + +/** Collect image parts (URL + optional detail) from one message's content. */ +export function extractCursorImageParts( + content: string | readonly OcxContentPart[], +): CursorImagePartRef[] { + if (typeof content === "string" || !Array.isArray(content)) return []; + const parts: CursorImagePartRef[] = []; + for (const part of content) { + if (isImagePart(part) && typeof part.imageUrl === "string" && part.imageUrl.length > 0) { + parts.push({ + imageUrl: part.imageUrl, + ...(typeof part.detail === "string" && part.detail.length > 0 ? { detail: part.detail } : {}), + }); + } + } + return parts; +} + +/** + * Resolve OpenCodex image parts (data: URLs only) into bytes for SelectedImage. + * Prep (JPEG soft-cap) runs before the 1 MiB wire cap so large clipboard PNGs can shrink. + * Unsupported / undecodable images are omitted (fail-closed). + */ +export async function resolveCursorImages( + imageUrls: readonly string[], + signal?: AbortSignal, + options?: { details?: readonly (string | undefined)[] }, +): Promise { + if (imageUrls.length > MAX_CURSOR_IMAGES) { + throw new CursorImageError(`Too many images in one request (max ${MAX_CURSOR_IMAGES}).`); + } + + const out: ResolvedCursorImage[] = []; + for (let i = 0; i < imageUrls.length; i++) { + throwIfImagePhaseAborted(signal); + const url = imageUrls[i]; + if (typeof url !== "string" || url.length === 0) { + // Soft-omit missing URLs rather than aborting a mixed turn. + continue; + } + // Remote URL fetching is deliberately out of scope here; https:// images are omitted. + if (!url.toLowerCase().startsWith("data:")) continue; + try { + const resolved = decodeCursorImageDataUrl(url); + if (resolved.data.byteLength === 0) continue; + const outcome = await prepareCursorImageForWire({ + data: resolved.data, + mimeType: resolved.mimeType, + uuid: randomUUID(), + ...(options?.details?.[i] ? { detail: options.details[i] } : {}), + }, signal); + if (outcome.status === "omitted") continue; + if (outcome.image.data.byteLength > MAX_CURSOR_IMAGE_BYTES) continue; + out.push(outcome.image); + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.name === "AbortError")) throw err; + if (err instanceof CursorImageError) continue; + continue; + } + } + return out; +} + +export async function resolveCursorImageParts( + parts: readonly CursorImagePartRef[], + signal?: AbortSignal, +): Promise { + return resolveCursorImages( + parts.map(part => part.imageUrl), + signal, + { details: parts.map(part => part.detail) }, + ); +} + +/** Filename Cursor clients typically put on SelectedImage.path (shunt / agent parity). */ +export function cursorImageAttachmentPath(uuid: string, mimeType: string): string { + const normalized = mimeType.toLowerCase(); + const ext = normalized === "image/jpeg" || normalized === "image/jpg" ? "jpg" + : normalized === "image/gif" ? "gif" + : normalized === "image/webp" ? "webp" + : "png"; + return `attachment-${uuid}.${ext}`; +} + +/** + * Re-encode toward a JPEG under the soft vision cap when Bun can decode the payload. + * Unsupported MIME, oversize dimensions/pixels, or undecodable bytes are omitted (fail-closed). + * After the quality ladder, edges shrink iteratively until the soft byte cap is met + * (or the min edge floor is hit) so large clipboard PNGs do not leave >softMax JPEGs + * that Cursor vision hallucinates on. + */ +export async function prepareCursorImageForWire( + image: ResolvedCursorImage, + signal?: AbortSignal, + testHooks?: { softMaxBytes?: number }, +): Promise { + throwIfImagePhaseAborted(signal); + const mime = image.mimeType.toLowerCase(); + const softMax = testHooks?.softMaxBytes ?? softMaxBytesForDetail(image.detail); + const qualities = jpegQualitiesForDetail(image.detail); + const lowestQuality = qualities[qualities.length - 1]!; + + if (!CURSOR_VISION_PASSTHROUGH_MIME.has(mime)) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + + const format = sniffCursorImageFormat(image.data); + // Peek headers before Bun.Image so huge compressed bombs fail closed cheaply. + const sniffed = sniffCursorImageDimensions(image.data); + if (!sniffed) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + const sniffedEdge = Math.max(sniffed.width, sniffed.height); + const sniffedPixels = sniffed.width * sniffed.height; + if (sniffedEdge > MAX_CURSOR_IMAGE_DECODE_EDGE || sniffedPixels > MAX_CURSOR_IMAGE_PIXELS) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + + const declaredJpeg = mime === "image/jpeg" || mime === "image/jpg"; + + try { + throwIfImagePhaseAborted(signal); + // metadata() decodes; reuse it as the Anthropic-style validate pass. + const meta = await new Bun.Image(image.data).metadata(); + + // Passthrough only after a successful decode, and only when declared MIME + // matches actual JPEG magic (never PNG-as-JPEG or SOF-only junk). + if (declaredJpeg && format === "jpeg" && image.data.byteLength <= softMax) { + return { status: "ready", image }; + } + + throwIfImagePhaseAborted(signal); + const width = typeof meta.width === "number" ? meta.width : 0; + const height = typeof meta.height === "number" ? meta.height : 0; + if (width > 0 && height > 0) { + const edge = Math.max(width, height); + if (edge > MAX_CURSOR_IMAGE_DECODE_EDGE || width * height > MAX_CURSOR_IMAGE_PIXELS) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + } + let targetW = width; + let targetH = height; + if (width > 0 && height > 0 && Math.max(width, height) > CURSOR_VISION_MAX_EDGE) { + const scale = CURSOR_VISION_MAX_EDGE / Math.max(width, height); + targetW = Math.max(1, Math.round(width * scale)); + targetH = Math.max(1, Math.round(height * scale)); + } + + const encodeAt = async (w: number, h: number, quality: number): Promise => { + throwIfImagePhaseAborted(signal); + let pipeline = new Bun.Image(image.data); + if (w > 0 && h > 0 && (w !== width || h !== height)) { + pipeline = pipeline.resize(w, h); + } + return new Uint8Array(await pipeline.jpeg({ quality }).bytes()); + }; + + let best: Uint8Array | undefined; + for (const quality of qualities) { + const encoded = await encodeAt(targetW, targetH, quality); + if (!best || encoded.byteLength < best.byteLength) best = encoded; + if (encoded.byteLength <= softMax) { + return { + status: "ready", + image: { ...image, data: encoded, mimeType: "image/jpeg" }, + }; + } + } + + // Quality ladder missed the soft cap — shrink edges until it fits or we hit the floor. + while ( + best + && best.byteLength > softMax + && targetW > 0 + && targetH > 0 + && Math.max(targetW, targetH) > CURSOR_VISION_SOFT_MIN_EDGE + ) { + throwIfImagePhaseAborted(signal); + const nextW = Math.max(1, Math.round(targetW * CURSOR_VISION_SOFT_SHRINK)); + const nextH = Math.max(1, Math.round(targetH * CURSOR_VISION_SOFT_SHRINK)); + if (Math.max(nextW, nextH) < CURSOR_VISION_SOFT_MIN_EDGE) { + const scale = CURSOR_VISION_SOFT_MIN_EDGE / Math.max(targetW, targetH); + targetW = Math.max(1, Math.round(targetW * scale)); + targetH = Math.max(1, Math.round(targetH * scale)); + } else { + targetW = nextW; + targetH = nextH; + } + const encoded = await encodeAt(targetW, targetH, lowestQuality); + if (!best || encoded.byteLength < best.byteLength) best = encoded; + if (encoded.byteLength <= softMax) { + return { + status: "ready", + image: { ...image, data: encoded, mimeType: "image/jpeg" }, + }; + } + if (Math.max(targetW, targetH) <= CURSOR_VISION_SOFT_MIN_EDGE) break; + } + + if (best && best.byteLength <= softMax) { + return { + status: "ready", + image: { ...image, data: best, mimeType: "image/jpeg" }, + }; + } + if (best) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + // Undeclared/mismatched magic with no encode result — omit rather than lie about MIME. + if (declaredJpeg && format !== "jpeg") { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + return { status: "ready", image }; + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.name === "AbortError")) throw err; + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } +} + +/** + * Sniff PNG/JPEG/GIF/WebP dimensions from raw bytes when the header is present. + * Best-effort only — unknown formats return undefined (dimension is optional). + */ +export function sniffCursorImageDimensions( + data: Uint8Array, +): { width: number; height: number } | undefined { + // PNG: signature + IHDR chunk (width/height at bytes 16..23) + if ( + data.byteLength >= 24 + && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 + && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a + ) { + const width = ((data[16]! << 24) | (data[17]! << 16) | (data[18]! << 8) | data[19]!) >>> 0; + const height = ((data[20]! << 24) | (data[21]! << 16) | (data[22]! << 8) | data[23]!) >>> 0; + if (width > 0 && height > 0) return { width, height }; + } + // GIF: "GIF8" + width/height as little-endian u16 at bytes 6..9 + if ( + data.byteLength >= 10 + && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38 + ) { + const width = data[6]! | (data[7]! << 8); + const height = data[8]! | (data[9]! << 8); + if (width > 0 && height > 0) return { width, height }; + } + // WebP: RIFF....WEBP + VP8X / VP8 / VP8L (same layout as anthropic-image-guard). + if ( + data.byteLength >= 30 + && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 + && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50 + ) { + const fourcc = String.fromCharCode(data[12]!, data[13]!, data[14]!, data[15]!); + if (fourcc === "VP8X") { + const width = 1 + (data[24]! | (data[25]! << 8) | (data[26]! << 16)); + const height = 1 + (data[27]! | (data[28]! << 8) | (data[29]! << 16)); + if (width > 0 && height > 0) return { width, height }; + } else if (fourcc === "VP8 ") { + if (data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { + const width = (data[26]! | (data[27]! << 8)) & 0x3fff; + const height = (data[28]! | (data[29]! << 8)) & 0x3fff; + if (width > 0 && height > 0) return { width, height }; + } + } else if (fourcc === "VP8L" && data[20] === 0x2f) { + const raw = data[21]! | (data[22]! << 8) | (data[23]! << 16) | (data[24]! << 24); + const width = (raw & 0x3fff) + 1; + const height = ((raw >> 14) & 0x3fff) + 1; + if (width > 0 && height > 0) return { width, height }; + } + } + // JPEG: scan for SOF0/SOF2 marker with dimensions + if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) { + let offset = 2; + while (offset + 8 < data.byteLength) { + if (data[offset] !== 0xff) break; + const marker = data[offset + 1]!; + // Standalone markers (TEM, RSTn, SOI, EOI) carry no length payload. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2; + continue; + } + const length = (data[offset + 2]! << 8) | data[offset + 3]!; + // SOFn frame headers share the dimension layout. 0xc4/0xc8/0xcc are DHT/JPG/DAC, not SOF. + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + const height = (data[offset + 5]! << 8) | data[offset + 6]!; + const width = (data[offset + 7]! << 8) | data[offset + 8]!; + if (width > 0 && height > 0) return { width, height }; + break; + } + if (length < 2) break; + offset += 2 + length; + } + } + return undefined; +} + +/** + * Build SelectedImage messages for the AgentService vision path: + * store bytes in the local KV map under sha256(blobId), and encode + * `blobIdWithData` so the server can populate its cache without relying solely + * on getBlobArgs timing. Also set `path` like native/shunt clients. + */ +export function buildSelectedImages( + images: readonly ResolvedCursorImage[], + requestScope?: CursorBlobRequestScopeToken, +): SelectedImage[] { + return images.map(image => { + const blobId = storeCursorBlob(image.data, requestScope); + const dims = sniffCursorImageDimensions(image.data); + return create(SelectedImageSchema, { + uuid: image.uuid, + path: cursorImageAttachmentPath(image.uuid, image.mimeType), + mimeType: image.mimeType, + ...(dims + ? { dimension: create(SelectedImage_DimensionSchema, dims) } + : {}), + dataOrBlobId: { + case: "blobIdWithData", + value: create(SelectedImage_BlobIdWithDataSchema, { + blobId, + data: image.data, + }), + }, + }); + }); +} + +/** + * Always send `UserMessage.selected_context`, even when empty — matches cursor-agent. + * When images are present, they are blobIdWithData refs backed by the request-scoped KV store. + */ +export function buildSelectedContext( + images: readonly ResolvedCursorImage[] = [], + requestScope?: CursorBlobRequestScopeToken, +): SelectedContext { + return create(SelectedContextSchema, { + selectedImages: buildSelectedImages(images, requestScope), + }); +} + +/** + * Resolve data: images for the active user/developer turn onto SelectedImage. + * Tool-result image promotion is intentionally out of scope in this slice. + */ +export async function resolveActiveCursorImages( + messages: readonly OcxMessage[] | undefined, + signal?: AbortSignal, + preparedImages?: readonly ResolvedCursorImage[], +): Promise { + if (!messages?.length) return []; + // Same window the prepare pass rewrote; a divergent rule would attach unprepared bytes. + const message = messages[cursorVisionPrepareStartIndex(messages)]; + if (!message || (message.role !== "user" && message.role !== "developer")) return []; + if (preparedImages) return [...preparedImages]; + return resolveCursorImageParts(extractCursorImageParts(message.content), signal); +} + +function imageDataUrlFromPrepared(image: ResolvedCursorImage): string { + return `data:${image.mimeType};base64,${Buffer.from(image.data).toString("base64")}`; +} + +/** + * Re-encode a single image URL through {@link prepareCursorImageForWire}. + * data: URLs only. Omitted images become text (caller replaces the part). + */ +export async function prepareCursorImageDataUrl( + imageUrl: string, + detail?: string, + signal?: AbortSignal, +): Promise< + | { status: "ready"; imageUrl: string; image: ResolvedCursorImage } + | { status: "omitted"; reason: string } +> { + try { + const resolved = imageUrl.toLowerCase().startsWith("data:") + ? decodeCursorImageDataUrl(imageUrl) + : null; + if (!resolved) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + if (resolved.data.byteLength === 0) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + const outcome = await prepareCursorImageForWire({ + data: resolved.data, + mimeType: resolved.mimeType, + uuid: randomUUID(), + ...(detail ? { detail } : {}), + }, signal); + if (outcome.status === "omitted") return outcome; + if (outcome.image.data.byteLength > MAX_CURSOR_IMAGE_BYTES) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + if ( + imageUrl.toLowerCase().startsWith("data:") + && outcome.image.data === resolved.data + && outcome.image.mimeType === resolved.mimeType + ) { + return { status: "ready", imageUrl, image: outcome.image }; + } + return { status: "ready", imageUrl: imageDataUrlFromPrepared(outcome.image), image: outcome.image }; + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.name === "AbortError")) throw err; + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } +} + +async function prepareCursorContentParts( + content: string | readonly OcxContentPart[], + signal?: AbortSignal, +): Promise<{ content: string | readonly OcxContentPart[]; images: ResolvedCursorImage[] }> { + if (typeof content === "string" || !Array.isArray(content)) { + return { content, images: [] }; + } + let changed = false; + const next: OcxContentPart[] = []; + const images: ResolvedCursorImage[] = []; + for (const part of content) { + if (part.type === "image" && typeof part.imageUrl === "string" && part.imageUrl.length > 0) { + throwIfImagePhaseAborted(signal); + const prepared = await prepareCursorImageDataUrl(part.imageUrl, part.detail, signal); + if (prepared.status === "omitted") { + changed = true; + next.push({ type: "text", text: prepared.reason }); + continue; + } + images.push(prepared.image); + if (prepared.imageUrl !== part.imageUrl) changed = true; + next.push({ ...part, imageUrl: prepared.imageUrl }); + } else { + next.push(part); + } + } + return { content: changed ? next : content, images }; +} + +/** + * First original-message index that still needs image prep for the active vision window. + * Historical messages before this index are left untouched (no decode). + */ +export function cursorVisionPrepareStartIndex(messages: readonly OcxMessage[]): number { + // Tool-result image preparation is out of scope in this slice. + if (messages.at(-1)?.role === "toolResult") return messages.length; + for (let i = messages.length - 1; i >= 0; i--) { + const role = messages[i]?.role; + if (role === "user" || role === "developer") return i; + } + return messages.length; +} + +/** + * Rewrite image data URLs in the active vision window (last user/developer turn) through + * the JPEG soft-cap path before protobuf encode. Historical messages are left by + * reference. Undecodable images become {@link CURSOR_VISION_IMAGE_OMITTED} text so + * image-only turns stay userMessageAction. + */ +export interface PreparedCursorRawMessages { + messages: readonly OcxMessage[] | undefined; + images: ResolvedCursorImage[]; +} + +export async function prepareCursorRawMessages( + messages: readonly OcxMessage[] | undefined, + signal?: AbortSignal, +): Promise { + if (!messages?.length) return { messages, images: [] }; + throwIfImagePhaseAborted(signal); + const prepareFrom = cursorVisionPrepareStartIndex(messages); + const active = messages[prepareFrom]; + if ( + active + && (active.role === "user" || active.role === "developer") + && extractCursorImageParts(active.content).length > MAX_CURSOR_IMAGES + ) { + throw new CursorImageError(`Too many images in one request (max ${MAX_CURSOR_IMAGES}).`); + } + let changed = false; + const out: OcxMessage[] = []; + const images: ResolvedCursorImage[] = []; + for (let i = 0; i < messages.length; i++) { + throwIfImagePhaseAborted(signal); + const message = messages[i]!; + if ( + i >= prepareFrom + && (message.role === "user" || message.role === "developer") + ) { + const prepared = await prepareCursorContentParts(message.content, signal); + images.push(...prepared.images); + if (prepared.content !== message.content) { + changed = true; + out.push({ ...message, content: prepared.content } as OcxMessage); + continue; + } + } + out.push(message); + } + return { messages: changed ? out : messages, images }; +} diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index f79fe27398..32bafe1517 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -14,6 +14,7 @@ * 5-byte gRPC/Connect frame makes the server mis-parse it ("illegal tag: field no 0"). */ import http2 from "node:http2"; +import { cursorH2Pool } from "./h2-pool"; import { fromBinary } from "@bufbuild/protobuf"; import type { UpstreamHttpVersion } from "../../types"; import { readBoundedResponseBytes } from "../../lib/bounded-body"; @@ -205,35 +206,29 @@ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions) resolve(value); }; - let client: http2.ClientHttp2Session; - try { - client = http2.connect(baseUrl); - } catch { - return finish({ ok: false, error: "transport", detail: "HTTP/2 connection setup failed" }); - } - const timer = setTimeout(() => { - finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` }); - client.destroy(); - }, timeoutMs); - const close = (value: CursorUsableModelsResult): void => { - clearTimeout(timer); - client.close(); - finish(value); - }; + const timer = setTimeout(() => { + // Cancel the borrowed pooled stream so it does not continue receiving + // body bytes after the caller has timed out (regression vs pre-pool behavior). + req?.destroy(); + finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` }); + }, timeoutMs); + const close = (value: CursorUsableModelsResult): void => { + clearTimeout(timer); + finish(value); + }; - client.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 session failed" })); - let req: http2.ClientHttp2Stream; - try { - req = client.request({ - ":method": "POST", - ":path": CURSOR_GET_USABLE_MODELS_PATH, - ...cursorDiscoveryHeaders(opts), - }); - } catch { - return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" }); - } + let req: http2.ClientHttp2Stream; + try { + req = cursorH2Pool.request(baseUrl, { + ":method": "POST", + ":path": CURSOR_GET_USABLE_MODELS_PATH, + ...cursorDiscoveryHeaders(opts), + }); + } catch { + return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" }); + } let status = 0; const chunks: Buffer[] = []; diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 07bfa54dca..ad48ec6713 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -13,6 +13,8 @@ import { type TranslatorBudget, } from "../../lib/translator-budget"; import { activePromptText, prepareCursorRunRequest } from "./protobuf-request"; +import { prepareCursorRawMessages, resolveActiveCursorImages } from "./images"; +import { cursorRequestMessagesFromRaw } from "./request-builder"; import { createCursorContextUsageTracker, createCursorProtobufEventState, @@ -89,6 +91,24 @@ const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; const HEARTBEAT_MS = 5_000; const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000; +/** + * T04 (senpi #1062 second half): after the first frame, a turn with NO inbound decoded + * frames for this long is failed instead of waiting for the 300s bridge stall watchdog + * (issue #2210). Reset on every decoded AgentServerMessage. + */ +const CURSOR_STREAM_SILENCE_FAIL_MS = 30_000; +/** + * A stream that produces ONLY liveness frames (server heartbeat / conversationCheckpointUpdate) + * for this long is equally stuck — the server is alive but the turn is not progressing. + * Reset on every decoded frame that is not liveness-only. + */ +const CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS = 90_000; +/** + * After `turnEnded` is decoded, the application turn is complete. A server that keeps + * HTTP/2 open past this point cannot hold the turn hostage (senpi #1062): we close our side + * after a short grace so any trailing frames (late usage, checkpoint) still land. + */ +const TURN_ENDED_CLOSE_GRACE_MS = 500; const CURSOR_TIMEOUT_DESTROY_GRACE_MS = 1_000; const CLIENT_TOOL_FINALIZE_GRACE_MS = 50; const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750; @@ -412,6 +432,18 @@ class LiveCursorTransport implements CursorTransport { private http1Connection?: CursorHttp1BidiConnection; private heartbeat?: ReturnType; private firstFrameTimer?: ReturnType; + private turnEndedCloseTimer?: ReturnType; + /** + * T04 inbound stream-health watchdog. Armed after the request is on the wire, reset by + * every DECODED frame (raw chunks deliberately do not count — TLS keepalive noise must not + * defeat it), disarmed by any settle/expected-close path. One timer covers both thresholds: + * it always fires at min(lastInbound + silence, lastMeaningful + heartbeatOnly) and re-arms + * when neither deadline has actually elapsed. + */ + private streamHealthTimer?: ReturnType; + private lastInboundFrameAt = 0; + private lastMeaningfulFrameAt = 0; + private streamHealthFail?: (error: Error) => void; private committed = false; private expectedClose = false; /** @@ -569,10 +601,29 @@ class LiveCursorTransport implements CursorTransport { // Advertise MCP tools before the stream opens — the server only calls tools it was told about. await this.prepareMcp(); - const activeText = activePromptText(request); - this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(request, this.clientToolFinalizeGraceMs); - const cursorVisibleTools = cursorToolsForActivePrompt(request.tools, activeText, request.toolChoice); - const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, request.toolChoice); + // JPEG soft-cap rewrite for active-turn data: images before encode. Rebuild text + // messages from the prepared raw channel so omission markers replace stale + // pre-rewrite content that activePromptText and the tool filter would otherwise see. + const preparedRaw = await prepareCursorRawMessages(request.rawMessages, signal); + const preparedRawMessages = preparedRaw.messages; + const selectedImages = await resolveActiveCursorImages( + preparedRawMessages, + signal, + preparedRaw.images, + ); + const preparedMessages = preparedRawMessages === request.rawMessages + ? request.messages + : cursorRequestMessagesFromRaw(preparedRawMessages); + const activeRequest: CursorRunRequest = { + ...request, + messages: preparedMessages, + rawMessages: preparedRawMessages, + selectedImages, + }; + const activeText = activePromptText(activeRequest); + this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(activeRequest, this.clientToolFinalizeGraceMs); + const cursorVisibleTools = cursorToolsForActivePrompt(activeRequest.tools, activeText, activeRequest.toolChoice); + const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, activeRequest.toolChoice); // `request.tools` is the catalog already filtered and budgeted by request-builder. Derive // conversion provenance only from tagged synthetic tools that also survive this final prompt // filter; a client tool with the same wire name can never opt into conversion by collision. @@ -608,7 +659,7 @@ class LiveCursorTransport implements CursorTransport { }); // Build the payload once. The estimate is only worth deriving when there is no // carry-forward to fall back on — with a carry present it would never be used (#373). - const prepared = prepareCursorRunRequest(request, { + const prepared = prepareCursorRunRequest(activeRequest, { estimateInputTokens: contextUsage.carryForwardTokens === undefined, }); this.blobRequestScope = prepared.blobRequestScope; @@ -732,14 +783,100 @@ class LiveCursorTransport implements CursorTransport { } } + private clearStreamHealthTimer(): void { + if (this.streamHealthTimer) { + clearTimeout(this.streamHealthTimer); + this.streamHealthTimer = undefined; + } + this.streamHealthFail = undefined; + } + + /** + * T04: arm (or re-arm) the inbound stream-health watchdog. `fail` is the turn's + * failAndClear; the timer owns nothing else. Never armed before the first decoded + * frame (the first-frame timer covers dial + first response), and disarmed by + * every settle / expected-close path alongside the other timers. + */ + private armStreamHealthTimer(fail: (error: Error) => void): void { + if (this.streamHealthTimer) clearTimeout(this.streamHealthTimer); + if (this.expectedClose) return; + this.streamHealthFail = fail; + const silenceMs = this.input.streamSilenceFailMs ?? CURSOR_STREAM_SILENCE_FAIL_MS; + const heartbeatOnlyMs = this.input.streamHeartbeatOnlyFailMs ?? CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS; + const now = Date.now(); + const deadline = Math.min( + this.lastInboundFrameAt + silenceMs, + this.lastMeaningfulFrameAt + heartbeatOnlyMs, + ); + this.streamHealthTimer = setTimeout(() => { + this.streamHealthTimer = undefined; + const failFn = this.streamHealthFail; + if (!failFn || this.expectedClose) return; + const stalledFor = Date.now() - this.lastInboundFrameAt; + const meaningfulStalledFor = Date.now() - this.lastMeaningfulFrameAt; + if (stalledFor < silenceMs && meaningfulStalledFor < heartbeatOnlyMs) { + // A frame landed between arming and firing — re-arm for the fresh deadline. + this.armStreamHealthTimer(failFn); + return; + } + const heartbeatOnly = stalledFor < silenceMs; + debugProviderDiagnostic("cursor", "stream-health-timeout", { + stalledMs: stalledFor, + meaningfulStalledMs: meaningfulStalledFor, + heartbeatOnly, + framesReceived: this.framesReceived, + elapsedMs: Date.now() - this.turnStartedAt, + }); + const reason = heartbeatOnly + ? `Cursor stream stalled: heartbeat-only traffic for ${Math.round(meaningfulStalledFor / 1000)}s without turn progress` + : `Cursor stream stalled: no inbound frames for ${Math.round(stalledFor / 1000)}s before turnEnded`; + failFn(new Error(reason)); + try { this.stream?.close(); } catch { this.stream?.destroy(); } + this.session?.close(); + this.http1Connection?.close(); + }, Math.max(0, deadline - now)); + } + + /** + * T04: record a decoded inbound frame. Liveness-only frames (server heartbeat, + * conversationCheckpointUpdate) keep the silence clock fresh but not the progress + * clock — matching senpi's split so a server that only pings still fails at the + * heartbeat-only threshold. + */ + private noteInboundFrame(livenessOnly: boolean): void { + const now = Date.now(); + this.lastInboundFrameAt = now; + if (!livenessOnly) this.lastMeaningfulFrameAt = now; + if (this.streamHealthFail) this.armStreamHealthTimer(this.streamHealthFail); + } + + /** + * A clean Connect END_STREAM owns the turn terminal even when Cursor keeps the + * HTTP body open or tears it down with an abort/reset immediately afterward. + * Stop client-side liveness work and classify that later transport close as + * expected without actively sending an RST_STREAM back to Cursor. + */ + private markProtocolComplete(): void { + this.expectedClose = true; + this.clearPendingFinalize(); + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = undefined; + } + this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); + } + private startShellCleanup(): Promise { return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId); } async close(): Promise { if (this.heartbeat) clearInterval(this.heartbeat); + if (this.turnEndedCloseTimer) clearTimeout(this.turnEndedCloseTimer); this.clearPendingFinalize(); this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); this.stream?.close(); this.session?.close(); this.http1Connection?.close(); @@ -755,6 +892,7 @@ class LiveCursorTransport implements CursorTransport { this.clearPendingFinalize(); if (this.heartbeat) clearInterval(this.heartbeat); this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); if (this.http1Connection) { this.http1Connection.close(); } else { @@ -772,6 +910,46 @@ class LiveCursorTransport implements CursorTransport { void this.startShellCleanup().catch(() => { /* close() observes the same cleanup promise */ }); } + /** + * T03 (#1062): after the server sends `turnEnded`, the application turn is complete. + * A server that keeps the HTTP/2 stream open past this point cannot hold the turn + * hostage until a 300s bridge idle timeout. Close our side after a short grace so any + * trailing frames (late usage, checkpoint) still land before we release the socket. + */ + private closeAfterTurnEnded(): void { + if (this.turnEndedCloseTimer) return; + // The application turn is over: the T03 grace timer owns the socket from here. + // The T04 watchdog must disarm NOW, not at the grace close — a watchdog shorter + // than the grace would otherwise fail a completed turn. + this.clearStreamHealthTimer(); + this.turnEndedCloseTimer = setTimeout(() => { + this.turnEndedCloseTimer = undefined; + // Only expectedClose (client-tool suspend cancel) blocks the close. + // emittedTerminal is intentionally NOT checked here: finalizeTurnEvents sets it + // synchronously during turnEnded mapping, ~500ms before this timer fires, so + // checking it would make the close unreachable on every real path (the exact + // scenario this PR exists to fix — senpi #1062). + if (this.expectedClose) return; + debugProviderDiagnostic("cursor", "turn-ended-close", { + committed: this.committed, + framesReceived: this.framesReceived, + }); + this.expectedClose = true; + this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); + if (this.heartbeat) clearInterval(this.heartbeat); + if (this.http1Connection) { + this.http1Connection.close(); + } else { + try { + this.stream?.close(); + } catch { + this.stream?.destroy(); + } + } + }, TURN_ENDED_CLOSE_GRACE_MS); + } + private releaseBlobRequestScope(): void { const scope = this.blobRequestScope; if (!scope) return; @@ -882,7 +1060,10 @@ class LiveCursorTransport implements CursorTransport { const settler = createTerminalSettler({ fail, finish, - clearTimer: () => this.clearFirstFrameTimer(), + clearTimer: () => { + this.clearFirstFrameTimer(); + this.clearStreamHealthTimer(); + }, }); const failAndClear = (error: Error) => { releaseBacklogLease(); @@ -979,10 +1160,54 @@ class LiveCursorTransport implements CursorTransport { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt, } : { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt }); - if (endError) failAndClear(endError); + if (endError) { + failAndClear(endError); + return; + } + // Connect's clean END_STREAM envelope is the protocol terminal. Cursor's RunSSE body can + // remain open after this frame (or close through an AbortError), so waiting for HTTP EOF + // strands an otherwise completed turn until the outer bridge stall watchdog fires. + // + // Earlier frames in this serialized frameWork chain have already run. Preserve their real + // turnEnded terminal when present; otherwise finalize the clean protocol end once so open + // tool calls still fail closed, a text-only turn receives its normal done event, and a + // drained client-tool turn does not lose the pending terminal when protocol cleanup clears + // its grace timer. + const hasPendingClientToolFinalization = this.pendingFinalize !== undefined; + if ( + !this.expectedClose + && !state.terminated + && !this.emittedTerminal + && ( + state.openToolCalls.size > 0 + || this.sawAssistantText + || hasPendingClientToolFinalization + ) + ) { + const terminal = hasPendingClientToolFinalization && state.openToolCalls.size === 0 + ? finalizeAfterDrain(state) + : finalizeTurnEvents(state); + for (const event of terminal) push(event); + } + this.markProtocolComplete(); + releaseBacklogLease(); + settler.settleFinish(); return; } - await this.handleServerMessage(fromBinary(AgentServerMessageSchema, frame.payload), state, push); + const decoded = fromBinary(AgentServerMessageSchema, frame.payload); + // T04: every decoded frame refreshes the silence clock; only non-liveness frames + // refresh the progress clock. First decoded frame arms the watchdog (the first-frame + // timer owned everything before this point). + const decodedUpdate = decoded.message.case === "interactionUpdate" ? decoded.message.value.message?.case : undefined; + const livenessOnly = decodedUpdate === "heartbeat" || decoded.message.case === "conversationCheckpointUpdate"; + if (!this.streamHealthFail) { + const now = Date.now(); + this.lastInboundFrameAt = now; + this.lastMeaningfulFrameAt = now; + this.streamHealthFail = failAndClear; + } + this.noteInboundFrame(livenessOnly); + await this.handleServerMessage(decoded, state, push); }; const drainPendingFrames = () => { const availableSlots = CURSOR_MAX_PENDING_FRAMES - this.pendingTransportFrames; @@ -1252,6 +1477,12 @@ class LiveCursorTransport implements CursorTransport { // A completion may carry only callId. Capture its ownership before mapping removes the open // call, because the embedded-tool classifier cannot identify that valid compact frame. const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined; + if (update?.case === "turnEnded") { + // T03: the application turn is complete. Close our side of HTTP/2 after a short + // grace so a held-open server response cannot pin the turn to the bridge's idle + // timeout (senpi #1062). finalizeTurnEvents already emitted done via the mapper. + this.closeAfterTurnEnded(); + } const completesOpenClientTool = update?.case === "toolCallCompleted" && state.openToolCalls.has(update.value.callId); const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted" diff --git a/src/adapters/cursor/native-exec-common.ts b/src/adapters/cursor/native-exec-common.ts index 1afa153074..86715636eb 100644 --- a/src/adapters/cursor/native-exec-common.ts +++ b/src/adapters/cursor/native-exec-common.ts @@ -1,6 +1,7 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { AgentClientMessageSchema, + ExecClientThrowSchema, ExecClientControlMessageSchema, ExecClientMessageSchema, ExecClientStreamCloseSchema, @@ -49,6 +50,22 @@ export function execStreamCloseBytes(execMsg: ExecServerMessage): Uint8Array { }); } +/** + * Exec-channel typed throw (`execClientControlMessage.throw`). senpi's contract (T05): + * a frame that cannot be answered at all must get an explicit error reply + stream-close + * so the server unblocks with a known failure, instead of waiting forever on silence. + */ +export function execThrowBytes(execMsg: ExecServerMessage, error: string): Uint8Array { + return clientBytes({ + message: { + case: "execClientControlMessage", + value: create(ExecClientControlMessageSchema, { + message: { case: "throw", value: create(ExecClientThrowSchema, { id: execMsg.id, error }) }, + }), + }, + }); +} + export function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index c72fa4715a..aee9ddac38 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -50,7 +50,7 @@ import { recordScreenExec, type CursorNativeToolDeps, } from "./native-exec-tools"; -import { clientBytes, execBytes } from "./native-exec-common"; +import { clientBytes, execBytes, execStreamCloseBytes, execThrowBytes } from "./native-exec-common"; import type { McpToolDefinition } from "./gen/agent_pb"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; @@ -603,10 +603,15 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C }))]; } // Unknown exec case — Cursor added a new native exec type that our protobuf definition does not - // include yet. Return an empty reply so the stream stays alive instead of throwing (which kills - // the entire gRPC connection via failAndClear). Same class of bug as #116. + // include yet. T05 (senpi contract): reply with ExecClientThrow + stream-close so the server + // unblocks with a known failure. Previously this returned an empty reply (silence), which is + // the stall class senpi explicitly refused (#116 was about throwing into failAndClear and + // killing the whole connection; a typed in-band throw does not do that). debugProviderDiagnostic("cursor", "unknown-exec-case", { execCase: execCase ?? "unknown", execId: execMsg.execId }); - return []; + return [ + execThrowBytes(execMsg, "Unknown exec message variant; this client does not implement it."), + execStreamCloseBytes(execMsg), + ]; } diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index ee126a51d9..c589d4f293 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -11,6 +11,7 @@ import { isCodexShellBridgeToolName, isCursorStructuredEditToolName, normalizeCursorWireName, + normalizeCursorTextToolMarkers, OCX_RESPONSES_TOOL_PROVIDER, resolveShellBridgeAliasKey, responsesToolNameFromCursorWire, @@ -1243,7 +1244,10 @@ export function mapCursorProtobufServerMessage( const update = serverMessage.message.value.message; switch (update.case) { case "textDelta": - return update.value.text ? [{ type: "text", text: update.value.text }] : []; + // #2305: fold Cursor display aliases inside textual pseudo tool-call markers back to + // the advertised wire name before any client sees the text. Real frames are already + // normalized structurally (mcpWireNameFromArgs above). + return update.value.text ? [{ type: "text", text: normalizeCursorTextToolMarkers(update.value.text) }] : []; case "thinkingDelta": return update.value.text ? [{ type: "thinking", thinking: update.value.text }] : []; case "toolCallStarted": { diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 37a45c1c3f..18d5957eab 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -15,6 +15,7 @@ import { storeCursorBlob, type CursorBlobRequestScopeToken, } from "./native-exec"; +import { buildSelectedContext, CURSOR_VISION_IMAGE_HISTORY_MARKER } from "./images"; import { estimateTokens } from "../../lib/token-estimate"; import { parseDataUrl } from "../image"; import { @@ -187,10 +188,12 @@ function assistantRootText( } // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata), -// so prior history — including assistant tool calls and tool results — must be replayed here or a -// ResumeAction has nothing model-visible to continue from. The active user message is excluded -// because it travels in the action. Tool results are assistant-role text with a [Tool Result] -// or [Tool Error] marker so Cursor does not wrap them as `` (#1992). Each entry is a SHA-256 blob ID. +// so prior history must be replayed here or a ResumeAction has nothing model-visible to continue from. +// The active user message is excluded because it travels in the action. When the continuation cannot +// rely on native MCP turn state, tool results stay assistant-role text with a [Tool Result] / +// [Tool Error] marker so Cursor does not wrap them as `` (#1992). Native resume models +// already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto +// few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID. function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): { ids: Uint8Array[]; byteLength: number; @@ -211,6 +214,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR } const externalModel = isCursorExternalWireModel(request.modelId); + const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId); const lastRawIsToolResult = messages.at(-1)?.role === "toolResult"; const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages); @@ -219,7 +223,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR const message = messages[i]; if (!message) continue; if (message.role === "user" || message.role === "developer") { - const text = contentText(message).trim(); + const text = historyContentText(message).trim(); // Cursor root replay expects OpenAI-style content parts for historical user messages. // A bare string survives blob hydration but external workers reject the completed replay // before tokenization (`usedTokens: 0`, then invalid_argument). @@ -242,6 +246,10 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR } // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here. } else if (message.role === "toolResult") { + // Native resume models already receive the paired MCP result through turns[]. Replaying + // the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto + // to echo that envelope as chat instead of continuing from the structured result. + if (!echoToolResultInRoot) continue; // #1920: the prefix must reflect the NORMALIZED error state (an empty // node_repl result is an error even when the runtime said isError=false). const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]"; @@ -336,7 +344,7 @@ function contentText(message: OcxMessage): string { .map(part => { if (part.type === "text") return part.text; if (part.type === "thinking") return part.thinking; - if (part.type === "image") return `[image produced by this tool, omitted from Cursor text replay: ${part.detail ?? "auto"}]`; + if (part.type === "image") return undefined; return undefined; }) .filter((value): value is string => typeof value === "string" && value.length > 0) @@ -346,7 +354,26 @@ function contentText(message: OcxMessage): string { function contentToText(content: OcxToolResultMessage["content"]): string { if (typeof content === "string") return content; return content - .map(part => part.type === "text" ? part.text : `[image produced by this tool, omitted from Cursor text replay: ${part.detail ?? "auto"}]`) + .map(part => { + if (part.type === "text") return part.text; + if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; + return undefined; + }) + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join("\n"); +} + +/** History serializer. Replayed turns are text-only; never embed image bytes. */ +function historyContentText(message: OcxMessage): string { + if (message.role === "toolResult" || typeof message.content === "string") return contentText(message); + return message.content + .map(part => { + if (part.type === "text") return part.text; + if (part.type === "thinking") return part.thinking; + if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; + return undefined; + }) + .filter((value): value is string => typeof value === "string" && value.length > 0) .join("\n"); } @@ -721,8 +748,10 @@ function conversationTurns( flush(); current = { userMessage: storeCursorBlob(toBinary(UserMessageSchema, create(UserMessageSchema, { - text: contentText(message), + text: historyContentText(message), messageId: crypto.randomUUID(), + selectedContext: buildSelectedContext([], requestScope), + mode: 1, })), requestScope), steps: [], }; @@ -792,6 +821,7 @@ function buildPreparedCursorRunRequest( ? appendCursorGenericToolUseHint(request.tools, rawText) : rawText; const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult"; + const selectedImages = request.selectedImages ?? []; // Native models resume the remembered Cursor conversation. External wire // models continue as userMessageAction so history-blob tool results stay // visible without a ResumeAction. Some native composer ids are also routed @@ -799,7 +829,11 @@ function buildPreparedCursorRunRequest( // because a bare resumeAction makes them continue exploring with native tools // instead of answering (observed on composer-2.5; see discovery.ts). const externalToolContinuation = lastRawIsToolResult && cursorNeedsExternalToolContinuation(request.modelId); - const actionCase = (externalToolContinuation || (!lastRawIsToolResult && text.trim().length > 0)) + // Image-only active turns (including soft-omitted images) stay userMessageAction. + const actionCase = ( + externalToolContinuation + || (!lastRawIsToolResult && (text.trim().length > 0 || selectedImages.length > 0)) + ) ? "userMessageAction" : "resumeAction"; const actionText = externalToolContinuation @@ -813,6 +847,9 @@ function buildPreparedCursorRunRequest( userMessage: create(UserMessageSchema, { text: actionText, messageId: crypto.randomUUID(), + selectedContext: buildSelectedContext(selectedImages, requestScope), + // OmniRoute / cursor-agent always send mode=1 on UserMessage. + mode: 1, }), requestContext: buildRequestContext(), }), diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 131fbf5152..9d0e83cbbf 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -31,6 +31,7 @@ import { type CursorCheckpointInvalidationReason, type CursorCheckpointSnapshot, } from "./checkpoint-store"; +import { extractCursorImageUrls } from "./images"; /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */ export const CURSOR_TOOL_COUNT_LIMIT = 330; @@ -211,15 +212,8 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri case "thinking": return part.thinking; case "image": - // User-message images are still flattened here: this path builds the plain-text prompt, and - // the schema slot that could carry them (UserMessage.selectedContext.selectedImages) is not - // populated by this adapter. The tool-result ENCODER does build real McpImageContent - // (see protobuf-request.ts), so the old "unsupported by Cursor adapter" wording is no - // longer true of the encoder — but note that nothing reaches Cursor today either way: - // every Cursor model is in noVisionModels (providers/registry.ts), so the vision sidecar - // describes or strips images before this adapter runs. Kept the same length to avoid - // shifting any byte-budgeted prompt path. - return `[image omitted from this Cursor text prompt: ${part.detail ?? "auto"}]`; + // Images ride UserMessage.selected_context (SelectedImage) instead of text. + return undefined; case "toolCall": // Cursor does not accept OpenAI Responses assistant tool-call parts as native history here. // Rendering them as visible "[tool_call]" text leaks synthetic protocol markers back into @@ -252,9 +246,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined { switch (message.role) { case "user": case "developer": - return { role: message.role, content: contentToText(message.content) }; + { + const content = contentToText(message.content); + // Image-only turns survive as empty content; the encoder keeps them userMessageAction. + if (content.length === 0 && extractCursorImageUrls(message.content).length === 0) { + return undefined; + } + return { role: message.role, content }; + } case "assistant": - return { role: "assistant", content: contentToText(message.content) }; + { + const content = contentToText(message.content); + return content.length > 0 ? { role: "assistant", content } : undefined; + } case "toolResult": return { role: "tool", @@ -263,6 +267,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined { } } +/** + * Rebuild the text `messages` channel from prepared `rawMessages` so omission markers + * and JPEG-rewritten parts stay visible to activePromptText after image preparation. + */ +export function cursorRequestMessagesFromRaw( + messages: readonly OcxMessage[] | undefined, +): CursorRequestMessage[] { + if (!messages?.length) return []; + return messages + .map(requestMessage) + .filter((message): message is CursorRequestMessage => !!message); +} + export function generatedCursorConversationId(): string { return `cursor_${crypto.randomUUID().replace(/-/g, "")}`; } @@ -409,9 +426,7 @@ export function createCursorRequest( parsed: OcxParsedRequest, options: CreateCursorRequestOptions = {}, ): CursorRunRequest { - const messages = parsed.context.messages - .map(requestMessage) - .filter((message): message is CursorRequestMessage => !!message && message.content.length > 0); + const messages = cursorRequestMessagesFromRaw(parsed.context.messages); const activeText = [...messages].reverse().find(message => message.role === "user" || message.role === "developer")?.content ?? ""; const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice); const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice); diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 0930f08f26..31d34ee5e7 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -336,6 +336,26 @@ export function normalizeCursorWireName(name: string): string { return name.startsWith(CURSOR_MCP_DISPLAY_PREFIX) ? name.slice(CURSOR_MCP_DISPLAY_PREFIX.length) : name; } +/** + * #2305: some models emit a TEXTUAL pseudo tool call ("[TOOL_CALL]name[ARGS]{...}") + * instead of a real frame, using Cursor's display alias as the name. Text-mode clients + * (Pi) parse that text and then cannot dispatch the undeclared display name. Rewrite the + * display alias to the advertised wire name ONLY inside the marker pair — prose that + * merely mentions the alias stays untouched, and the scope guard is the exact + * `mcp_${OCX_RESPONSES_TOOL_PROVIDER}_` prefix, never generic `mcp_`. + * Known limit (recorded in devlog 230): a marker split across two streaming deltas is + * not rewritten; tail-buffering is deferred until a live trace shows split markers. + */ +const CURSOR_TEXT_TOOL_MARKER = new RegExp( + String.raw`\[TOOL_CALL\](${CURSOR_MCP_DISPLAY_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\[\]]+)\[ARGS\]`, + "g", +); + +export function normalizeCursorTextToolMarkers(text: string): string { + if (!text.includes(CURSOR_MCP_DISPLAY_PREFIX)) return text; + return text.replace(CURSOR_TEXT_TOOL_MARKER, (_match, name: string) => `[TOOL_CALL]${normalizeCursorWireName(name)}[ARGS]`); +} + export function responsesToolNameFromCursorWire(name: string, cursorToolNameMap?: ReadonlyMap): string { const normalized = normalizeCursorWireName(name); if (!cursorToolNameMap) return normalized; diff --git a/src/adapters/cursor/transport.ts b/src/adapters/cursor/transport.ts index 81b924c90b..79f241ca0e 100644 --- a/src/adapters/cursor/transport.ts +++ b/src/adapters/cursor/transport.ts @@ -29,6 +29,16 @@ export interface CursorTransportFactoryInput { firstFrameTimeoutMs?: number; /** Grace (ms) between close() and the force-destroy fallback after a first-frame timeout. Defaults to 1s. */ timeoutDestroyGraceMs?: number; + /** + * T04 watchdog: maximum inbound decoded-frame silence (ms) after the first frame before the + * turn is failed. Defaults to 30s. + */ + streamSilenceFailMs?: number; + /** + * T04 watchdog: maximum heartbeat/checkpoint-only traffic (ms) without turn progress before + * the turn is failed. Defaults to 90s. + */ + streamHeartbeatOnlyFailMs?: number; /** * Grace window (ms) before a drained client-tool turn is finalized, so a sibling tool call * announced in a later receive chunk can revoke a premature finalize. Defaults to 50ms. diff --git a/src/adapters/cursor/types.ts b/src/adapters/cursor/types.ts index c7b8d65a2d..cbc9a9f3f6 100644 --- a/src/adapters/cursor/types.ts +++ b/src/adapters/cursor/types.ts @@ -2,6 +2,7 @@ import type { OcxUsage } from "../../types"; import type { OcxMessage, OcxRequestOptions, OcxTool } from "../../types"; import type { CursorRoutingLevel } from "./discovery"; import type { CursorCheckpointInvalidationReason } from "./checkpoint-store"; +import type { ResolvedCursorImage } from "./images"; export interface CursorRequestedModelParameter { id: string; @@ -17,7 +18,13 @@ export interface CursorRunRequest { conversationId: string; system: string[]; messages: CursorRequestMessage[]; - rawMessages?: OcxMessage[]; + rawMessages?: readonly OcxMessage[]; + /** + * Images for the active user/developer turn. Encoded as SelectedImage blobIdWithData refs under + * UserMessage.selected_context (bytes live in the request-scoped KV store for getBlobArgs + * hydration). History stays text-only. data: URLs only in this slice. + */ + selectedImages?: readonly ResolvedCursorImage[]; tools?: OcxTool[]; toolChoice?: OcxRequestOptions["toolChoice"]; parallelToolCalls?: boolean; diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 07dd38e476..746f9490a4 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -413,6 +413,7 @@ interface GoogleResponsePart { thought?: boolean; thoughtSignature?: string; thought_signature?: string; + extra_content?: { google?: { thought_signature?: unknown } }; functionCall?: unknown; } @@ -421,6 +422,18 @@ interface GoogleFunctionCall { args?: unknown; } +/** + * Read a Gemini/Antigravity thought signature from a response part. Antigravity can place it + * either directly on the part (`thoughtSignature` / `thought_signature`) or inside the same + * nested `extra_content.google.thought_signature` shape used on the Responses wire. + */ +function googlePartThoughtSignature(part: GoogleResponsePart): string | undefined { + const direct = part.thoughtSignature ?? part.thought_signature; + if (typeof direct === "string" && direct.length > 0) return direct; + const nested = part.extra_content?.google?.thought_signature; + return typeof nested === "string" && nested.length > 0 ? nested : undefined; +} + /** * Carry a Gemini thought signature with the exact function-call part that produced it. Google * validates the signature against that specific part, so it must ride the individual tool call @@ -430,7 +443,7 @@ function googleToolCallMetadataFromPart( part: GoogleResponsePart, fallbackSignature?: string, ): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined { - const signature = part.thoughtSignature ?? part.thought_signature ?? fallbackSignature; + const signature = googlePartThoughtSignature(part) ?? fallbackSignature; if (!isLikelyRealThoughtSignature(signature)) return undefined; return { providerMetadata: { google: { thoughtSignature: signature } } }; } @@ -960,7 +973,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } if (parts) { for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingStreamThoughtSig = sig; } @@ -1224,7 +1237,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } let pendingThoughtSig: string | undefined; for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingThoughtSig = sig; } diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 323f9fbf40..eeeb38c386 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -19,6 +19,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; +import { normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { createAdapterTierMetadata, } from "../providers/fastwire"; @@ -1503,17 +1504,55 @@ function stripUnsupportedHostedTools(body: unknown): unknown { * provider capability metadata; an unclassified upstream keeps the fields. */ const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const; -export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.tools)) return body; + +function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): { + tools: unknown[]; + changed: boolean; +} { let changed = false; - const tools = body.tools.map(t => { - if (!isPlainObject(t) || (t.type !== "web_search" && t.type !== "web_search_preview")) return t; - if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(t, field))) return t; - const { external_web_access: _access, search_context_size: _size, ...rest } = t; + const stripped = tools.map(tool => { + if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) { + return tool; + } + if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool; + const { external_web_access: _access, search_context_size: _size, ...rest } = tool; changed = true; return rest; }); - return changed ? { ...body, tools } : body; + return { tools: changed ? stripped : tools, changed }; +} + +export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { + if (!isPlainObject(body)) return body; + + let next: Record = body; + let changed = false; + if (Array.isArray(body.tools)) { + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools); + if (stripped.changed) { + next = { ...next, tools: stripped.tools }; + changed = true; + } + } + + if (Array.isArray(body.input)) { + let inputChanged = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + return item; + } + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools); + if (!stripped.changed) return item; + inputChanged = true; + return { ...item, tools: stripped.tools }; + }); + if (inputChanged) { + next = { ...next, input }; + changed = true; + } + } + + return changed ? next : body; } /** Replace every `input_image` part under a routed-compaction body with a short marker. */ @@ -1692,17 +1731,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // that already recorded a single-query web_search_call replays it every turn, and // a strict parser rejects the whole request over it (#930). outBody = backfillWebSearchQueries(outBody); - // Same predicate as the routedCompaction gate in handleResponses(): an - // authMode check would let a noncanonical custom forward provider skip this - // rewrite while the server still routes it as a summarizer turn (#422). - if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { - outBody = buildRoutedCompactionBody(outBody); - } if (!isCanonicalOpenAiForwardProvider(provider)) { outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { - const rewritten = rewriteRoutedCustomToolsForUpstream(outBody); + const rewritten = rewriteRoutedCustomToolsForUpstream( + outBody, + provider.supportsResponsesCustomTools, + ); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; } @@ -1712,12 +1748,6 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedToolSearchForUpstream(outBody); outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; - // xAI rejects these OpenAI web_search extensions with HTTP 400. Keep them - // for OpenAI API-key traffic and unclassified gateways; only an explicit - // provider capability denial activates the compatibility transform. - if (provider.supportsOpenAiWebSearchToolFields === false) { - outBody = stripOpenAiOnlyWebSearchFields(outBody); - } } if (!isCanonicalOpenAiForwardProvider(provider)) { // Codex 0.147 emits private namespace tool groups, while public/third-party Responses @@ -1726,9 +1756,25 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; + // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the + // generic capability fallback removes the private OpenAI fields. + outBody = normalizeXaiResponsesWebSearch(outBody, provider); + // xAI and explicitly classified compatible gateways reject these OpenAI web_search + // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. + if (provider.supportsOpenAiWebSearchToolFields === false) { + outBody = stripOpenAiOnlyWebSearchFields(outBody); + } // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } + // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would + // let a noncanonical custom forward provider skip this rewrite while the server still routes + // it as a summarizer turn (#422). The compaction body build removes the tool surface and must + // therefore be the last routed transform: anything before it may depend on the declarations; + // anything after it cannot. + if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { + outBody = buildRoutedCompactionBody(outBody); + } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( outBody, diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts new file mode 100644 index 0000000000..ce72fe2c54 --- /dev/null +++ b/src/adapters/xai-web-search.ts @@ -0,0 +1,185 @@ +import type { OcxProviderConfig } from "../types"; + +const CODEX_WEB_SEARCH_TOOL = "web_search"; +const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; +const XAI_API_HOST = "api.x.ai"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isCodexWebSearchToolType(value: unknown): boolean { + return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL; +} + +/** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */ +function isXaiPublicApi(provider: Pick): boolean { + try { + const url = new URL(provider.baseUrl); + return url.protocol === "https:" + && url.hostname.toLowerCase() === XAI_API_HOST + && (url.port === "" || url.port === "443"); + } catch { + return false; + } +} + +type ToolGroupRewrite = { + tools: unknown[]; + changed: boolean; +}; + +/** + * Translate Codex-private hosted-search fields to xAI's public Responses schema. + * + * xAI web search is live-only. A Codex cached/index-only declaration carries + * `external_web_access: false`; dropping that flag while keeping the tool would silently widen + * network access, so the whole tool is omitted instead. `true` maps to xAI's ordinary live + * `{type:"web_search"}` declaration. Requests that omit the private flag are already public-API + * shaped and retain their live-search behavior. + */ +function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite { + const normalized: unknown[] = []; + let changed = false; + + for (const tool of tools) { + if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) { + normalized.push(tool); + continue; + } + + const hasExternalAccess = Object.hasOwn(tool, "external_web_access"); + if (hasExternalAccess && tool.external_web_access !== true) { + // xAI has no cached/index-only equivalent. Fail closed instead of turning it into live search. + changed = true; + continue; + } + + const searchContentTypes = Array.isArray(tool.search_content_types) + ? tool.search_content_types + : undefined; + const enableImageSearch = searchContentTypes?.includes("image") === true; + const next: Record = { ...tool, type: CODEX_WEB_SEARCH_TOOL }; + delete next.external_web_access; + delete next.search_context_size; + delete next.search_content_types; + delete next.user_location; + if (enableImageSearch && !Object.hasOwn(next, "enable_image_search")) { + next.enable_image_search = true; + } + + const toolChanged = Object.keys(next).length !== Object.keys(tool).length + || Object.entries(next).some(([key, value]) => tool[key] !== value); + changed ||= toolChanged; + normalized.push(toolChanged ? next : tool); + } + + return { tools: changed ? normalized : tools, changed }; +} + +function hasWebSearchTool(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.some(tool => + isPlainObject(tool) && isCodexWebSearchToolType(tool.type) + )) return true; + return Array.isArray(body.input) && body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.some(tool => isPlainObject(tool) && isCodexWebSearchToolType(tool.type)) + ); +} + +function hasAnyDeclaredTool(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.length > 0) return true; + return Array.isArray(body.input) && body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.length > 0 + ); +} + +/** Remove selectors that would still force a cached-only tool omitted above. */ +function normalizeToolChoice(body: Record): Record { + const choice = body.tool_choice; + if (choice === undefined) return body; + const hasSearch = hasWebSearchTool(body); + + if (isPlainObject(choice) && isCodexWebSearchToolType(choice.type)) { + if (!hasSearch) return { ...body, tool_choice: "none" }; + return choice.type === CODEX_WEB_SEARCH_TOOL + ? body + : { ...body, tool_choice: { ...choice, type: CODEX_WEB_SEARCH_TOOL } }; + } + if (isPlainObject(choice) && choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + let changed = false; + const tools: unknown[] = []; + for (const tool of choice.tools) { + if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) { + tools.push(tool); + continue; + } + if (!hasSearch) { + changed = true; + continue; + } + if (tool.type === CODEX_WEB_SEARCH_PREVIEW_TOOL) { + tools.push({ ...tool, type: CODEX_WEB_SEARCH_TOOL }); + changed = true; + } else { + tools.push(tool); + } + } + if (!changed) return body; + return { + ...body, + tool_choice: tools.length > 0 ? { ...choice, tools } : "none", + }; + } + if (choice === "required" && !hasAnyDeclaredTool(body)) { + return { ...body, tool_choice: "none" }; + } + return body; +} + +/** + * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other + * providers or mutating the caller-owned request body. + */ +export function normalizeXaiResponsesWebSearch( + body: unknown, + provider: Pick, +): unknown { + if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body; + + let next: Record = body; + if (Array.isArray(body.tools)) { + const rewritten = normalizeToolGroup(body.tools); + if (rewritten.changed) { + next = { ...next }; + if (rewritten.tools.length > 0) next.tools = rewritten.tools; + else delete next.tools; + } + } + + if (Array.isArray(next.input)) { + let inputChanged = false; + const input: unknown[] = []; + for (const item of next.input) { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + input.push(item); + continue; + } + const rewritten = normalizeToolGroup(item.tools); + if (!rewritten.changed) { + input.push(item); + continue; + } + inputChanged = true; + if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools }); + } + if (inputChanged) next = { ...next, input }; + } + + return normalizeToolChoice(next); +} diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 9cdef3df63..e16ca8e3e9 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -27,7 +27,8 @@ const USAGE = `Usage: ocx agent effort [--main ] [--subagent ] [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] - ocx agent sidecar [--list] [--model ] [--backend ] + ocx agent sidecar [--list] [--model ] + [--backend web: vision:] [--reasoning ] [--max-descriptions ] [--json]`; function clearable(value: string | undefined): string | null | undefined { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b70de46548..217e2d8967 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -172,9 +172,9 @@ const commandRunners: Record = { }, doctor: async deps => { const doctorArgs = deps.args.slice(1); - const { runDoctor } = await import("./doctor"); + const { RECOVER_ZERO_BYTE_COORDINATOR_FLAG, runDoctor } = await import("./doctor"); await runDoctor(doctorArgs); - if (!doctorArgs.includes("--fix-codex-runtime")) { + if (!doctorArgs.includes("--fix-codex-runtime") && !doctorArgs.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) { console.log(""); const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor"); printCodexLogGuardDoctor(); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 8af24a2693..d40ba14f2e 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback"; import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim"; import { countPendingOpencodexHistory } from "../codex/history-provider"; +import { + inspectCodexCoordinator, + recoverZeroByteCodexCoordinator, + type CodexCoordinatorDiagnostic, +} from "../codex/coordinator-doctor"; import { inspectAbandonedResponseStateTemps, reclaimAbandonedResponseStateTemps, @@ -684,6 +689,7 @@ export async function fetchServiceMemory( const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`; export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps"; +export const RECOVER_ZERO_BYTE_COORDINATOR_FLAG = "--recover-zero-byte-coordinator"; /** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */ const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096; /** Names the subsystem: other components mint temps with the same shape and are not covered. */ @@ -734,6 +740,60 @@ export function formatResponseTempLines( return lines; } +export function formatCoordinatorDoctorLines(diagnostic: CodexCoordinatorDiagnostic): string[] { + const pathLine = diagnostic.path ? [` path: ${diagnostic.path}`] : []; + const evidenceLines = "evidence" in diagnostic && diagnostic.evidence + ? [ + ` size: ${diagnostic.evidence.sizeBytes} bytes; user_version: ${diagnostic.evidence.schemaVersion}`, + ` tables: ${diagnostic.evidence.tables.length === 0 ? "none" : diagnostic.evidence.tables.join(", ")}`, + ` transition rows: ${diagnostic.evidence.transitionRows ?? "not inspected"}; singleton=1 rows: ${diagnostic.evidence.singletonRows ?? "not inspected"}`, + ] + : []; + switch (diagnostic.kind) { + case "absent": + return [" ok native-write coordinator not created yet", ...pathLine]; + case "ready": + return [" ok native-write coordinator has an authoritative transition row", ...pathLine, ...evidenceLines]; + case "zero-byte": + return [ + " !! native-write coordinator is a zero-byte remnant and has no authority", + ...pathLine, + ...evidenceLines, + ` Action: stop the OpenCodex proxy/service, then run ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`, + ]; + case "unversioned-empty": + return [ + " !! native-write coordinator is a non-empty unversioned database; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "rowless": + return [ + " !! native-write coordinator has schema version 1 but no authoritative row; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "unversioned-nonempty": + return [ + " !! native-write coordinator is unversioned and contains unknown tables; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "unsupported": + return [ + ` !! native-write coordinator schema version ${diagnostic.version} is unsupported; automatic recovery is refused`, + ...pathLine, + ...evidenceLines, + ]; + case "changed": + return [" -- native-write coordinator changed during diagnosis; re-run ocx doctor", ...pathLine]; + case "unsafe": + return [` !! native-write coordinator path is unsafe: ${diagnostic.reason}`, ...pathLine]; + case "unreadable": + return [` !! native-write coordinator is unreadable: ${diagnostic.reason}`, ...pathLine, ...evidenceLines]; + } +} + /** Render the doctor "Memory / runtime" section lines (testable without console capture). */ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] { const lines: string[] = []; @@ -846,6 +906,33 @@ export async function runDoctor(args: string[] = []): Promise { return; } + if (args.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) { + if (!args.includes("--yes")) { + console.log(`Recovery is explicit and creates a same-directory backup. Re-run: ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`); + process.exitCode = 1; + return; + } + const diagnostics = readConfigDiagnostics().config; + const live = await findLiveProxy({ + configFn: () => ({ port: diagnostics.port, hostname: diagnostics.hostname }), + }); + if (live) { + console.log(`Recovery refused: OpenCodex proxy pid ${live.pid} is still running. Stop the proxy/service and retry.`); + process.exitCode = 1; + return; + } + const recovered = recoverZeroByteCodexCoordinator(); + if (!recovered.ok) { + console.log(`Recovery refused: ${recovered.reason}.`); + process.exitCode = 1; + return; + } + console.log(`Moved the non-authoritative coordinator to ${recovered.backupPath}`); + console.log("Run `ocx sync` to retry Codex config injection. The backup was preserved and no Codex config/catalog file was changed by recovery."); + process.exitCode = 0; + return; + } + console.log("opencodex doctor\n"); // Ordering note: the memory/runtime section renders after "Running proxy @@ -1005,6 +1092,8 @@ export async function runDoctor(args: string[] = []): Promise { const reason = cause instanceof CodexUserIdentityRefusal ? cause.message : String(cause); console.log(` -- history coordinator namespace refused: ${reason}`); } + console.log("\nCodex native-write coordinator"); + for (const line of formatCoordinatorDoctorLines(inspectCodexCoordinator())) console.log(line); const pending = countPendingOpencodexHistory(); if (pending.failed) { console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown"); diff --git a/src/cli/help.ts b/src/cli/help.ts index ca1efe8c01..89e2a4edb2 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -38,6 +38,8 @@ Usage: ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability) ocx doctor --reclaim-response-temps Reclaim abandoned response-state temp files (works without a running proxy) + ocx doctor --recover-zero-byte-coordinator --yes + Back up a proven zero-byte Codex coordinator after stopping the proxy ocx debug provider/usage/injection/claude on|off|status|reset ocx login OAuth or API-key provider login ocx logout Remove a stored OAuth login diff --git a/src/cli/registry.ts b/src/cli/registry.ts index c8c786b54e..b52a5c81b7 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -61,10 +61,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "service", - usage: "ocx service [install|start|stop|status|uninstall|remove]", + usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]", summary: "Run as a background service.", details: [ - "With no subcommand, installs/updates and starts the background service.", + "With no subcommand, installs when absent or repairs/restarts an existing service.", + "`restart` is an alias of `repair` and does not re-register an installed service.", "Use `ocx service status` to see diagnostics and log paths.", ], }, @@ -108,6 +109,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ name: "doctor", usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability).", + details: [ + "Default mode is observe-only and reports the native-write coordinator state and exact path.", + "After stopping the proxy/service, `--recover-zero-byte-coordinator --yes` moves only a proven zero-byte coordinator to a same-directory backup.", + ], }, { name: "debug", diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 71a79b1b67..7dd58c6b91 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,3 +1,4 @@ +import { createHmac, randomBytes } from "node:crypto"; import { CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, @@ -38,6 +39,38 @@ import { getAccountQuota } from "./quota"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; +import { retainedUtf8Bytes } from "../lib/admission"; + +const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; +const CODEX_APP_AFFINITY_KEY = randomBytes(32); + +function boundedCodexAffinityComponent(value: string | null): string | undefined { + const normalized = value?.trim(); + if (!normalized) return undefined; + if (retainedUtf8Bytes(normalized) > CODEX_AFFINITY_COMPONENT_MAX_BYTES) return undefined; + return normalized; +} + +/** + * Preserve Codex's parent-thread affinity when present. Desktop App requests can omit that + * header while retaining a stable session/thread pair, so derive an opaque process-local key + * only from the complete bounded pair. Raw identifiers and durable hashes never enter Pool state. + */ +export function codexPoolAffinityKey(headers: Headers): string | undefined { + const parentThreadId = boundedCodexAffinityComponent(headers.get("x-codex-parent-thread-id")); + if (parentThreadId) return parentThreadId; + + const sessionId = boundedCodexAffinityComponent(headers.get("session-id")); + const threadId = boundedCodexAffinityComponent(headers.get("thread-id")); + if (!sessionId || !threadId) return undefined; + + return `app:${createHmac("sha256", CODEX_APP_AFFINITY_KEY) + .update("opencodex-app-pool-affinity-v1\0") + .update(sessionId) + .update("\0") + .update(threadId) + .digest("base64url")}`; +} export type CodexAuthContext = | { kind: "main"; accountId: null } @@ -50,6 +83,8 @@ export type CodexAuthContext = chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; + /** Pool binding key; the Desktop fallback is an opaque process-local HMAC. */ + affinityKey?: string; /** * Set when this request was admitted through an active quota cooldown as * the account's single probe. Must be echoed into the upstream outcome so @@ -71,6 +106,8 @@ export type CodexAuthContext = chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; + /** See `pool.affinityKey`. */ + affinityKey?: string; /** See `pool.probeLeaseId`. */ probeLeaseId?: string; quotaScope?: CodexQuotaScope; @@ -343,6 +380,7 @@ export async function resolveCodexAuthContext( } return { kind: "main", accountId: null }; } + const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined; const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config) : undefined; @@ -369,7 +407,6 @@ export async function resolveCodexAuthContext( // routing inspect it. Selectors arriving after the fence skip reconciliation // and may still route to non-main pool accounts without touching switch state. if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState(); - const threadId = headers.get("x-codex-parent-thread-id"); const resolution = fixedAccountId !== undefined ? { status: "selected" as const, accountId: fixedAccountId } : options.excludeAccountId @@ -385,7 +422,7 @@ export async function resolveCodexAuthContext( ? { status: "selected" as const, accountId: selected } : { status: "none" as const }; })() - : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope, selectionOptions); + : resolveCodexAccountForThreadDetailed(affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const selected = resolution.status === "selected" ? resolution.accountId : null; if (!selected) { @@ -500,6 +537,7 @@ export async function resolveCodexAuthContext( accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), + ...(affinityKey ? { affinityKey } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), @@ -516,6 +554,7 @@ export async function resolveCodexAuthContext( accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), + ...(affinityKey ? { affinityKey } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 3bf5daa086..0648b64d17 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -147,7 +147,7 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) entry.service_tiers = [{ id: "priority", name: "Fast", - description: "1.5x speed, increased usage", + description: model.fastTierDescription ?? "1.5x speed, increased usage", }]; entry.additional_speed_tiers = ["fast"]; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 0d1b2c2aaa..a2a1c86c7c 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -125,6 +125,8 @@ export interface CatalogModel { supportsVerbosity?: boolean; /** Whether this exact routed model has a verified OpenAI-compatible service tier. */ supportsServiceTier?: boolean; + /** Optional provider-specific copy for the advertised Fast tier. */ + fastTierDescription?: string; supportsReasoningSummaries?: boolean; /** * Codex tool calling mode for this routed model. diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 9c3384b4e3..565f6057a4 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -34,7 +34,8 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { captureFastPolicyAuthority, - serviceTierSupportForModel, + fastPolicyForModel, + serviceTierSupportFromPolicy, } from "../../providers/service-tier"; import type { FastPolicyAuthority } from "../../providers/fastwire"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; @@ -647,8 +648,13 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); - const supportsServiceTier = serviceTierSupportForModel(prov, model.id, name); - const { supportsServiceTier: _staleServiceTier, ...modelWithoutServiceTier } = model; + const fastPolicy = fastPolicyForModel(prov, model.id, name); + const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy); + const { + supportsServiceTier: _staleServiceTier, + fastTierDescription: _staleFastTierDescription, + ...modelWithoutServiceTier + } = model; // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow @@ -671,6 +677,9 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined + ? { fastTierDescription: fastPolicy.fastTierDescription } + : {}), ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}), // Default-on for openai-chat providers (explicit false opts out); other adapters // advertise only on explicit opt-in. @@ -1845,8 +1854,11 @@ async function gatherRoutedModelsUncached( ? nativeDefaultReasoningEffort(cm.modelId) : undefined; const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); - const supportsServiceTier = effectiveProvider - ? serviceTierSupportForModel(effectiveProvider, cm.modelId, cm.provider) + const fastPolicy = effectiveProvider + ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider) + : undefined; + const supportsServiceTier = fastPolicy + ? serviceTierSupportFromPolicy(fastPolicy) : undefined; const base: CatalogModel = { id: cm.modelId, @@ -1883,6 +1895,9 @@ async function gatherRoutedModelsUncached( ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined + ? { fastTierDescription: fastPolicy.fastTierDescription } + : {}), ...(cm.codexToolMode !== undefined ? { codexToolMode: cm.codexToolMode } : effectiveProvider?.codexToolMode !== undefined diff --git a/src/codex/coordinator-doctor.ts b/src/codex/coordinator-doctor.ts new file mode 100644 index 0000000000..1c238bd922 --- /dev/null +++ b/src/codex/coordinator-doctor.ts @@ -0,0 +1,332 @@ +/** + * Observe and explicitly quarantine non-authoritative native-write coordinators. + * + * Default doctor runs use immutable SQLite reads so diagnostics cannot create + * WAL/SHM sidecars. Recovery is deliberately opt-in and moves, never deletes, + * only a file that is still the same private regular file observed beforehand. + */ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + realpathSync, + renameSync, + type Stats, +} from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { Database, constants as sqliteConstants } from "bun:sqlite"; + +import { resolveCodexHomeDir } from "./home"; +import { + CodexUserIdentityRefusal, + probeCodexCoordinatorNamespace, + resolveEffectiveUserIdentity, + samePathIdentity, +} from "./user-identity"; +import { + CODEX_COORDINATOR_SCHEMA_VERSION, + readCodexCoordinatorState, +} from "./transition-state"; + +const IMMUTABLE_READONLY_FLAGS = + sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI; + +export type FileIdentity = Pick; + +export interface CodexCoordinatorDiagnosticEvidence { + sizeBytes: number; + schemaVersion: number; + tables: readonly string[]; + transitionRows: number | null; + singletonRows: number | null; +} + +export type CodexCoordinatorDiagnostic = + | { kind: "absent"; path: string | null } + | { kind: "zero-byte"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unversioned-empty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unversioned-nonempty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "rowless"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "ready"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unsupported"; path: string; identity: FileIdentity; version: number; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "changed"; path: string } + | { kind: "unsafe"; path: string | null; reason: string } + | { kind: "unreadable"; path: string; reason: string; evidence?: CodexCoordinatorDiagnosticEvidence }; + +export type CodexCoordinatorRecoveryResult = + | { ok: true; backupPath: string } + | { ok: false; reason: string }; + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +function sameNodeAndSize(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size; +} + +function coordinatorPathWithoutCreation(): { kind: "absent"; path: string | null } | { kind: "path"; path: string } { + const identity = resolveEffectiveUserIdentity(); + const canonicalCodexHome = realpathSync.native(resolveCodexHomeDir()); + const namespace = probeCodexCoordinatorNamespace(identity); + if (namespace.status === "missing") return { kind: "absent", path: null }; + + const locks = join(namespace.root, "native-write-locks"); + let locksEntry: Stats; + try { + locksEntry = lstatSync(locks); + } catch (cause) { + if (errorCode(cause) === "ENOENT") { + const digest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return { kind: "absent", path: join(locks, `${digest}.sqlite`) }; + } + throw new CodexUserIdentityRefusal("The coordinator lock directory cannot be inspected.", { cause }); + } + if (locksEntry.isSymbolicLink() || !locksEntry.isDirectory()) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace is not a real directory."); + } + if (identity.platform === "posix") { + if (locksEntry.uid !== identity.uid || (locksEntry.mode & 0o777) !== 0o700) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace has unsafe ownership or permissions."); + } + } else if (!samePathIdentity(realpathSync.native(locks), locks, "win32")) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace is redirected by a junction or reparse point."); + } + + const digest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return { kind: "path", path: join(locks, `${digest}.sqlite`) }; +} + +function inspectTarget( + path: string, + options: { allowSqliteSidecars?: boolean } = {}, +): { kind: "absent" } | { kind: "file"; identity: FileIdentity } | { kind: "unsafe"; reason: string } { + let entry: Stats; + try { + entry = lstatSync(path); + } catch (cause) { + if (errorCode(cause) === "ENOENT") return { kind: "absent" }; + return { kind: "unsafe", reason: "the coordinator file cannot be inspected" }; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + return { kind: "unsafe", reason: "the coordinator path is not a real file" }; + } + try { + if (!samePathIdentity(realpathSync.native(path), path)) { + return { kind: "unsafe", reason: "the coordinator path is redirected" }; + } + } catch { + return { kind: "unsafe", reason: "the coordinator path cannot be resolved" }; + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + if (uid === undefined || entry.uid !== uid || (entry.mode & 0o777) !== 0o600) { + return { kind: "unsafe", reason: "the coordinator file has unsafe ownership or permissions" }; + } + } + if (!options.allowSqliteSidecars) { + for (const suffix of ["-journal", "-wal", "-shm"]) { + if (existsSync(`${path}${suffix}`)) { + return { kind: "unsafe", reason: `the coordinator has an active SQLite ${suffix.slice(1)} sidecar` }; + } + } + } + return { kind: "file", identity: entry }; +} + +function classifyOpenedDatabase( + database: Database, + path: string, + identity: FileIdentity, +): CodexCoordinatorDiagnostic { + const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0; + const tables = database.query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).all().map(row => row.name); + const baseEvidence = { + sizeBytes: identity.size, + schemaVersion: version, + tables, + transitionRows: null, + singletonRows: null, + } satisfies CodexCoordinatorDiagnosticEvidence; + if (version === 0) { + const evidence = tables.length === 0 + ? { ...baseEvidence, transitionRows: 0, singletonRows: 0 } + : baseEvidence; + return tables.length === 0 + ? { kind: "unversioned-empty", path, identity, evidence } + : { kind: "unversioned-nonempty", path, identity, evidence }; + } + if (version !== CODEX_COORDINATOR_SCHEMA_VERSION) { + return { kind: "unsupported", path, identity, version, evidence: baseEvidence }; + } + if (tables.length !== 1 || tables[0] !== "codex_transition_state") { + return tables.length === 0 + ? { kind: "rowless", path, identity, evidence: baseEvidence } + : { kind: "unreadable", path, reason: "the coordinator contains unexpected tables", evidence: baseEvidence }; + } + let rowCounts: { total: number; singleton: number } | null; + try { + rowCounts = database.query<{ total: number; singleton: number }, []>( + "SELECT count(*) AS total, sum(CASE WHEN singleton = 1 THEN 1 ELSE 0 END) AS singleton FROM codex_transition_state", + ).get() ?? null; + } catch { + return { + kind: "unreadable", + path, + reason: "the transition table schema is not recognized", + evidence: baseEvidence, + }; + } + const evidence = { + ...baseEvidence, + transitionRows: rowCounts?.total ?? null, + singletonRows: rowCounts?.singleton ?? null, + }; + if (!rowCounts || rowCounts.total === 0) return { kind: "rowless", path, identity, evidence }; + if (rowCounts.total !== 1 || rowCounts.singleton !== 1) { + return { + kind: "unreadable", + path, + reason: "the coordinator does not contain exactly one singleton row", + evidence, + }; + } + try { + readCodexCoordinatorState(database); + } catch { + return { + kind: "unreadable", + path, + reason: "the authoritative transition row is malformed", + evidence, + }; + } + return { kind: "ready", path, identity, evidence }; +} + +export function inspectCodexCoordinator(): CodexCoordinatorDiagnostic { + let resolved: ReturnType; + try { + resolved = coordinatorPathWithoutCreation(); + } catch (cause) { + return { + kind: "unsafe", + path: null, + reason: cause instanceof Error ? cause.message : String(cause), + }; + } + if (resolved.kind === "absent") return resolved; + return inspectCodexCoordinatorPath(resolved.path); +} + +/** Inspect one already-resolved coordinator path without creating SQLite state. */ +export function inspectCodexCoordinatorPath(path: string): CodexCoordinatorDiagnostic { + const target = inspectTarget(path); + if (target.kind === "absent") return { kind: "absent", path }; + if (target.kind === "unsafe") return { kind: "unsafe", path, reason: target.reason }; + + let database: Database | undefined; + try { + const uri = `${pathToFileURL(path).href}?immutable=1`; + database = new Database(uri, IMMUTABLE_READONLY_FLAGS); + const result = classifyOpenedDatabase(database, path, target.identity); + const after = inspectTarget(path); + if (after.kind !== "file" || !sameIdentity(target.identity, after.identity)) { + return { kind: "changed", path }; + } + // Size alone is not evidence that this is a non-authoritative remnant. + // Query the immutable snapshot too, so the recovery label means all three + // facts were observed together: zero bytes, schema version zero, no tables. + if (target.identity.size === 0 && result.kind === "unversioned-empty") { + return { kind: "zero-byte", path, identity: target.identity, evidence: result.evidence }; + } + return result; + } catch (cause) { + return { kind: "unreadable", path, reason: cause instanceof Error ? cause.message : String(cause) }; + } finally { + try { database?.close(); } catch { /* diagnostics already completed */ } + } +} + +function recoverable(diagnostic: CodexCoordinatorDiagnostic): diagnostic is Extract< + CodexCoordinatorDiagnostic, + { kind: "zero-byte" } +> { + return diagnostic.kind === "zero-byte"; +} + +function backupTimestamp(now: Date): string { + return now.toISOString().replace(/[-:.]/g, ""); +} + +export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordinatorRecoveryResult { + const observed = inspectCodexCoordinator(); + if (!recoverable(observed)) { + if (observed.kind === "unsafe" || observed.kind === "unreadable") { + return { ok: false, reason: `coordinator state is ${observed.kind}: ${observed.reason}` }; + } + return { ok: false, reason: `coordinator state is ${observed.kind}, not a recoverable zero-byte remnant` }; + } + + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(observed.path, { readwrite: true, create: false }); + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + const lockedEntry = inspectTarget(observed.path, { allowSqliteSidecars: true }); + // SQLite may update file timestamps merely by opening a zero-byte database + // for BEGIN IMMEDIATE. Device/inode/size are the stable identity here; the + // transaction excludes content writers while we reclassify the database. + if (lockedEntry.kind !== "file" || !sameNodeAndSize(observed.identity, lockedEntry.identity)) { + return { ok: false, reason: "the coordinator changed before recovery acquired its SQLite lock" }; + } + if (lockedEntry.identity.size !== 0) { + return { ok: false, reason: "the coordinator stopped being zero-byte before recovery" }; + } + database.exec("ROLLBACK"); + transactionOpen = false; + database.close(); + database = undefined; + + const finalEntry = inspectTarget(observed.path); + if (finalEntry.kind !== "file" || !sameIdentity(lockedEntry.identity, finalEntry.identity)) { + return { ok: false, reason: "the coordinator changed before the backup move" }; + } + const backupPath = `${observed.path}.zero-byte-backup-${backupTimestamp(now)}`; + if (existsSync(backupPath)) return { ok: false, reason: "the same-directory backup path already exists" }; + renameSync(observed.path, backupPath); + const backupEntry = inspectTarget(backupPath); + // The rename itself can advance ctime, so post-move verification uses the + // stable filesystem object and byte size. The full timestamp identity was + // already revalidated immediately before rename while the source existed. + if (backupEntry.kind !== "file" || !sameNodeAndSize(finalEntry.identity, backupEntry.identity) || existsSync(observed.path)) { + return { ok: false, reason: "the coordinator backup move could not be verified" }; + } + return { ok: true, backupPath }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + const busy = errorCode(cause) === "SQLITE_BUSY" || errorCode(cause) === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); + return { ok: false, reason: busy ? "the coordinator is busy; stop active sync/service writers and retry" : message }; + } finally { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases the lock */ } + } + try { database?.close(); } catch { /* recovery already completed */ } + } +} diff --git a/src/codex/features.ts b/src/codex/features.ts index 8958564a30..9875a6ea2c 100644 --- a/src/codex/features.ts +++ b/src/codex/features.ts @@ -155,6 +155,29 @@ export function multiAgentV2EnabledFromConfigText(content: string | null): boole return false; } + // Bun 1.4 enforces TOML's "value must begin on the assignment line" rule that + // 1.3.14 did not, so `hint =` followed by `[` on the next line now fails the + // real parse and reaches the line-based fallback below — which reads that `[` + // as a table header and truncates the table before `enabled`. Codex's own + // parser accepts the document, so answering "disabled" would report a parser + // disagreement as a feature state (#1295, #1691). + // + // Joining a dangling `=` to the line that follows is the smallest repair that + // keeps the scanner untouched: widening `tomlTableBody` to be string-aware is + // what previously broke `getAgentsEnabled`, `getAgentsMaxDepth`, and + // `getMaxConcurrentThreads` (see its comment). If the joined document parses, + // that answer is authoritative; if it does not, nothing is lost. + const joined = joinDanglingTomlAssignments(content); + if (joined !== content) { + const reparsed = parsedTomlTable(joined, "features"); + if (reparsed !== null) { + const table = plainTomlRecord(reparsed.multi_agent_v2); + if (table !== null) return table.enabled === true; + if (typeof reparsed.multi_agent_v2 === "boolean") return reparsed.multi_agent_v2; + return false; + } + } + const table = tomlTableBody(content, "features.multi_agent_v2"); if (table !== null) { const enabled = tomlBoolInBody(table, "enabled"); @@ -184,6 +207,41 @@ function plainTomlRecord(value: unknown): Record | null { : null; } +/** + * Join `key =` to the following line when the value was written on the next + * line, so a parser enforcing TOML's same-line rule can read the document. + * + * Bun 1.3.14 accepted this shape; Bun 1.4 rejects it, correctly — TOML requires + * the value to begin on the assignment line. Codex's parser still accepts it, so + * this exists to keep the two readers agreeing rather than to endorse the shape. + * + * Deliberately narrow: it only acts on a line whose LAST non-comment character + * is `=`, which cannot occur in a valid assignment. Lines inside multi-line + * strings are left alone — a `"""` body line ending in `=` would be rewritten, + * but the result is only used when it PARSES, and the unmodified document is + * always tried first, so a wrong join cannot displace a correct read. + */ +function joinDanglingTomlAssignments(content: string): string { + const lines = content.split("\n"); + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + // A dangling assignment: trailing `=` with nothing after it on this line. + if (/^[^#]*[^=!<>]=\s*$/.test(line) && i + 1 < lines.length) { + let j = i + 1; + // Skip blank and comment-only lines between the `=` and its value. + while (j < lines.length && /^\s*(?:#.*)?$/.test(lines[j]!)) j++; + if (j < lines.length) { + out.push(`${line.replace(/\s*$/, "")} ${lines[j]!.replace(/^\s*/, "")}`); + i = j; + continue; + } + } + out.push(line); + } + return out.join("\n"); +} + /** * A top-level table from a full TOML parse, or null when the document does not * parse. A parsed document with no such table yields `{}` rather than null: that diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 91f9374bc6..a8b1c28858 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -5,10 +5,11 @@ * sequence it is, rather than doubling in length around the lock. */ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { atomicWriteFile } from "../config"; import type { CodexWriteLockResult } from "./codex-write-lock"; +import { inspectCodexCoordinatorPath } from "./coordinator-doctor"; import { JOURNAL_PATH } from "./journal"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; import { @@ -43,21 +44,51 @@ export type CodexWriteCoordinationEligibility = | { kind: "legacy-uncoordinated"; reason: string } | { kind: "refused"; reason: string }; +/** + * A live SQLite creator exposes a zero-byte pathname before BEGIN IMMEDIATE. + * Requiring a settled filesystem age makes that scheduling window remain on + * the coordinated path while old crash remnants can use the legacy boundary. + */ +export const STABLE_ZERO_BYTE_COORDINATOR_AGE_MS = 1_000; + export function codexWriteCoordinationEligibility(deps: { coordinatorPath: () => string; residue: () => { kind: string }; integrationRecord: () => { kind: string }; + nowMs?: () => number; }): CodexWriteCoordinationEligibility { let coordinatorExists: boolean; + let coordinatorIsStableZeroByte = false; try { - coordinatorExists = existsSync(deps.coordinatorPath()); + const path = deps.coordinatorPath(); + coordinatorExists = existsSync(path); + if (coordinatorExists) { + const entry = lstatSync(path); + if (entry.isFile() && !entry.isSymbolicLink() && entry.size === 0) { + const diagnostic = inspectCodexCoordinatorPath(path); + if (diagnostic.kind === "zero-byte") { + const lastIdentityChange = Math.max(diagnostic.identity.mtimeMs, diagnostic.identity.ctimeMs); + coordinatorIsStableZeroByte = (deps.nowMs?.() ?? Date.now()) - lastIdentityChange + >= STABLE_ZERO_BYTE_COORDINATOR_AGE_MS; + } + } + } } catch (error) { return { kind: "refused", reason: `the coordinator path could not be resolved: ${String(error)}` }; } - // An existing coordinator is authoritative, and the lock owns validating it — - // including the unversioned and rowless cases it must refuse rather than adopt. - if (coordinatorExists) return { kind: "coordinated" }; + // Every existing coordinator remains authoritative unless it is proven to be + // an old, immutable SQLite-empty remnant. The age gate is part of that proof: + // a live creator exposes the same zero-byte pathname briefly before taking N, + // and sending that fresh file down the legacy path would bypass its lock. + // Non-empty, fresh, unsafe, changed, unversioned, and rowless files therefore + // stay coordinated and are validated/refused by the transaction owner. + // + // We do NOT initialize or adopt it here. Clean homes still enter the + // coordinated path, whose SQLite transaction safely initializes it. Routed + // or indeterminate legacy homes keep the same uncoordinated compatibility + // boundary they would have had if the remnant pathname were absent. + if (coordinatorExists && !coordinatorIsStableZeroByte) return { kind: "coordinated" }; const record = deps.integrationRecord(); if (record.kind === "invalid") { @@ -83,7 +114,9 @@ export function codexWriteCoordinationEligibility(deps: { */ return { kind: "legacy-uncoordinated", - reason: residue.kind === "residue" + reason: coordinatorIsStableZeroByte + ? "the coordinator is a zero-byte non-authoritative remnant and this routed home has not been adopted yet" + : residue.kind === "residue" ? "this home was routed before write coordination existed and has not been adopted yet" : "the existing native Codex state could not be classified, so it cannot seed a coordinator row", }; diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index 27ce605530..ed00fca09f 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -37,7 +37,7 @@ import { samePathIdentity, } from "./user-identity"; -const COORDINATOR_SCHEMA_VERSION = 1; +export const CODEX_COORDINATOR_SCHEMA_VERSION = 1; const DURABLE_HISTORY_STATUSES = new Set(["converged", "pending", "running", "blocked", "unknown"]); const DURABLE_HISTORY_REASONS = new Set([ "db-busy", @@ -241,7 +241,7 @@ function rowToState(row: TransitionRow | null): CodexTransitionState { }; } -function readState(database: Database): CodexTransitionState { +export function readCodexCoordinatorState(database: Database): CodexTransitionState { const row = database.query(SELECT_TRANSITION_ROW).get(); return rowToState(row); } @@ -282,7 +282,7 @@ function assertInitialStateCanBeCreated(): void { function initialize(database: Database, databaseWasAbsent: boolean): void { const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version; - if (version !== 0 && version !== COORDINATOR_SCHEMA_VERSION) { + if (version !== 0 && version !== CODEX_COORDINATOR_SCHEMA_VERSION) { throw new CodexCoordinatorTransactionError("The coordinator database schema version is unsupported."); } if (!databaseWasAbsent && version === 0) { @@ -301,8 +301,8 @@ function initialize(database: Database, databaseWasAbsent: boolean): void { assertInitialStateCanBeCreated(); database.query(INITIALIZE_TRANSITION_ROW).run(new Date().toISOString()); } - if (version === 0) database.exec(`PRAGMA user_version = ${COORDINATOR_SCHEMA_VERSION}`); - readState(database); + if (version === 0) database.exec(`PRAGMA user_version = ${CODEX_COORDINATOR_SCHEMA_VERSION}`); + readCodexCoordinatorState(database); } function createCapability( @@ -336,7 +336,7 @@ function createCapability( expected.nativeGeneration, expected.currentTxId, ); - const state = readState(database); + const state = readCodexCoordinatorState(database); const update: TransitionStateUpdate = result.changes === 1 ? { kind: "updated", state } : { kind: "conflict", current: state }; @@ -451,7 +451,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code capability, expectation() { requireOpen(); - const state = readState(db); + const state = readCodexCoordinatorState(db); return { nativeBefore: state.nativeGeneration, nativeAfter: state.nativeGeneration + 1, @@ -460,7 +460,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code }, version() { requireOpen(); - const state = readState(db); + const state = readCodexCoordinatorState(db); return { nativeGeneration: state.nativeGeneration, currentTxId: state.currentTxId }; }, assertPublished(expectation) { @@ -468,7 +468,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code if (lastResult?.kind !== "updated") { throw new CodexCoordinatorTransactionError("The coordinator transition was not published."); } - const state = readState(db); + const state = readCodexCoordinatorState(db); if (state.nativeGeneration !== expectation.nativeAfter || state.currentTxId !== expectation.txId) { throw new CodexCoordinatorTransactionError("The coordinator published a different transition."); } @@ -540,7 +540,7 @@ function readCommittedState(): TransitionStateRead { try { database = new Database(path, { readonly: true }); database.exec("PRAGMA busy_timeout = 0"); - return { kind: "ready", state: readState(database) }; + return { kind: "ready", state: readCodexCoordinatorState(database) }; } catch (error) { return mapUnavailable(error); } finally { @@ -577,7 +577,7 @@ export const updateCodexHistoryTransition: UpdateCodexHistoryTransition = (expec database = new Database(currentCoordinatorDatabasePath(), { readwrite: true, create: false }); database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); transactionOpen = true; - const current = readState(database); + const current = readCodexCoordinatorState(database); if (current.nativeGeneration > 0 && current.historySchedule === null) { throw new CodexCoordinatorTransactionError("A positive transition cannot lose its direction."); } @@ -594,7 +594,7 @@ export const updateCodexHistoryTransition: UpdateCodexHistoryTransition = (expec expected.currentTxId, expected.currentTxId, ); - const state = readState(database); + const state = readCodexCoordinatorState(database); database.exec("COMMIT"); transactionOpen = false; return result.changes === 1 diff --git a/src/lib/bun-stream-caps.ts b/src/lib/bun-stream-caps.ts index e86df8a556..ca4a61315a 100644 --- a/src/lib/bun-stream-caps.ts +++ b/src/lib/bun-stream-caps.ts @@ -3,9 +3,9 @@ * * The eager bounded relay (src/server/relay-eager.ts) uses a JS async producer * loop — the exact shape of the Bun#32111 use-after-free (fixed upstream by Bun - * PR #32120, merged 2026-06-21). No RELEASED Bun version is proven to carry - * that fix yet, so `MIN_FIXED_BUN_VERSION` is null: every runtime is - * "known-bad" until a bundle-bump commit sets it. Windows no-rewrite traffic + * PR #32120, merged 2026-06-21). Bun 1.4.0 is the first RELEASED version proven + * to carry that fix, so `MIN_FIXED_BUN_VERSION` is "1.4.0": older runtimes stay + * "known-bad". Windows no-rewrite traffic * follows this runtime/config decision, preserving the explicit legacy-tee * safety pin. Darwin no-rewrite traffic stays on tee * for `auto` regardless of runtime capability and reaches eager relay only via @@ -21,8 +21,11 @@ /** * Bump in the SAME commit that bumps package.json's bundled Bun to a version * verified to include Bun PR #32120. null = no released version is known-fixed. + * Bun 1.4.0 (npm stable, bundled by this package.json) carries the fix: PR + * #32120 merged 2026-06-21, well before the 1.4.0 cut, and the full suite ran + * green under the 1.4 line on every supported OS (devlog/260814_bun14-preview-dev). */ -export const MIN_FIXED_BUN_VERSION: string | null = null; +export const MIN_FIXED_BUN_VERSION: string | null = "1.4.0"; export type StreamMode = "auto" | "legacy-tee" | "eager-relay"; diff --git a/src/lib/errors.ts b/src/lib/errors.ts index c5523712dc..a7bbdb71e9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -162,11 +162,16 @@ export function classifyError(status: number, type: string, message: string): Oc return { message, type: "invalid_request_error", code: "context_length_exceeded" }; } // "Cursor resource limit exceeded" is emitted only for explicit request-size overflow - // details (isCursorRequestTooLargeDetail in cursor-errors.ts); quota-style resource - // exhaustion arrives as "Cursor rate limit exceeded" and falls through to 429 below. + // details (isCursorRequestTooLargeDetail in cursor-errors.ts); "Cursor context limit + // exceeded" is the bare payload-overflow shape (isCursorZeroTokenResourceExhausted); + // quota-style resource exhaustion arrives as "Cursor rate limit exceeded" and falls + // through to 429 below. if (text.includes("cursor resource limit exceeded")) { return { message, type: "invalid_request_error", code: "tool_catalog_too_large" }; } + if (text.includes("cursor context limit exceeded")) { + return { message, type: "invalid_request_error", code: "context_length_exceeded" }; + } // The Cursor adapter's classified rate-limit prefix is authoritative: its DETAIL may echo // quota wording ("... quota exhausted") that would otherwise hit the insufficient_quota // branch below and break the planned retry-with-backoff contract (WP3 review blocker 1). @@ -306,6 +311,7 @@ export function inferHttpStatusFromAdapterMessage(message: string): number { // See classifyError: this prefix now only means explicit request-size overflow (400); // quota-style Cursor resource exhaustion carries the rate-limit prefix and maps to 429. if (lower.includes("cursor resource limit exceeded")) return 400; + if (lower.includes("cursor context limit exceeded")) return 400; if ( lower.includes("resource_exhausted") || lower.includes("resource exhausted") || diff --git a/src/oauth/cursor.ts b/src/oauth/cursor.ts index d7607cef83..d30bc33b87 100644 --- a/src/oauth/cursor.ts +++ b/src/oauth/cursor.ts @@ -101,6 +101,19 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } +/** Terminal poll statuses (T07, senpi PR #905): the login is denied/expired — retrying cannot succeed. */ +const POLL_TERMINAL_STATUSES = new Set([400, 401, 403, 410]); + +export class CursorAuthTerminalError extends Error { + readonly status: number; + + constructor(status: number) { + super(`Cursor login rejected by the auth server (HTTP ${status}); start a new login`); + this.name = "CursorAuthTerminalError"; + this.status = status; + } +} + /** * Poll cursor.com for login completion. 404 = still pending (back off), 200 = tokens. * `baseDelayMs` is injectable so tests can avoid the real 1s cadence; production uses the default. @@ -135,9 +148,17 @@ export async function pollCursorAuth( return { accessToken: data.accessToken, refreshToken: data.refreshToken }; } + // T07: a terminal auth status means the login attempt itself is dead (denied, + // expired, revoked). Fail on the FIRST such response instead of burning the + // 3-strike retry budget and masking the reason behind a generic error. + if (POLL_TERMINAL_STATUSES.has(response.status)) { + throw new CursorAuthTerminalError(response.status); + } + throw new Error(`Cursor auth poll failed: ${response.status}`); } catch (err) { if (signal?.aborted) throw err instanceof Error ? err : new Error("Cursor login cancelled"); + if (err instanceof CursorAuthTerminalError) throw err; consecutiveErrors++; if (consecutiveErrors >= 3) { throw new Error("Too many consecutive errors during Cursor auth polling"); diff --git a/src/providers/command-code-efforts.ts b/src/providers/command-code-efforts.ts index b790c8779d..b7d5b15d1b 100644 --- a/src/providers/command-code-efforts.ts +++ b/src/providers/command-code-efforts.ts @@ -9,6 +9,13 @@ const COMMAND_CODE_MODEL_EFFORTS = { efforts: ["high", "max"], profileUrl: "https://commandcode.ai/models/deepseek-v4-flash", }, + // Ox Alpha (stealth preview, added in Command Code v1.31.0): free 1M-context + // reasoning model on every plan. The profile does not publish an effort ladder, + // so mirror the OpenRouter contract (reasoning mandatory; max/high/low). + "stealth/ox-alpha": { + efforts: ["low", "high", "max"], + profileUrl: "https://commandcode.ai/models/ox-alpha", + }, // Keys must match the EXACT upstream /provider/v1/models ids (GLM ships as // `zai-org/GLM-5.3`, not `zai-org/glm-5.3`). The table doubles as the router's // known-ids decode source (via `knownModelIdsForProvider`), so a case mismatch diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts new file mode 100644 index 0000000000..a83c93d216 --- /dev/null +++ b/src/providers/cursor-pool.ts @@ -0,0 +1,72 @@ +/** + * Weighted credential routing for Cursor accounts. + * + * Transfer from yelixir-dev/cursor-ai-proxy-bridge credentials.ts: + * weighted round-robin selection with per-credential auth-failure cooldown + * and one-retry failover on a different account before surfacing the error. + * + * OpenCodex already has JWT-based multi-account identification (src/oauth/cursor.ts) + * and Anthropic-specific 429 rotation; this module adds Cursor-aware weighted + * routing on top of those primitives. + */ + +export interface CursorCredential { + readonly id: string; + weight: number; +} + +interface CredentialState { + readonly credential: CursorCredential; + currentWeight: number; + disabledUntil: number; +} + +export class NoAvailableCursorCredentialError extends Error { + constructor(message = "No available Cursor credentials") { super(message); } +} + +export class CursorCredentialRouter { + private states: CredentialState[] = []; + private readonly cooldownMs: number; + + constructor(credentials: ReadonlyArray, cooldownMs = 300_000) { + this.cooldownMs = cooldownMs; + this.replace(credentials); + } + + replace(credentials: ReadonlyArray): void { + this.states = credentials.map(c => ({ + credential: { ...c, weight: Math.max(1, c.weight || 1) }, + currentWeight: 0, + disabledUntil: 0, + })); + } + + pick(excludeIds: ReadonlySet = new Set()): CursorCredential { + const now = Date.now(); + const candidates = this.states.filter(s => + !excludeIds.has(s.credential.id) && s.disabledUntil <= now, + ); + if (candidates.length === 0) throw new NoAvailableCursorCredentialError(); + let selected: CredentialState | undefined; + let totalWeight = 0; + for (const state of candidates) { + state.currentWeight += state.credential.weight; + totalWeight += state.credential.weight; + if (!selected || state.currentWeight > selected.currentWeight) selected = state; + } + if (!selected) throw new NoAvailableCursorCredentialError(); + selected.currentWeight -= totalWeight; + return { ...selected.credential }; + } + + disable(id: string): void { + const state = this.states.find(s => s.credential.id === id); + if (state) state.disabledUntil = Date.now() + this.cooldownMs; + } + + get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> { + const now = Date.now(); + return this.states.map(s => ({ id: s.credential.id, disabled: s.disabledUntil > now })); + } +} diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c00df10bee..63cd1c9388 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -483,6 +483,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsOpenAiWebSearchToolFields === undefined && entry.supportsOpenAiWebSearchToolFields !== undefined) { prov.supportsOpenAiWebSearchToolFields = entry.supportsOpenAiWebSearchToolFields; } + if (prov.supportsResponsesCustomTools === undefined && entry.supportsResponsesCustomTools !== undefined) { + prov.supportsResponsesCustomTools = entry.supportsResponsesCustomTools; + } if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); diff --git a/src/providers/fastwire.ts b/src/providers/fastwire.ts index 63cd642532..9098bd9df9 100644 --- a/src/providers/fastwire.ts +++ b/src/providers/fastwire.ts @@ -33,6 +33,7 @@ export interface FastPolicyAuthority { readonly providerAdapter: string; readonly providerAuthMode?: ProviderAuthKind; readonly fastWireDeclaration: FastWire | null | undefined; + readonly fastTierDescription?: string; readonly modelWireOverrideAllowed: boolean; readonly authTransport: FastPolicyAuthTransport; readonly capability: { @@ -55,6 +56,7 @@ export interface ResolvedFastPolicy { | "pin-unavailable"; readonly adapter: string; readonly fastWire: FastWire | null; + readonly fastTierDescription?: string; readonly forwardCallerTier: boolean; } @@ -222,7 +224,16 @@ export function resolveFastPolicy( else if (capability === undefined) eligibility = "unclassified"; else eligibility = "eligible"; - return { capability, eligibility, adapter, fastWire, forwardCallerTier }; + return { + capability, + eligibility, + adapter, + fastWire, + ...(authority.fastTierDescription !== undefined + ? { fastTierDescription: authority.fastTierDescription } + : {}), + forwardCallerTier, + }; } export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | undefined { diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index e1ffc397fb..892b08462b 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -169,6 +169,7 @@ export async function resolveFirstUsableOpenAiSidecar( authContext.accountId, outcome, { + threadId: authContext.affinityKey, probeLeaseId: authContext.probeLeaseId, writerGeneration: authContext.writerGeneration, }, diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 24b06ef7a9..db0202161d 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -50,6 +50,7 @@ const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; const CLINE_BASE_URL = "https://api.cline.bot"; const ZAI_BASE_URL = "https://api.z.ai"; +const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; @@ -343,7 +344,12 @@ function isCanonicalClineBaseUrl(baseUrl: string): boolean { function isCanonicalZaiBaseUrl(baseUrl: string): boolean { const normalized = normalizedBaseUrl(baseUrl); - return normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`; + return normalized === ZAI_BASE_URL + || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` + || normalized === ZAI_CN_BASE_URL + || normalized === `${ZAI_CN_BASE_URL}/api/coding/paas/v4` + // BigModel serves the same GLM Coding Plan on the OpenAI Responses wire at /api/v1. + || normalized === `${ZAI_CN_BASE_URL}/api/v1`; } function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { @@ -669,34 +675,67 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro /** * Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan - * subscription's 5-hour token cycle, weekly quota, and monthly MCP usage. - * Authenticates with the API key as a Bearer token per Z.AI's API reference. + * limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the + * same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT` + * rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 → + * the rolling five-hour window), unit 6 is weeks (number 1 → the weekly + * window). `TIME_LIMIT` rows are the monthly MCP tool budget (Web Search / Web + * Reader / Zread). Every row's `percentage` is the consumed share (falling + * back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms) + * the window reset. */ -async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; - const apiKey = resolveEnvValue(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${ZAI_BASE_URL}/api/monitor/usage/quota/limit`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; +export function parseZaiQuotaLimits(data: Record | null): ProviderQuota | null { + const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null; + if (!limits) return null; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + for (const raw of limits) { + const row = asRecord(raw); + if (!row) continue; + const resetAt = normalizeResetAt(row.nextResetTime); + let percent = normalizePercent(row.percentage); + if (percent === undefined) { + const used = toFiniteNumber(row.currentValue); + const total = toFiniteNumber(row.usage); + if (used !== undefined && total !== undefined && total > 0) { + percent = normalizePercent((used / total) * 100); + } + } + if (percent === undefined) continue; + if (row.type === "TOKENS_LIMIT" || row.type === "CREDIT_LIMIT") { + const unit = toFiniteNumber(row.unit); + const number = toFiniteNumber(row.number); + if (unit === 3 && number === 5) { + quota.fiveHourPercent = percent; + if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; + windows += 1; + } else if (unit === 6 && number === 1) { + quota.weeklyPercent = percent; + if (resetAt !== undefined) quota.weeklyResetAt = resetAt; + windows += 1; + } + } else if (row.type === "TIME_LIMIT") { + quota.monthlyPercent = percent; + if (resetAt !== undefined) quota.monthlyResetAt = resetAt; + windows += 1; + } } - const body = asRecord(await readQuotaJson(response)); - if (!body || body.success === false) return null; - const data = asRecord(body.data) ?? body; - // The plugin renders a 5h token window, a weekly window, and a monthly MCP - // window. Look for percent fields with window identifiers. + return windows > 0 ? quota : null; +} + +/** + * Legacy Z.AI payload shape: percent fields with window identifiers directly on + * the data object (optionally nested under `quota`). Kept as a fallback so + * older responses keep rendering when the `limits` array is absent. + */ +function parseZaiQuotaLegacyFields(data: Record | null): ProviderQuota | null { + if (!data) return null; const quota: ProviderQuota = { updatedAt: Date.now() }; let windows = 0; const percentAt = (key: string): number | undefined => { - const value = normalizePercent(data?.[key]); + const value = normalizePercent(data[key]); if (value !== undefined) return value; - const nested = asRecord(data?.quota); + const nested = asRecord(data.quota); return nested ? normalizePercent(nested[key]) : undefined; }; const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed"); @@ -714,7 +753,40 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi quota.monthlyPercent = monthly; windows += 1; } - return windows > 0 ? report(provider, "zai:quota-limit", quota) : null; + return windows > 0 ? quota : null; +} + +/** + * Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider + * points at (api.z.ai or open.bigmodel.cn). Authenticates with the API key as + * a Bearer token per Z.AI's API reference. The `limits` array shape is + * preferred; older field-name payloads fall back to the legacy parser. + */ +async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const normalized = normalizedBaseUrl(config.baseUrl); + const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` + ? ZAI_BASE_URL + : ZAI_CN_BASE_URL; + const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + if (!body || body.success === false) return null; + const data = asRecord(body.data) ?? body; + const quota = Array.isArray(data?.limits) + ? parseZaiQuotaLimits(data) + : parseZaiQuotaLegacyFields(data); + return quota ? report(provider, "zai:quota-limit", quota) : null; } /** @@ -2106,7 +2178,8 @@ async function maybeFetchProviderQuota( if ((provider.authMode ?? "key") === "key" && name === "cline-pass") { return fetchClineQuota(name, provider); } - if ((provider.authMode ?? "key") === "key" && name === "zai") { + if ((provider.authMode ?? "key") === "key" + && (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) { return fetchZaiQuota(name, provider); } if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 24e46c476f..3fa208456f 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -10,6 +10,7 @@ import { MOONSHOT_BASE_URL_CHOICES, MOONSHOT_INTL_BASE_URL, } from "./base-url-choices"; import { + CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS, cursorModelContextWindows, cursorModelIds, @@ -224,8 +225,22 @@ export interface ProviderRegistryEntry { supportsServiceTier?: boolean; /** Registry default for OpenAI extended hosted web_search field support. */ supportsOpenAiWebSearchToolFields?: boolean; + /** Registry default for native Responses custom-tool support. */ + supportsResponsesCustomTools?: boolean; /** Registry default for exact model service-tier capability; explicit config keys win. */ modelSupportsServiceTier?: Record; + /** + * Registry-only service-tier defaults for an OAuth preset's explicit API-key transport. + * Applied only when `allowKeyAuthOverride` is true and the captured effective auth transport + * is key-based. Explicit provider config still wins field-by-field, including `false`. + */ + keyAuthServiceTier?: { + supportsServiceTier?: boolean; + modelSupportsServiceTier?: Record; + chatServiceTier?: boolean; + }; + /** Provider-specific copy for the Codex catalog's Fast tier. */ + fastTierDescription?: string; /** * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence * without changing provider ownership, routing, authentication, or config validation. @@ -452,7 +467,23 @@ const THINKING_BUDGET_MODELS = [ ]; const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]; const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"]; +/* + * DeepSeek's experimental vision preview (released 2026-08-21, api-docs.deepseek.com): + * text+image input on the V4 Flash base. DeepSeek positions it as a preview id; + * the expectation is that vision merges into `deepseek-v4-flash` proper later, + * at which point this id retires the same way deepseek-chat/reasoner did. + */ +const DEEPSEEK_VISION_PREVIEW_MODEL = "deepseek-v4-flash-vision-exp"; const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"]; +/* + * OpenCode Zen's free slug for the OpenRouter stealth model "Ox Alpha" + * (openrouter.ai/stealth/ox-alpha): 1,048,576-token context, multimodal + * (text+image+video upstream; Zen serves text+image), mandatory reasoning, + * free during the stealth window. Zen displays it as "Ox Alpha Free" under + * this exact id (opencode.ai/docs/zen, verified 2026-08-21). + */ +const OPENCODE_OX_ALPHA_FREE_MODEL = "x-preview-f-free"; +const OX_ALPHA_CONTEXT_WINDOW = 1_048_576; /* * Zen free models that reject `image_url` upstream (#1043, and the reproducible * half of #1024). @@ -994,11 +1025,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // no-effort fallback to `kimi-k3-max` would never be reached. Mirrors the other K3 // routes (kimi, kimi-code, opencode-go). modelDefaultReasoningEfforts: { "kimi-k3": "max" }, - // Cursor's wire protocol never forwards image parts (request-builder emits an unsupported- - // content marker), so the vision sidecar covers ALL cursor models regardless of what the - // upstream model could natively do. Live-discovered models outside the static list fall back - // to the same marker until they appear here. - noVisionModels: cursorModelIds(CURSOR_STATIC_MODELS), + // Blind Cursor models (Auto routers, Composer, GLM-5.2, GLM-5.3) go through the vision sidecar; + // multimodal hosts (Claude/Gemini/GPT/Kimi/Grok) take native SelectedImage. The catalog + // still advertises image for noVision members so Codex can attach (sidecar option B). + noVisionModels: [...CURSOR_NO_VISION_MODELS], }, { id: "xai", @@ -1007,10 +1037,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ baseUrl: "https://api.x.ai/v1", authKind: "oauth", allowKeyAuthOverride: true, + // Priority Processing is documented for xAI's public API-key Chat Completions and + // Responses endpoints. OAuth is a separate Grok CLI subscription gateway and remains + // unclassified; do not turn this into a provider-wide supportsServiceTier declaration. + keyAuthServiceTier: { + supportsServiceTier: true, + chatServiceTier: true, + }, + fastTierDescription: "Priority processing, 2x token price", featured: true, oauthId: "xai", jawcodeBundle: "xai", supportsOpenAiWebSearchToolFields: false, + // Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting + // the otherwise-identical request after the custom tool is lowered to a function. + supportsResponsesCustomTools: false, note: "Log in with your Grok account", // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole @@ -1101,6 +1142,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Unknown/new live models deliberately do not advertise a reasoning picker. reasoningEfforts: [], modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, + // Ox Alpha (stealth preview, changelog v1.31.0): free 1M multimodal reasoning + // model on every plan. DeepSeek vision preview id is preemptive metadata — + // it is expected to merge into deepseek-v4-flash later. + modelContextWindows: { + "stealth/ox-alpha": OX_ALPHA_CONTEXT_WINDOW, + [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: 1_048_576, + }, + modelInputModalities: { + "stealth/ox-alpha": ["text", "image"], + [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: ["text", "image"], + }, defaultMaxOutputTokens: 64_000, // The proprietary generate wire has no verified per-request serialization flag. parallelToolCalls: false, @@ -1292,8 +1344,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - 장점, 단점 및 영향: Luna reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. */ modelWireDefaults: { "gpt-5.6-luna": "openai-responses" }, - modelContextWindows: { "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW }, - modelInputModalities: { "kimi-k3": ["text", "image"] }, + modelContextWindows: { + "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, + // Ox Alpha (stealth 1M multimodal) and the DeepSeek vision preview are + // metadata-only here: the Go roster is discovered live, so these apply + // the moment the gateway starts serving the ids. + [OPENCODE_OX_ALPHA_FREE_MODEL]: OX_ALPHA_CONTEXT_WINDOW, + [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, + }, + modelInputModalities: { + "kimi-k3": ["text", "image"], + [OPENCODE_OX_ALPHA_FREE_MODEL]: ["text", "image"], + // Experimental DeepSeek vision preview — expected to merge into deepseek-v4-flash later. + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + }, modelReasoningEfforts: { "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, @@ -1396,11 +1460,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", - models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], + // stealth/ox-alpha: free stealth-window frontier model (launched 2026-08-20). + // /api/v1/models reports 1,048,576 context, 131,072 max output, text+image+video + // input, $0 pricing, mandatory reasoning. Single provider slug: `stealth`. + models: ["anthropic/claude-sonnet-5", "stealth/ox-alpha", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, + "stealth/ox-alpha": OX_ALPHA_CONTEXT_WINDOW, ...OPENROUTER_GPT56_CONTEXT_WINDOWS, }, + modelInputModalities: { "stealth/ox-alpha": ["text", "image"] }, // OpenRouter documents priority support for OpenAI endpoints, but not Anthropic. Keep the // provider unclassified and opt in only the exact OpenAI-backed slugs we ship. These facts // belong only to the canonical destination; a same-named custom gateway is unknown to us. @@ -1551,11 +1620,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // keep validating and routing (they previously mapped to v4-flash; devlog // _fin/260710_provider_hardening/002_research_cn.md). The current offerings are // the V4 ids — defaultModel and the model-specific wiring above use them. - models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS], + // deepseek-v4-flash-vision-exp: experimental vision preview (2026-08-21) — + // expected to merge into deepseek-v4-flash later; see DEEPSEEK_VISION_PREVIEW_MODEL. + models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], defaultModel: "deepseek-v4-flash", // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576 // for both V4 models; the older 1,000,000 figure was a rounded approximation. - modelContextWindows: { "deepseek-v4-flash": 1_048_576, "deepseek-v4-pro": 1_048_576 }, + modelContextWindows: { "deepseek-v4-flash": 1_048_576, "deepseek-v4-pro": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, + modelInputModalities: { [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"] }, // DeepSeek documents both V4 models as native Responses API models adapted for Codex // (model table marks Responses API ✓ for flash and pro; the /responses reference lists // both ids as accepted `model` values — verified 2026-08-13 with the V4 Pro GA, @@ -1808,6 +1880,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // slash ids — so a Codex-facing slug like `commandcode/deepseek-deepseek-v4-pro` // is sent upstream verbatim and rejected with `unsupported_model`. modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, + // Ox Alpha (stealth preview, Command Code changelog v1.31.0) ships with a + // 1.05M-token multimodal context; the DeepSeek vision preview id is + // preemptive for when the catalog serves it (merges into v4-flash later). + modelContextWindows: { + "stealth/ox-alpha": OX_ALPHA_CONTEXT_WINDOW, + [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: 1_048_576, + }, + modelInputModalities: { + "stealth/ox-alpha": ["text", "image"], + [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: ["text", "image"], + }, modelDiscovery: { path: "models", maxResponseBytes: 256 * 1024, @@ -2452,6 +2535,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]), ), preserveReasoningContentModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], + // Same Zen gateway as opencode-free: Ox Alpha Free (1M multimodal stealth model) + // and the DeepSeek vision preview (merges into deepseek-v4-flash later). + modelContextWindows: { + [OPENCODE_OX_ALPHA_FREE_MODEL]: OX_ALPHA_CONTEXT_WINDOW, + [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, + }, + modelInputModalities: { + [OPENCODE_OX_ALPHA_FREE_MODEL]: ["text", "image"], + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + }, noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_THINKING_MODELS], }, { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" }, @@ -2482,6 +2575,18 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS, + // Ox Alpha Free (`x-preview-f-free`): the OpenRouter stealth model on Zen's + // free tier — 1,048,576 context, text+image input. Deliberately NOT in the + // text-only list below. The DeepSeek vision preview id is preemptive + // metadata for when Zen starts serving it (merges into v4-flash later). + modelContextWindows: { + [OPENCODE_OX_ALPHA_FREE_MODEL]: OX_ALPHA_CONTEXT_WINDOW, + [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, + }, + modelInputModalities: { + [OPENCODE_OX_ALPHA_FREE_MODEL]: ["text", "image"], + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + }, // Same Zen roster behind the same base URL, so it carries the same measured // text-only list rather than only its DeepSeek member (#1043). noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS, diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index 2a09530d23..de71cb3f9e 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -70,11 +70,22 @@ function buildFastPolicyAuthority( capabilityProvider: ServiceTierCapabilityProvider = provider, ): FastPolicyAuthority { const registry = registryTransportMatch ? getProviderRegistryEntry(providerName) : undefined; + const authTransport = resolveProviderAuthTransport( + provider.adapter, + provider.authMode ?? registry?.authKind ?? "key", + provider.apiKeyTransport, + ); + const keyAuthDefaults = registry?.allowKeyAuthOverride === true + && (authTransport === "authorization_bearer" || authTransport === "x_api_key") + ? registry.keyAuthServiceTier + : undefined; const registryModelCapabilities = registry && registryModelServiceTierCapabilityApplies(registry, capabilityProvider) ? registry.modelSupportsServiceTier : undefined; - const providerCapability = capabilityProvider.supportsServiceTier ?? registry?.supportsServiceTier; + const providerCapability = capabilityProvider.supportsServiceTier + ?? keyAuthDefaults?.supportsServiceTier + ?? registry?.supportsServiceTier; const authority: FastPolicyAuthority = Object.freeze({ providerAdapter: provider.adapter, providerAuthMode: provider.authMode ?? registry?.authKind ?? "key", @@ -82,19 +93,23 @@ function buildFastPolicyAuthority( provider.fastWire !== undefined ? provider.fastWire : registry?.fastWire, { freeze: true }, ), + ...(registry?.fastTierDescription !== undefined + ? { fastTierDescription: registry.fastTierDescription } + : {}), modelWireOverrideAllowed: !isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig), - authTransport: resolveProviderAuthTransport( - provider.adapter, - provider.authMode ?? registry?.authKind ?? "key", - provider.apiKeyTransport, - ), + authTransport, capability: Object.freeze({ ...(providerCapability !== undefined ? { provider: providerCapability } : {}), models: Object.freeze({ ...(registryModelCapabilities ?? {}), + ...(keyAuthDefaults?.modelSupportsServiceTier ?? {}), ...(capabilityProvider.modelSupportsServiceTier ?? {}), }), - ...(provider.chatServiceTier !== undefined ? { chatServiceTier: provider.chatServiceTier } : {}), + ...(provider.chatServiceTier !== undefined + ? { chatServiceTier: provider.chatServiceTier } + : keyAuthDefaults?.chatServiceTier !== undefined + ? { chatServiceTier: keyAuthDefaults.chatServiceTier } + : {}), }), modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }), hardPins: captureWireAdapterHardPins(providerName), diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index e7db3c32a6..d5d4e93b30 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -4,6 +4,13 @@ import { collectResponsesToolGroups } from "./tool-groups"; const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; +function routedCustomToolPassesThrough( + name: string, + supportsResponsesCustomTools: boolean | undefined, +): boolean { + return supportsResponsesCustomTools !== false && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name); +} + function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -34,7 +41,10 @@ export function routedCustomToolWireName(value: unknown): string | undefined { * Names of converted custom declarations after namespace lowering. Restoration uses these exact * wire identities so same-named function and custom children in different namespaces stay distinct. */ -function collectRoutedCustomToolWireNames(body: unknown): Set { +function collectRoutedCustomToolWireNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const groups = collectResponsesToolGroups(body); const bareWireNames = new Set(); @@ -54,7 +64,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { if ( tool.type === "custom" && typeof tool.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name) + && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) ) { names.add(tool.name); continue; @@ -67,7 +77,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { isPlainObject(child) && child.type === "custom" && typeof child.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name) + && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) ) names.add(customToolWireName(tool.name, child.name)); } @@ -81,7 +91,10 @@ export function customToolItemId(id: unknown): unknown { return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; } -export function collectRoutedCustomToolNames(body: unknown): Set { +export function collectRoutedCustomToolNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const visit = (value: unknown): void => { if (Array.isArray(value)) { @@ -92,7 +105,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set { if ( value.type === "custom" && typeof value.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name) + && !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools) ) { names.add(value.name); } @@ -184,12 +197,15 @@ function rewriteForUpstream( return changed ? next : value; } -export function rewriteRoutedCustomToolsForUpstream(body: unknown): { +export function rewriteRoutedCustomToolsForUpstream( + body: unknown, + supportsResponsesCustomTools?: boolean, +): { body: unknown; names: Set; } { - const conversionNames = collectRoutedCustomToolNames(body); - const names = collectRoutedCustomToolWireNames(body); + const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools); + const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools); if (conversionNames.size === 0) return { body, names }; const callIds = new Set(); collectConvertedCallIds(body, conversionNames, callIds); diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3f6cd42ea2..cbc90db605 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -268,9 +268,8 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { const groups = collectResponsesToolGroups(body); const plan = buildRewritePlan(groups); - // Deliberately not gated on the plan being non-empty: a turn whose catalog is gone still replays - // call items carrying a private `namespace`, and the routed compaction turn strips the whole tool - // surface before this runs. + // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still + // replay call items carrying a private `namespace`. const emitted = new Set(); const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools; diff --git a/src/router.ts b/src/router.ts index 35e34d75ca..47a604d77c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -366,6 +366,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.supportsOpenAiWebSearchToolFields !== undefined ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields } : {}), + ...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined + ? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools } + : {}), ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index a8e160a206..325c4ffd08 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -258,6 +258,10 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + // Terminal vision-describe marker (roadmap 180): the bridge rebuilds + // headers from the FORWARD_HEADERS allowlist, which would drop the raw + // header — so the fact is detected here and carried as an option flag. + ...(req.headers.get("x-opencodex-vision-describe") === "1" ? { visionDescribeTerminal: true } : {}), translatorBudget, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index b27f29c962..e00caea282 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -11,6 +11,7 @@ import type { AdmissionLease } from "../lib/admission"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; +import { isModelTextOnly } from "../vision"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, @@ -61,6 +62,12 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo if (rawBody.store === true || rawBody.background === true) return false; if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return false; if (rawBody.compaction_trigger !== undefined) return false; + // Vision sidecar coverage (roadmap 180): a text-only routed model with an + // image-bearing body must go through the Responses pipeline, whose plan + // site describes or strips the image. The native fast path has no vision + // handling, so letting it keep such a request forwards raw pixels to a + // model the operator declared blind. + if (isModelTextOnly(provider, route.modelId) && chatBodyCarriesImage(rawBody)) return false; if (Array.isArray(rawBody.tools)) { for (const tool of rawBody.tools) { if (!isRec(tool)) continue; @@ -72,6 +79,19 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo return true; } +/** Any messages[].content[] part of type image_url. */ +function chatBodyCarriesImage(rawBody: Rec): boolean { + const messages = rawBody.messages; + if (!Array.isArray(messages)) return false; + for (const message of messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (isRec(part) && part.type === "image_url") return true; + } + } + return false; +} + function chatCompletionJson(value: unknown): Rec | null { if (!isRec(value) || !Array.isArray(value.choices) || value.choices.length === 0) return null; return value; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 2fbee7d5a9..28e326fda9 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1073,13 +1073,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const section = body[field]; if (section === undefined || section === null) continue; if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400); - // The widened union applies to the WEB-SEARCH override only (roadmap 060). - // Vision keeps its two-backend contract — accepting a wider id there would - // persist a backend the vision resolver reads as unset, silently activating - // a backend the operator never chose (review F1). + // Both overrides now speak their full unions (roadmap 060 web, 170 + // vision revised). Vision's third arm is "routed" (loopback through the + // proxy's own router), never exa: exa is not an LLM, and accepting an + // unknown literal would persist a backend the vision resolver reads as + // unset (review F1's failure mode). const allowedBackends = field === "webSearchSidecar" ? ["openai", "anthropic", "xai", "gemini", "exa"] - : ["openai", "anthropic"]; + : ["openai", "anthropic", "routed"]; if (section.backend !== undefined && section.backend !== null && !allowedBackends.includes(section.backend as string)) { return jsonResponse({ error: `${field}.backend must be ${allowedBackends.join(", ")}, or null` }, 400); @@ -1094,8 +1095,18 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const requested = section.model; const candidates = await visionCandidateRows(config); const hint = section.backend === "anthropic" || section.backend === "openai" + || section.backend === "routed" ? section.backend : config.claudeCode?.visionSidecar?.backend; + // Same coherence rule as /api/sidecar-settings (roadmap 170 r2). + const effectiveBackend = hint ?? "openai"; + const namespaced = requested.includes("/"); + if (namespaced && effectiveBackend !== "routed") { + return jsonResponse({ error: `visionSidecar.model "${requested}" is provider-namespaced; it requires backend "routed"` }, 400); + } + if (!namespaced && effectiveBackend === "routed") { + return jsonResponse({ error: `visionSidecar.backend "routed" requires a provider-namespaced model ("provider/model"); got "${requested}"` }, 400); + } if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) { return jsonResponse(visionDescriberRejection("visionSidecar.model", requested, config, candidates), 400); } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 1e5e1ad2c6..0e7a0c8db6 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -113,8 +113,14 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ // Match the runtime's one selected Anthropic executor for both backend fallback // and catalog reachability; resolving it once prevents the two projections drifting. const anthropicSidecar = findAnthropicVisionProvider(config); - const backend = resolveVisionBackend(vs.backend, anthropicSidecar); - const model = resolveEffectiveVisionModel(config, backend); + // The routed backend reports its own namespaced model verbatim: it is the + // dispatched value, and collapsing it through the legacy resolver would + // display a describer the runtime is not using (roadmap 190). + const routedActive = vs.backend === "routed" && !!vs.model && vs.model.includes("/"); + const backend = routedActive ? "routed" as const : resolveVisionBackend(vs.backend, anthropicSidecar); + const model = routedActive && vs.model + ? vs.model + : resolveEffectiveVisionModel(config, backend === "routed" ? resolveVisionBackend(undefined, anthropicSidecar) : backend); const reasoning = normalizeVisionReasoningForModel(model, vs.reasoning) ?? "low"; const models = await visionModelOptionsFor(config, anthropicSidecar); // Display-only grandfather: a persisted id stays selectable, but the write gate @@ -592,8 +598,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0) backends.push("openai"); - if (anthropicSidecar) backends.push("anthropic"); - // Neither side resolvable (fresh install, no login): fall back to both so the - // picker is populated rather than empty, matching the permissive-unknown rule. - return backends.length > 0 ? backends : ["openai", "anthropic"]; + const auth = resolveSidecarAuth(config); + // Preserve the caller's resolution for the anthropic side: the descriptor + // reads the shared auth module, but a caller that already resolved "no + // executor" must not see anthropic options it cannot dispatch. The filter + // applies to the ACTIVE set only — the fresh-install fallback below stays + // both universal sides, exactly the pre-widening behavior (test 6 pins it). + const active = VISION_BACKENDS + .filter(descriptor => descriptor.isActive(auth, config)) + .map(descriptor => descriptor.backend) + .filter(backend => backend !== "anthropic" || anthropicSidecar !== undefined); + // "routed" is active by construction, so the fresh-install fallback keys on + // the UNIVERSAL sides: when neither resolves, both are offered so the picker + // stays populated (permissive-unknown rule; test 6 pins it). + if (!active.includes("openai") && !active.includes("anthropic")) { + return ["openai", "anthropic", ...active]; + } + return active; } /** @@ -93,10 +108,16 @@ export async function visionModelOptionsFor( * When no catalog row matches, the caller's `backend` is only a HINT, never the * authority. Trusting it let a client launder a known-blind OpenAI model past the * gate by claiming `backend: "anthropic"`, since the id is absent from the - * Anthropic table and absence reads as "unknown". Both families are therefore - * consulted and any positive text-only verdict wins. That is safe precisely - * because the two vendor tables share no bare model id, so they can never - * disagree about one. + * Anthropic table and absence reads as "unknown". + * + * A NAMESPACED id ("provider/model", the routed-backend option shape) names + * its provider outright, so that provider's config row and metadata family + * are probed directly. A BARE id probes ALL configured provider families and + * any positive text-only verdict wins (roadmap 170: a bare `grok-4` is + * provably text-only in the xai vendor table and must not slip through a + * two-family probe). That is safe precisely because the vendor tables share + * no bare model id (collision scan in roadmap 160: openai 48, anthropic 26, + * xai 32, google 43, zero overlaps), so they can never disagree about one. */ export function visionDescriberIsProvablyBlind( config: OcxConfig, @@ -109,11 +130,25 @@ export function visionDescriberIsProvablyBlind( if (candidates.some(candidate => candidate.id === requested && modelAcceptsImageInput(config, candidate) === false)) return true; - const hinted: VisionSidecarBackend = backendHint === "anthropic" ? "anthropic" : "openai"; - const probed: VisionSidecarBackend[] = hinted === "anthropic" - ? ["anthropic", "openai"] - : ["openai", "anthropic"]; - return probed.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false); + // Namespaced routed id: the provider is named, probe it directly (config + // row enrichment + its metadata family both flow through the predicate). + const sep = requested.indexOf("/"); + if (sep > 0) { + const provider = requested.slice(0, sep); + const id = requested.slice(sep + 1); + if (modelAcceptsImageInput(config, { provider, id }) === false) return true; + // A namespaced candidate row (value shape) may also carry the proof. + return candidates.some(candidate => candidate.provider === provider && candidate.id === id + && modelAcceptsImageInput(config, candidate) === false); + } + + // Bare id: probe the base vendor families plus every configured provider — + // a positive text-only verdict from any source wins. + const families = new Set(["openai", "anthropic", "xai", "google-antigravity", ...Object.keys(config.providers ?? {})]); + const ordered = backendHint === "anthropic" + ? ["anthropic", ...[...families].filter(family => family !== "anthropic")] + : ["openai", ...[...families].filter(family => family !== "openai")]; + return ordered.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false); } /** The 400 body both routes return, so the two errors cannot diverge either. */ diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index adc9415ec6..4273dae40b 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -461,7 +461,6 @@ export async function handleResponsesCompact( } compactHostAdmissionLease = null; }; - const compactThreadId = req.headers.get("x-codex-parent-thread-id"); const connectMs = config.connectTimeoutMs ?? 200_000; // Takes its context explicitly: the alternate-account flow below records a rejection // against A while promoting B, then records B's own outcome. A closure over a single @@ -478,7 +477,7 @@ export async function handleResponsesCompact( if (!usesCodexForwardPoolAuth(ctx, route.provider)) return; recordCodexUpstreamOutcome(config, ctx.accountId, outcome, { ...meta, - threadId: compactThreadId, + threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined, fixedAccount: ctx.fixedAccount, modelId: selectedModelId, probeLeaseId: codexProbeLeaseId(ctx), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 22bf3c18c3..b1bb6fadeb 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -111,6 +111,7 @@ import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenA import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, + codexPoolAffinityKey, CodexAccountCooldownError, codexMainProfileDrainingResponse, cooldownErrorResponse, @@ -139,6 +140,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-m import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug"; import { computeQuotaCooldown, + codexQuotaScopeForModel, formatCodexProviderForLog, previewCodexAccountForRequest, recordCodexUpstreamOutcome, @@ -328,11 +330,10 @@ export function adapterNeedsForcedContinuation(name: string): boolean { export function sidecarOutcomeRecorder( config: OcxConfig, authCtx: CodexAuthContext, - threadId?: string | null, ): ((outcome: CodexUpstreamOutcome) => void) | undefined { return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId, + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, probeLeaseId: authCtx.probeLeaseId, probeQuotaScope: authCtx.probeQuotaScope, @@ -946,7 +947,7 @@ async function retryCodexPoolOnAlternateAccount( const recordFirstOutcome = (): void => { recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, { ...quotaMeta, - threadId: req.headers.get("x-codex-parent-thread-id"), + threadId: firstAuthCtx.affinityKey, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(firstAuthCtx), probeQuotaScope: codexProbeQuotaScope(firstAuthCtx), @@ -1081,7 +1082,6 @@ export function codexForwardTerminalOutcomeRecorder( provider: OcxProviderConfig, modelId?: string, logCtx?: RequestLogContext, - threadId?: string | null, ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined { if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined; return (status, httpStatusOverride) => { @@ -1090,7 +1090,7 @@ export function codexForwardTerminalOutcomeRecorder( // request. Don't penalize account health; record success to clear any // prior soft-avoid so a healthy account isn't stuck avoided. recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { - threadId, + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId, probeLeaseId: codexProbeLeaseId(authCtx), @@ -1112,7 +1112,7 @@ export function codexForwardTerminalOutcomeRecorder( ? 200 : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId, + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId, probeLeaseId: codexProbeLeaseId(authCtx), @@ -1227,6 +1227,15 @@ export interface HandleResponsesOptions { onConsumedComboFailure?: (failure: ConsumedComboFailure) => void; /** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */ translatorBudget?: TranslatorBudget; + /** + * Terminal vision-describe marker (roadmap 180): true when the inbound + * request IS the vision sidecar's own loopback describe call. The plan site + * then STRIPS images instead of planning another describe — a depth cap of 1 + * that holds under predicate drift and combo re-resolution. The Chat surface + * detects the raw `x-opencodex-vision-describe` header before its bridge + * rebuilds headers and carries the fact through this flag. + */ + visionDescribeTerminal?: boolean; } @@ -2177,6 +2186,27 @@ async function handleResponsesInner( if (inboundClientThreadId) { parsed._clientThreadId = inboundClientThreadId; parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId }; + } else if ( + options.inboundWire === "anthropic" + && options.promptCacheKeyIsSharedCohort !== true + && typeof parsed.options.promptCacheKey === "string" + && parsed.options.promptCacheKey.trim().length > 0 + ) { + // Claude Code has no Codex parent-thread header, but its metadata.user_id is + // translated into a stable per-session prompt_cache_key. Use it as the replay + // thread identity so Gemini thought signatures are remembered by call_id for + // Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so + // existing provider session-id derivation (first-user-text fallback) is unchanged. + // Normalize through anthropicSessionKeyFromParts so overlong keys are hashed and + // trimming matches the affinity/session-key path exactly (no raw >128-char ids). + const normalizedCacheKey = anthropicSessionKeyFromParts({ + promptCacheKey: parsed.options.promptCacheKey, + // The enclosing branch already proves this is not the shared cohort. + promptCacheKeyIsSharedCohort: false, + }); + if (normalizedCacheKey) { + parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey }; + } } } catch (err) { if (isTranslatorBudgetExceededError(err)) { @@ -2278,6 +2308,7 @@ async function handleResponsesInner( let subagentFallbackPreviewAccountId: string | null | undefined; let subagentQuotaFailureModel = parsed.modelId; const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null; + const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null; try { if ( @@ -2293,12 +2324,15 @@ async function handleResponsesInner( // Preview the preferred Codex account without acquiring a probe lease or refreshing // tokens — auth is resolved only after the final route is selected. if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) { - const threadId = req.headers.get("x-codex-parent-thread-id"); + // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId), + // so the preview must read the same scope slot — an undefined scope would map to the + // "legacy" affinity bucket and never find a binding made under "shared" or a native + // model scope, making the preview diverge from the account that actually authenticates. const previewAccountId = previewCodexAccountForRequest( - threadId, + poolAffinityKey, config, Date.now(), - undefined, + codexQuotaScopeForModel(route.modelId), previewSelectionOptions, ); subagentFallbackPreviewAccountId = previewAccountId; @@ -2725,7 +2759,15 @@ async function handleResponsesInner( // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each // attached image through the selected sidecar backend and replace it with text BEFORE the main // call, so the text-only model can reason about it. - const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar); + // Terminal describe fence (roadmap 180): the sidecar's OWN loopback describe + // call must never plan another describe. The flag arrives from the Chat + // surface (whose bridge rebuilds headers) or as the raw header for native + // Responses callers. Marked + text-only routed model → strip, depth cap 1. + const visionDescribeTerminal = options.visionDescribeTerminal === true + || req.headers.get("x-opencodex-vision-describe") === "1"; + const visionPlan = visionDescribeTerminal + ? undefined + : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar); const recordSidecarOutcome = openAiSidecar?.recordOutcome; if (visionPlan) { await describeImagesInPlace( @@ -3005,7 +3047,7 @@ async function handleResponsesInner( } if (usesCodexForwardPoolAuth(authCtx, route.provider)) { recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId: req.headers.get("x-codex-parent-thread-id"), + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), @@ -3388,7 +3430,6 @@ async function handleResponsesInner( route.provider, route.modelId, logCtx, - req.headers.get("x-codex-parent-thread-id"), ); const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream; // Capture quota from upstream response for multi-account tracking @@ -3429,7 +3470,7 @@ async function handleResponsesInner( )) { recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, { ...quotaMeta, - threadId: req.headers.get("x-codex-parent-thread-id"), + threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), diff --git a/src/service.ts b/src/service.ts index a00e616ba1..cf61a657fb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -3274,6 +3274,7 @@ export async function serviceStatusReport( } export function normalizeServiceSubcommand(sub?: string): string { + if (sub === "restart") return "repair"; return sub ?? "install"; } @@ -3283,6 +3284,119 @@ export interface ParsedServiceArgs { invalid: string[]; } +export type ServiceInstallationState = "installed" | "absent" | "unknown"; + +export interface ServiceInstallationProbe { + state: ServiceInstallationState; + detail?: string; +} + +export interface ServiceInstallationProbeHooks { + platform?: NodeJS.Platform; + exists?: (path: string) => boolean; + probeWindowsTask?: () => WindowsSchedulerTaskProbe; + nativeStatus?: () => WinswStatus; +} + +/** + * Read only enough registration state to choose between install and repair. + * Windows must keep query failure distinct from proven absence: treating an + * unreadable scheduler/SCM as absent would send a bare command into the + * elevated registration path and recreate the original #2287 failure. + */ +export function probeServiceInstallation( + hooks: ServiceInstallationProbeHooks = {}, +): ServiceInstallationProbe { + const platform = hooks.platform ?? process.platform; + const exists = hooks.exists ?? existsSync; + if (platform === "darwin") { + return { state: exists(plistPath()) ? "installed" : "absent" }; + } + if (platform === "linux") { + return { state: exists(unitPath()) ? "installed" : "absent" }; + } + if (platform !== "win32") return { state: "absent" }; + + let scheduler: WindowsSchedulerTaskProbe; + try { + scheduler = (hooks.probeWindowsTask ?? probeWindowsSchedulerTask)(); + } catch (cause) { + scheduler = { status: "unknown", detail: schtasksErrorDetail(cause) }; + } + let native: WinswStatus; + try { + native = (hooks.nativeStatus ?? statusWinswRaw)(); + } catch { + native = "unknown"; + } + + if (scheduler.status === "present" || native === "started" || native === "stopped") { + return { state: "installed" }; + } + if (scheduler.status === "unknown" || native === "unknown") { + const parts = [ + scheduler.status === "unknown" ? `Task Scheduler: ${scheduler.detail}` : null, + native === "unknown" ? "WinSW status could not be determined" : null, + ].filter((part): part is string => Boolean(part)); + return { state: "unknown", detail: parts.join("; ") }; + } + return { state: "absent" }; +} + +/** + * A bare invocation is an idempotent "make the installed service current" + * operation. First-time setup still installs, but an existing registration must + * use the repair path so Windows does not re-run the elevated `schtasks /create`. + * Backend flags remain an explicit install request because they select which + * registration mechanism to create. + */ +export function selectServiceSubcommand( + parsed: ParsedServiceArgs, + options: { hasExplicitSubcommand: boolean; installed: boolean }, +): string { + if (!options.hasExplicitSubcommand && parsed.backend === null && options.installed) return "repair"; + return parsed.sub; +} + +export type ServiceCommandPlan = + | { ok: true; parsed: ParsedServiceArgs; command: string } + | { ok: false; message: string }; + +export function planServiceCommand( + args: string[], + options: { platform?: NodeJS.Platform; probeInstallation?: () => ServiceInstallationProbe } = {}, +): ServiceCommandPlan { + const parsed = parseServiceArgs(args); + if (parsed.invalid.length > 0) { + return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` }; + } + if (parsed.backend && parsed.sub !== "install") { + return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." }; + } + if (parsed.backend === "native" && (options.platform ?? process.platform) !== "win32") { + return { ok: false, message: "--native (WinSW) is Windows-only." }; + } + + const hasExplicitSubcommand = args.some(arg => !arg.startsWith("--")); + let installed = false; + if (!hasExplicitSubcommand && parsed.backend === null) { + const probe = (options.probeInstallation ?? probeServiceInstallation)(); + if (probe.state === "unknown") { + const suffix = probe.detail ? ` (${probe.detail})` : ""; + return { + ok: false, + message: `Could not safely determine whether the service is installed${suffix}. Run 'ocx service status' and retry; use explicit 'ocx service install' only after confirming it is absent.`, + }; + } + installed = probe.state === "installed"; + } + return { + ok: true, + parsed, + command: selectServiceSubcommand(parsed, { hasExplicitSubcommand, installed }), + }; +} + /** * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the * subcommand; backend flags are only meaningful for `install` (validated by the caller). @@ -3308,20 +3422,13 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs { } export async function serviceCommand(...args: (string | undefined)[]): Promise { - const parsed = parseServiceArgs(args.filter((a): a is string => Boolean(a))); - const command = parsed.sub; - if (parsed.invalid.length > 0) { - console.error(`Unknown service option: ${parsed.invalid.join(" ")}`); - process.exit(1); - } - if (parsed.backend && command !== "install") { - console.error("--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend."); - process.exit(1); - } - if (parsed.backend === "native" && process.platform !== "win32") { - console.error("--native (WinSW) is Windows-only."); + const filteredArgs = args.filter((a): a is string => Boolean(a)); + const plan = planServiceCommand(filteredArgs); + if (!plan.ok) { + console.error(plan.message); process.exit(1); } + const { parsed, command } = plan; if (command === "repair") { assertServiceEnvironmentMatchesInstall(); assertServiceAuthEnvironment(); @@ -3458,9 +3565,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise requestedServiceTier @@ -405,9 +413,9 @@ function isConfirmedFast(tier?: ServiceTierInput): boolean { * normalized billable input — normalization subtracts cache read/write, so a * cache-heavy long prompt would fall below the boundary and under-bill. * - * Skipped entirely for a response-confirmed Fast request: OpenAI does not serve - * long context in Fast mode, so the two are mutually exclusive regimes rather - * than composable multipliers. + * A provider's declaration decides how a response-confirmed priority tier relates to this band. + * OpenAI declares the bands exclusive. xAI publishes neither a combined rate nor an exclusion, + * so its long-context rate remains the known lower bound instead of inventing a stacked multiplier. */ function applyContextTier( cost4: Cost4, @@ -415,25 +423,27 @@ function applyContextTier( modelId: string, rawInputTokens: number | undefined, tier?: ServiceTierInput, -): [Cost4, ContextTierName | undefined] { - if (rawInputTokens === undefined) return [cost4, undefined]; - if (isConfirmedFast(tier)) return [cost4, undefined]; +): [Cost4, ContextTierName | undefined, boolean] { + if (rawInputTokens === undefined) return [cost4, undefined, false]; const rule = findContextTier(baseProviderLabel(provider), modelId); - if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined]; + if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined, false]; + const confirmedFast = isConfirmedFast(tier); + if (confirmedFast && rule.confirmedPriorityRelation === "exclusive") { + return [cost4, undefined, false]; + } return [{ input: cost4.input * rule.multiplier.input, output: cost4.output * rule.multiplier.output, cacheRead: cost4.cacheRead * rule.multiplier.cacheRead, cacheWrite: cost4.cacheWrite * rule.multiplier.cacheWrite, - }, "long"]; + }, "long", confirmedFast && rule.confirmedPriorityRelation === "lower-bound"]; } /** - * Apply the OpenAI priority-tier multiplier to a Cost4 when applicable. + * Apply a declared provider/model priority-tier multiplier to a Cost4 when applicable. * Returns [effectiveCost4, multiplier]. Multiplier is 1 (no-op) when: * - serviceTier is not "priority" - * - provider is not a canonical OpenAI forward provider - * - model is not in PRIORITY_MULTIPLIERS + * - no exact provider/model rule exists */ function applyPriorityMultiplier( cost4: Cost4, @@ -443,8 +453,9 @@ function applyPriorityMultiplier( ): [Cost4, number] { if (tierScalar(serviceTier) !== "priority") return [cost4, 1]; const base = baseProviderLabel(provider); - if (!OPENAI_TIER_PROVIDER_IDS.has(base)) return [cost4, 1]; - const multiplier = resolvePriorityMultiplier(modelId); + const rule = findPriorityPricingRule(base, modelId); + if (rule?.requiresResponseConfirmation && !isConfirmedFast(serviceTier)) return [cost4, 1]; + const multiplier = rule?.multiplier ?? 1; if (multiplier === 1) return [cost4, 1]; return [{ input: cost4.input * multiplier, @@ -495,16 +506,17 @@ export function estimateAttemptCost( const attemptServiceTier = attempt.tierOutcome ? serviceTierContextFromOutcome(attempt.tierOutcome) : serviceTier; - const [tieredCost4, contextTier] = applyContextTier( + const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier( price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier, ); - // Exclusive both ways: if the long rate applied, the request was NOT served as - // Fast (Fast does not support long context), so the Fast multiplier must not - // also apply — otherwise a downgraded request bills at both rates. + // A published long-context row owns the numeric estimate. OpenAI declares that band + // exclusive with Fast; xAI's confirmed combination is deliberately left unmultiplied + // and marked as a lower bound because no combined price has been published. const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model, attemptServiceTier); - const priorityLowerBound = isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome); + const priorityLowerBound = contextPriorityLowerBound + || isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome); return { ordinal: attempt.ordinal, provider: attempt.provider, @@ -514,8 +526,8 @@ export function estimateAttemptCost( cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(attempt.usage, attempt.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), - ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), }; } @@ -557,8 +569,10 @@ export function estimateComboCost( ...(estimates.some(est => est.priorityMultiplier && est.priorityMultiplier !== 1) ? { priorityMultiplier: estimates.find(est => est.priorityMultiplier)?.priorityMultiplier } : {}), - ...(estimates.some(est => est.priorityLowerBound) ? { priorityLowerBound: true } : {}), ...(estimates.some(est => est.contextTier) ? { contextTier: "long" as const } : {}), + ...(estimates.every(est => est.priorityLowerBound === true) + ? { priorityLowerBound: true as const } + : {}), }; } @@ -579,13 +593,13 @@ export function estimateRequestCost( if (!tokens) return null; const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays); if (!price) return null; - const [tieredCost4, contextTier] = applyContextTier( + const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier( price.cost4, input.provider, input.model, input.usage.inputTokens, input.serviceTier, ); const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, input.provider, input.model, input.serviceTier); - const priorityLowerBound = isOpenRouterPriorityLowerBound( + const priorityLowerBound = contextPriorityLowerBound || isOpenRouterPriorityLowerBound( input.provider, typeof input.serviceTier === "object" ? input.serviceTier.tierOutcome : undefined, ); @@ -595,8 +609,8 @@ export function estimateRequestCost( cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(input.usage, input.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), - ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), }; } diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 64a42e87e7..af94b8b262 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -181,6 +181,30 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ { provider: "cursor", modelId: "auto", cost4: { input: 1.25, output: 6, cacheRead: 0.25, cacheWrite: 1.25 }, source: "https://docs.cursor.com/account/pricing + https://cursor.com/blog/aug-2025-pricing", verifiedAt: "2026-07-20", status: "verified" }, ]; +/** + * Exact official corrections for stale nonzero catalog rows. These are intentionally separate + * from fallback overlays: they win over the bundled row only for the declared provider/model and + * therefore cannot reprice routed resellers that reuse the same model slug. + */ +export const VERIFIED_PRICE_OVERRIDES: readonly ExpectedPriceOverlay[] = [ + { + provider: "xai", + modelId: "grok-4.6", + cost4: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, + source: "https://docs.x.ai/developers/pricing", + verifiedAt: "2026-08-18", + status: "verified", + }, +]; + +export function findVerifiedPriceOverride( + provider: string, + modelId: string, + overrides: readonly ExpectedPriceOverlay[] = VERIFIED_PRICE_OVERRIDES, +): ExpectedPriceOverlay | undefined { + return overrides.find(row => row.provider === provider && row.modelId === modelId); +} + /** * Exact-key overlay lookup. Returns verified first, then verified-derived. * NEVER returns "unverified" rows — fail-closed is enforced in code, not just docs. @@ -196,12 +220,7 @@ export function findExpectedPriceOverlay( ?? exact.find(row => row.status === "verified-derived"); } -/** - * OpenAI Fast mode (`service_tier=priority`) price multipliers by model slug. - * Source: https://openai.com/api-fast-mode/ (2026-07-31). - * Fast pricing applies uniformly to all token types (input, output, cache). - * Models not listed here fall back to 1× (no multiplier). - */ +/** OpenAI Fast price multipliers retained as a compatibility export. */ export const PRIORITY_MULTIPLIERS: Readonly> = { "gpt-5.6-sol": 2, // Post-price-cut Fast tables (https://openai.com/api-fast-mode/, 2026-08-05): @@ -219,6 +238,52 @@ export function resolvePriorityMultiplier(modelId: string): number { return PRIORITY_MULTIPLIERS[modelId] ?? 1; } +export interface PriorityPricingRule { + provider: string; + modelId: string; + multiplier: number; + /** Apply the premium only after the upstream response confirms this tier. */ + requiresResponseConfirmation?: true; + source: string; + verifiedAt: string; +} + +const OPENAI_FAST_PRICING = "https://openai.com/api-fast-mode/"; +const XAI_PRIORITY_PRICING = "https://docs.x.ai/developers/advanced-api-usage/priority-processing"; + +/** + * Exact provider/model priority premiums. Routed resellers never inherit a vendor rule merely + * because they reuse its model slug. Multipliers apply uniformly after cache discounts. + */ +export const PRIORITY_PRICING_RULES: readonly PriorityPricingRule[] = [ + ...["openai", "openai-apikey"].flatMap(provider => + Object.entries(PRIORITY_MULTIPLIERS).map(([modelId, multiplier]): PriorityPricingRule => ({ + provider, + modelId, + multiplier, + source: OPENAI_FAST_PRICING, + verifiedAt: "2026-08-05", + })), + ), + ...["grok-4.5", "grok-4.6"].map((modelId): PriorityPricingRule => ({ + provider: "xai", + modelId, + multiplier: 2, + requiresResponseConfirmation: true, + source: XAI_PRIORITY_PRICING, + verifiedAt: "2026-08-18", + })), +]; + +/** Exact provider/model priority-pricing lookup. */ +export function findPriorityPricingRule( + provider: string, + modelId: string, + rules: readonly PriorityPricingRule[] = PRIORITY_PRICING_RULES, +): PriorityPricingRule | undefined { + return rules.find(rule => rule.provider === provider && rule.modelId === modelId); +} + /** * Long-context pricing tiers (#908). Several vendors reprice the ENTIRE request * once the prompt crosses a published input-token threshold, so a flat Cost4 @@ -244,6 +309,8 @@ export interface ContextTier { inclusive: boolean; /** Per-field factor from the short rate to the published long rate. */ multiplier: Cost4; + /** Published relationship between confirmed priority and long-context bands. */ + confirmedPriorityRelation?: "exclusive" | "lower-bound"; source: string; verifiedAt: string; } @@ -277,6 +344,7 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [ thresholdInputTokens: 272_000, inclusive: false, multiplier: OPENAI_LONG_CONTEXT, + confirmedPriorityRelation: "exclusive", source: OPENAI_PRICING_DOC, verifiedAt: "2026-08-03", })), @@ -287,19 +355,21 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [ thresholdInputTokens: 200_000, inclusive: true, multiplier: UNIFORM_DOUBLE, + confirmedPriorityRelation: "lower-bound", source: "https://docs.x.ai/developers/pricing", verifiedAt: "2026-08-03", }, { - // 260813: grok-4.6 long-context tier mirrored from grok-4.5; the official pricing row - // was not yet published when the model page went up, so treat as provisional. + // xAI publishes the whole-request >=200k band for grok-4.6. Its combination with + // Priority Processing is not published, so confirmed priority uses this row as a lower bound. provider: "xai", modelId: "grok-4.6", thresholdInputTokens: 200_000, inclusive: true, multiplier: UNIFORM_DOUBLE, + confirmedPriorityRelation: "lower-bound", source: "https://docs.x.ai/developers/pricing", - verifiedAt: "2026-08-13", + verifiedAt: "2026-08-18", }, { // daybreak-blue-latest aliases gpt-5.6-sol, which publishes the full long-context row diff --git a/src/vision/backends.ts b/src/vision/backends.ts new file mode 100644 index 0000000000..b9f42b8408 --- /dev/null +++ b/src/vision/backends.ts @@ -0,0 +1,97 @@ +/** + * Which backends may DESCRIBE images for the vision sidecar, and which + * candidate rows each can describe through (#2188 vision rules; roadmap 170 + * REVISED: the "routed" backend). + * + * A SIBLING of WEB_SEARCH_BACKENDS, not a shared table: vision has no + * per-model probe (rule 2 is "− provably text-only", enforced by + * modelAcceptsImageInput, not here), carries per-side baseline models, and + * excludes non-LLM backends like exa. + * + * Three backends, not one per provider: "openai" and "anthropic" carry auth + * semantics loopback routing cannot replicate (forwarded ChatGPT headers, + * OAuth beta fences) and their defaults must not drift. Every OTHER + * picker-visible provider row reaches the describer through "routed" — a + * loopback self-fetch of the proxy's own /v1/chat/completions, where the + * router and adapters already speak each provider's wire. That is what makes + * this table closed under provider growth: a new provider needs no new + * describe executor. + */ +import type { OcxConfig } from "../types"; +import type { SidecarAuthState } from "../sidecar/auth"; +import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar"; +import type { VisionCandidateModel, VisionSidecarBackend } from "./eligibility"; + +export interface VisionBackendDescriptor { + backend: VisionSidecarBackend; + /** Liveness signal for this backend. */ + isActive(auth: SidecarAuthState, config: OcxConfig): boolean; + /** Which candidate rows this backend's describe executor can actually run. */ + candidateMatch(candidate: VisionCandidateModel, auth: SidecarAuthState): boolean; + /** + * Default entry for this side: cheap, image-capable, present in every + * deployment. Only the two universal sides carry one — "routed" has no + * universal model to name. + */ + baseline?: string; + /** Stable option ordering (baselines first within a side). */ + rank: number; +} + +export const VISION_BACKENDS: readonly VisionBackendDescriptor[] = [ + { + backend: "openai", + // The OpenAI describer needs a CANONICAL ChatGPT forward provider, not + // merely a provider keyed "openai" — same predicate the runtime sidecar + // resolver uses. Deliberately NOT auth.isCodexAuth: tightening to a live + // credential here would change which options a fresh install sees, and + // the options list is a suggestion surface, not the write gate. + isActive: (_auth, config) => listOpenAiForwardSidecarCandidates(config).length > 0, + candidateMatch: candidate => candidate.native === true || candidate.provider === "openai", + baseline: "gpt-5.6-luna", + rank: 0, + }, + { + backend: "anthropic", + isActive: auth => auth.isAnthropicAuth, + // The runtime dispatches through exactly ONE Anthropic provider — the + // resolved OAuth row. Same-adapter keyed rows are unreachable (see + // visionBackendForCandidate's original stance). + candidateMatch: (candidate, auth) => candidate.provider === auth.anthropicProviderName, + baseline: "claude-haiku-4-5", + rank: 1, + }, + { + backend: "routed", + // Always offered: options only materialize when a matching picker row + // exists, and the row's own provider config is the liveness signal — the + // loopback request fails closed through ordinary routing errors. + isActive: () => true, + // Any row the other two executors do NOT own. Auth-slot rows are + // entitlements of the openai/anthropic sides and never route here. + candidateMatch: (candidate, auth) => + candidate.native !== true + && candidate.provider !== "openai" + && candidate.provider !== auth.anthropicProviderName, + rank: 2, + }, +]; + +export function visionBackendDescriptor(backend: VisionSidecarBackend): VisionBackendDescriptor { + const descriptor = VISION_BACKENDS.find(entry => entry.backend === backend); + if (!descriptor) throw new Error(`unknown vision backend "${backend}"`); + return descriptor; +} + +/** + * The active backend set for option generation. Falls back to the two + * UNIVERSAL sides when neither is active (fresh install: picker stays + * populated, permissive-unknown rule); "routed" is active by construction. + */ +export function activeVisionBackends(auth: SidecarAuthState, config: OcxConfig): VisionSidecarBackend[] { + const active = VISION_BACKENDS.filter(entry => entry.isActive(auth, config)).map(entry => entry.backend); + return active.includes("openai") || active.includes("anthropic") + ? active + : ["openai", "anthropic", ...active.filter(backend => backend === "routed")]; +} + diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 7737f67844..06a4ecce02 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -26,15 +26,27 @@ import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import { enrichProviderFromRegistry } from "../providers/derive"; -/** The two wire protocols `planVisionSidecar` can actually dispatch to. */ -export type VisionSidecarBackend = "openai" | "anthropic"; +/** + * The wire protocols `planVisionSidecar` can dispatch to (#2188 roadmap 170 + * REVISED). "routed" describes through the proxy's OWN router via loopback — + * one executor for every non-forward, non-OAuth-Anthropic provider row. + */ +export type VisionSidecarBackend = "openai" | "anthropic" | "routed"; + +/** The two sides every deployment has; also the empty-auth fallback set. */ +export type UniversalVisionBackend = "openai" | "anthropic"; /** * Default entry per backend: cheap, image-capable, and present in every deployment. Offered * whenever its side is enabled, and withheld only when that provider explicitly lists it as a * model the sidecar describes FOR — never merely because a metadata table stayed silent. + * + * Keyed by the UNIVERSAL subset on purpose (roadmap 170, audit blocker A): + * xai/gemini are auth-gated sides whose catalogs are present whenever the side + * is, so they carry no baseline, and a narrow-key total record documents that + * without sprinkling non-null assertions at the consumers. */ -export const BASELINE_VISION_MODELS: Record = { +export const BASELINE_VISION_MODELS: Record = { openai: "gpt-5.6-luna", anthropic: "claude-haiku-4-5", }; @@ -146,7 +158,7 @@ function isVisionEligibleModelWithCache( return modelAcceptsImageInputWithCache(config, candidate, cache) !== false; } -/** Which executor can describe through this row, or undefined when neither can. */ +/** Which executor can describe through this row. */ export function visionBackendForCandidate( config: Pick, candidate: VisionCandidateModel, @@ -158,17 +170,17 @@ export function visionBackendForCandidate( // Messages wire is not enough: a key-auth row of the same adapter is unreachable, and an // option that cannot be dispatched is worse than a missing one, because selecting it fails // at describe time rather than at pick time. - // - // So the executor's name is REQUIRED for an Anthropic suggestion. When the caller has no - // executor to name, no catalog row qualifies; the side's baseline is added separately and - // keeps the picker populated. Narrowing here never widens the write gate, which is a - // different predicate (`modelAcceptsImageInput`) and still treats unknown as allowed. - if (anthropicProviderName === undefined) return undefined; - return candidate.provider === anthropicProviderName ? "anthropic" : undefined; + if (anthropicProviderName !== undefined && candidate.provider === anthropicProviderName) { + return "anthropic"; + } + // EVERY other provider row describes through the proxy's own router + // (roadmap 170 revised): the loopback executor covers all provider wires, + // so no row is left without an executor. + return "routed"; } function baselineCandidate( - backend: VisionSidecarBackend, + backend: UniversalVisionBackend, anthropicProviderName: string | undefined, ): VisionCandidateModel { return { @@ -181,12 +193,13 @@ function baselineCandidate( } /** - * The picker's option list: every eligible row reachable by one of the two - * executors, plus each enabled side's baseline unless that baseline is explicitly - * excluded, de-duplicated and stably ordered (openai side first, baselines first - * within a side). Anthropic rows must belong to the OAuth provider that would - * actually execute them, so `anthropicProviderName` is what makes that side's - * catalog rows eligible at all. + * The picker's option list: every eligible row reachable by an enabled + * executor, plus each enabled universal side's baseline unless that baseline + * is explicitly excluded, de-duplicated and stably ordered (side rank order, + * baselines first within a side). Anthropic rows must belong to the OAuth + * provider that would actually execute them, so `anthropicProviderName` is + * what makes that side's catalog rows eligible at all; xai/gemini rows map by + * provider identity and appear only when the caller enabled those backends. * * This is the SUGGESTION list (narrow): it emits only rows an executor can reach * and some source has heard of. It is deliberately NOT the same set as the write @@ -208,7 +221,7 @@ export function visionEligibleModelOptions( const byValue = new Map(); const enrichedProviders: EnrichedProviderCache = new Map(); - for (const backend of ["openai", "anthropic"] as const) { + for (const backend of Object.keys(BASELINE_VISION_MODELS) as UniversalVisionBackend[]) { if (!enabled.has(backend)) continue; const candidate = baselineCandidate(backend, anthropicProviderName); if (!isVisionEligibleModelWithCache(config, candidate, enrichedProviders)) continue; @@ -219,11 +232,19 @@ export function visionEligibleModelOptions( const backend = visionBackendForCandidate(config, candidate, anthropicProviderName); if (!backend || !enabled.has(backend)) continue; if (!isVisionEligibleModelWithCache(config, candidate, enrichedProviders)) continue; - if (byValue.has(candidate.id)) continue; - byValue.set(candidate.id, { value: candidate.id, label: candidate.id, backend }); + // Routed rows carry NAMESPACED values ("provider/model") so the loopback + // dispatch is unambiguous under routeModel; the legacy sides keep bare ids + // (GUI current-value compatibility, and the forward/OAuth executors POST + // the string verbatim). De-dup stays keyed by the emitted value. + const value = backend === "routed" ? `${candidate.provider}/${candidate.id}` : candidate.id; + if (byValue.has(value)) continue; + byValue.set(value, { value, label: value, backend }); } + // Two slots per side (baseline first), ranked openai < anthropic < routed + // so widening the union appends rather than interleaves (roadmap 170). + const sideRank: Record = { openai: 0, anthropic: 2, routed: 4 }; const order = (option: VisionModelOption) => - (option.backend === "openai" ? 0 : 2) + (option.baseline ? 0 : 1); + sideRank[option.backend] + (option.baseline ? 0 : 1); return [...byValue.values()].sort((a, b) => order(a) - order(b) || a.value.localeCompare(b.value)); } diff --git a/src/vision/index.ts b/src/vision/index.ts index 792e5db1b1..b945620ca6 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -5,6 +5,8 @@ import { modelRecordValue } from "../reasoning-effort"; import type { VisionReasoningEffort } from "../reasoning-effort"; import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe"; import { describeImageAnthropic } from "./anthropic-describe"; +import { describeImageRouted } from "./routed-describe"; +import { modelAcceptsImageInput } from "./eligibility"; import { normalizeVisionReasoningForModel } from "./reasoning"; import type { CodexAuthContext } from "../codex/auth-context"; import { resolveSidecarAuth } from "../sidecar/auth"; @@ -226,16 +228,23 @@ export function findAnthropicVisionProvider(config: OcxConfig): AnthropicVisionP } export function resolveVisionBackend( - explicit: "openai" | "anthropic" | undefined, + explicit: "openai" | "anthropic" | "routed" | undefined, anthropicSidecar: AnthropicVisionProvider | undefined, ): "openai" | "anthropic" { if (explicit === "openai" || explicit === "anthropic") return explicit; + // "routed" collapses to the legacy default order until its describe executor + // lands (roadmap 170 → 180 revised): a persisted routed backend without a + // dispatchable arm degrades exactly like unset rather than crashing. wp3 + // replaces this collapse with the real routed arm in planVisionSidecar. return anthropicSidecar ? "anthropic" : "openai"; } /** Native model used by the OpenAI vision helper, including its bounded default. */ export function resolveOpenAiVisionModel(config: Pick): string { - return config.visionSidecar?.model || DEFAULT_VISION_MODEL; + const configured = config.visionSidecar?.model; + // Namespaced routed ids never reach the forward executor (see + // resolveEffectiveVisionModel). + return configured && !configured.includes("/") ? configured : DEFAULT_VISION_MODEL; } /** Effective describer model for the backend `planVisionSidecar` selected. */ @@ -243,9 +252,15 @@ export function resolveEffectiveVisionModel( config: Pick, backend: "openai" | "anthropic", ): string { + const configured = config.visionSidecar?.model; + // A namespaced "provider/model" id belongs to the routed backend only; the + // forward/OAuth executors POST the model string verbatim, so it falls back + // to the side's default here (PUT coherence rejects new writes of this + // shape, but a legacy or hand-edited config must not break the executor). + const usable = configured && !configured.includes("/") ? configured : undefined; return backend === "anthropic" - ? config.visionSidecar?.model || DEFAULT_ANTHROPIC_VISION_MODEL - : resolveOpenAiVisionModel(config); + ? usable || DEFAULT_ANTHROPIC_VISION_MODEL + : usable || DEFAULT_VISION_MODEL; } /** A user/developer/toolResult message can carry images (toolResult: e.g. Codex view_image output). */ @@ -271,9 +286,13 @@ export function shouldResolveOpenAiVisionSidecar( } export interface VisionPlan { - backend: "openai" | "anthropic"; + backend: "openai" | "anthropic" | "routed"; forwardSidecar?: ResolvedOpenAiForwardSidecar; anthropicSidecar?: AnthropicVisionProvider; + /** Namespaced "provider/model" describer for the routed backend (roadmap 180). */ + routedModel?: string; + /** Loopback dispatch inputs for the routed backend. */ + routedConfig?: Pick; settings: VisionSettings; maxDescriptionsPerTurn: number; } @@ -295,8 +314,45 @@ export function planVisionSidecar( if (!messagesHaveImage(parsed)) return undefined; const cfg = config.visionSidecar ?? {}; if (cfg.enabled === false) return undefined; + + // Routed arm (roadmap 180 revised): explicit backend + NAMESPACED explicit + // model only — never inferred from credential availability. Plan-time + // fence: the target must not be provably blind, and must not itself be a + // model this planner would re-enter for (belt; the terminal marker on the + // loopback request is the braces). + if (cfg.backend === "routed") { + const routedModel = cfg.model; + const sep = routedModel ? routedModel.indexOf("/") : -1; + if (routedModel && sep > 0) { + const targetProvider = routedModel.slice(0, sep); + const targetId = routedModel.slice(sep + 1); + const targetProviderConfig = config.providers?.[targetProvider]; + const targetVisible = modelAcceptsImageInput(config, { provider: targetProvider, id: targetId }) !== false + && !(targetProviderConfig && isModelTextOnly(targetProviderConfig, targetId)); + if (targetVisible) { + return { + backend: "routed", + routedModel, + routedConfig: { port: config.port, ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}) }, + settings: { + model: routedModel, + reasoning: DEFAULT_REASONING, + timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs), + }, + maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn), + }; + } + } + // Misconfigured routed backend (bare id, unknown provider, or provably + // blind target): fall through to the legacy default order below rather + // than dispatching a describe that cannot work. + } + const anthropicSidecar = findAnthropicVisionProvider(config); const backend = resolveVisionBackend(cfg.backend, anthropicSidecar); + // A namespaced routed model must never reach the forward/OAuth executors + // (they POST the string verbatim); the effective-model resolver falls back + // to each side's default in that case. const model = resolveEffectiveVisionModel(config, backend); const maxDescriptionsPerTurn = resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn); @@ -447,6 +503,18 @@ async function executeDescription( abortSignal?: AbortSignal, recordSidecarOutcome?: SidecarOutcomeRecorder, ): Promise { + if (plan.backend === "routed") { + if (!plan.routedModel || !plan.routedConfig) return { text: "", error: "routed vision sidecar is unavailable" }; + return describeImageRouted( + job.imageUrl, + job.detail, + job.contextText, + plan.routedModel, + plan.routedConfig, + plan.settings, + abortSignal, + ); + } if (plan.backend === "anthropic") { const sidecar = plan.anthropicSidecar; if (!sidecar) return { text: "", error: "anthropic vision sidecar is unavailable" }; diff --git a/src/vision/routed-describe.ts b/src/vision/routed-describe.ts new file mode 100644 index 0000000000..fdd1575606 --- /dev/null +++ b/src/vision/routed-describe.ts @@ -0,0 +1,175 @@ +/** + * Describe ONE image via a ROUTED model through the proxy's own + * /v1/chat/completions on loopback (#2188 roadmap 180 revised). + * + * One executor for every provider the router can reach: the chat inbound + * translates image_url parts and each adapter compiles its own wire + * (Anthropic blocks, Antigravity inlineData, xai Responses input_image, plain + * openai-chat), so provider coverage is the router's job, not this file's. + * + * Recursion fence: the request carries `x-opencodex-vision-describe: 1`. + * The Chat surface detects the raw header before its bridge rebuilds headers + * and carries it into handleResponses as `visionDescribeTerminal`; a marked + * request STRIPS images instead of planning another describe (depth cap 1, + * holds under predicate drift and combo re-resolution — audit rounds 2-4). + * + * Admission ladder (audit round 3): configuredApiAuthToken() (env token) || + * service token file || first config.apiKeys entry, sent as + * `x-opencodex-api-key` — never Authorization (gateway-cache.ts rule: an + * admission secret in a forwardable header is a forwarding hazard). Loopback + * binds require no token at all (resolveApiAuth admits loopback). + * + * Known limitation (recorded in roadmap 170): a bindHost where 127.0.0.1 + * does not answer cannot reach its own loopback — same latent limitation + * gateway-cache has. + */ +import type { OcxConfig } from "../types"; +import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; +import { redactSecretString } from "../lib/redact"; +import { sidecarEnter } from "../lib/sidecar-tracker"; +import { configuredApiAuthToken, configuredPort } from "../server/auth-cors"; +import { loadServiceTokenFromFile } from "../lib/service-secrets"; +import type { DescribeOutcome, VisionSettings } from "./describe"; + +export const VISION_DESCRIBE_TERMINAL_HEADER = "x-opencodex-vision-describe"; + +const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]); +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +/** Bound the loopback JSON response; descriptions are clamped to ~2k chars by the caller anyway. */ +const MAX_ROUTED_RESPONSE_BYTES = 4 * 1024 * 1024; + +const DESCRIBE_INSTRUCTION = + "You are a vision describer for a text-only model that cannot see the image. Describe the image " + + "thoroughly and factually so that model can fully reason about it: transcribe any visible text " + + "verbatim, and note UI/layout, colors, branding/logos, charts, and notable details. Focus on " + + "what's relevant to the user's request. Output only the description."; + +function validateImageUrl(url: string): string | null { + if (url.startsWith("data:")) { + const match = /^data:([^;,]+?)(;base64)?,(.*)$/s.exec(url); + if (!match) return "malformed data URL"; + const mime = match[1].toLowerCase(); + if (!ALLOWED_IMAGE_MIME.has(mime)) return `unsupported image type "${mime}"`; + if (match[2]) { + const bytes = Math.floor((match[3].length * 3) / 4); + if (bytes > MAX_IMAGE_BYTES) return `image too large (~${Math.round(bytes / 1024 / 1024)}MB)`; + } + return null; + } + if (url.startsWith("https://")) return null; + return "unsupported image URL scheme (expected data: or https:)"; +} + +/** The admission ladder: env token, service token file, first configured API key. */ +export function routedDescribeAdmissionToken(config: Pick): string | undefined { + const envToken = configuredApiAuthToken(); + if (envToken) return envToken; + const fileToken = loadServiceTokenFromFile(process.env); + if (fileToken) return fileToken; + const first = config.apiKeys?.[0]?.key?.trim(); + return first || undefined; +} + +/** Base URL seam for tests; production always self-fetches loopback. */ +export function routedDescribeBaseUrl(config: Pick): string { + // config.port can be 0 (ephemeral bind, tests) or stale after a live port + // override; the server records its ACTUAL bound port via setCorsOrigin at + // startup, so prefer that when config carries no positive port. + const port = config.port && config.port > 0 ? String(config.port) : configuredPort(); + return `http://127.0.0.1:${port}`; +} + +export async function describeImageRouted( + imageUrl: string, + _detail: string | undefined, + contextText: string, + routedModel: string, + config: Pick, + settings: VisionSettings, + abortSignal?: AbortSignal, + baseUrlOverride?: string, +): Promise { + const invalid = validateImageUrl(imageUrl); + if (invalid) return { text: "", error: invalid }; + + const headers: Record = { + "Content-Type": "application/json", + [VISION_DESCRIBE_TERMINAL_HEADER]: "1", + }; + const admission = routedDescribeAdmissionToken(config); + if (admission) headers["x-opencodex-api-key"] = admission; + + const requestBody = { + model: routedModel, + stream: false, + messages: [ + { role: "system", content: DESCRIBE_INSTRUCTION }, + { + role: "user", + content: [ + ...(contextText ? [{ type: "text", text: `User's request context: ${contextText}` }] : []), + { type: "image_url", image_url: { url: imageUrl } }, + ], + }, + ], + }; + + const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); + const sidecarExit = sidecarEnter("vision"); + const t0 = Date.now(); + try { + const res = await fetch(`${baseUrlOverride ?? routedDescribeBaseUrl(config)}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: linkedSignal.signal, + redirect: "manual", + }); + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + try { + const raw = await res.text(); + if (raw.length > MAX_ROUTED_RESPONSE_BYTES) { + return { text: "", error: "routed describe response exceeded byte bound" }; + } + if (!res.ok) { + return { text: "", error: `routed describe HTTP ${res.status}: ${redactSecretString(raw.slice(0, 200))}` }; + } + let payload: unknown; + try { payload = JSON.parse(raw); } catch { + return { text: "", error: "routed describe returned non-JSON" }; + } + const content = extractChatContent(payload); + if (!content) return { text: "", error: "routed describe returned no text" }; + return { text: content }; + } finally { + detachBodyGuard(); + } + } catch (e) { + const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + console.warn(`[vision] routed describe ${kind} (${Date.now() - t0}ms)`); + return { text: "", error: redactSecretString(e instanceof Error ? e.message : String(e)) }; + } finally { + sidecarExit(); + linkedSignal.cleanup(); + } +} + +function extractChatContent(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") return undefined; + const choices = (payload as { choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) return undefined; + const message = (choices[0] as { message?: unknown })?.message; + if (!message || typeof message !== "object") return undefined; + const content = (message as { content?: unknown }).content; + if (typeof content === "string" && content.trim().length > 0) return content; + // Some adapters emit content parts; join text parts. + if (Array.isArray(content)) { + const joined = content + .map(part => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "")) + .join(""); + if (joined.trim().length > 0) return joined; + } + return undefined; +} diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 6054c211d4..26a279f592 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -80,6 +80,27 @@ on proven absence, never on an unreadable path. - 다른 대안 대신 이 방식을 선택한 이유: Physical credential ownership remains cross-process safe, while an inert optional subsystem can no longer create the reported lock/recovery catch-22. - 장점, 단점 및 영향: Fresh installs avoid the SQLite profile lock; any present or uncertain stage state retains the existing locked fail-closed cleanup and recovery behavior. +The native-write coordinator is keyed by the canonical `CODEX_HOME` in the effective-user runtime +namespace. A pathname alone is not authority: SQLite can expose a zero-byte file before its first +schema write, and a terminated process can leave that remnant behind. Eligibility treats the file +as non-authoritative only after an immutable SQLite read proves version zero with no tables, the +filesystem identity remains unchanged, and the file has been settled for at least one second; a +fresh zero-byte creator stays on the coordinated path so its lock cannot be bypassed. `ocx doctor` inspects the +coordinator with immutable read-only SQLite flags so diagnosis never creates WAL/SHM sidecars. It +distinguishes absent, zero-byte, unversioned, rowless, valid, unsupported, changed, unsafe, and +unreadable states and prints the exact path. Explicit recovery is available only after the proxy is +stopped and only for a proven zero-byte state. The command revalidates the same private +regular-file identity under a non-blocking SQLite write lock and moves it to a same-directory +backup; it never deletes or auto-adopts legacy routed residue. + +[Decision Log] +- 목적과 의도: Recover a crashed zero-byte coordinator without mistaking SQLite's normal creation window for stale authority. +- 기존 구현 및 제약 조건: Eligibility treated every existing pathname as coordinated, while initialization correctly refused a missing row over routed residue; catalog sync could therefore succeed before config injection failed permanently. +- 검토한 주요 대안: Delete zero-byte files automatically, initialize a new row over residue, require a manual filesystem command, or add observe-only classification plus explicit guarded quarantine. +- 선택한 방식: Treat only a settled, identity-stable, immutably verified zero-byte database like the existing legacy-uncoordinated boundary; keep fresh creators coordinated, diagnose all other database states immutably, and expose an opt-in zero-byte-only same-directory backup move with identity, ownership, sidecar, liveness, and SQLite-lock checks. +- 다른 대안 대신 이 방식을 선택한 이유: Automatic deletion or adoption can race a live creator or erase transition evidence; a guarded backup preserves evidence and makes the operator action reproducible. +- 장점, 단점 및 영향: A stale zero-byte file no longer wedges sync, valid/unrecognized databases remain fail-closed, and recovery requires the proxy to be stopped before `ocx sync` retries injection. + OpenCodex never overrides an explicit `CODEX_HOME`. On Windows, `ocx doctor` and `ocx status` nevertheless diagnose the high-confidence Orca dual-home case: both `CODEX_HOME` and `ORCA_CODEX_HOME` select Orca's `orca/codex-runtime-home/home`, while the ChatGPT/Codex app uses the diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 75410d38b3..927a8f2fd5 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1,5 +1,22 @@ # Transports And Sidecars SOT +## Background service command selection + +A bare `ocx service` is an idempotent install-or-repair command. Argument validation happens before +any platform status probe. macOS and Linux choose from the registration file's proven presence; +Windows combines the Task Scheduler and WinSW probes into `installed`, `absent`, or `unknown`. +Only proven absence enters registration. A query failure refuses the bare command with status +guidance, because treating `unknown` as absent can rerun elevated `schtasks /create` against an +existing task. Explicit `ocx service install` remains the operator-owned registration request. + +[Decision Log] +- 목적과 의도: Make a bare service refresh safe and idempotent without converting a localized or transient Windows status failure into an elevated re-registration. +- 기존 구현 및 제약 조건: The command defaulted to install and later used a boolean diagnostic whose scheduler query fallback could collapse unknown into absent; repair must preserve the existing Windows launcher and Bun stability workarounds. +- 검토한 주요 대안: Always repair; keep a boolean installed check; infer presence from saved state alone; use a tri-state live registration probe. +- 선택한 방식: Validate arguments first, then use a narrow tri-state platform probe only for a bare backend-neutral invocation; route installed to repair, absent to install, and unknown to a refusal. +- 다른 대안 대신 이 방식을 선택한 이유: Saved state can be stale and unconditional repair breaks first install, while a boolean cannot represent the exact uncertainty that must fail closed. +- 장점, 단점 및 영향: Existing services avoid UAC and registration churn, invalid input performs no status I/O, and uncertain Windows hosts require one explicit status/installation decision instead of risking a destructive guess. + ## Provider diagnostic outbound safety Provider connection tests and live model discovery share the GET-only provider outbound wrapper. @@ -27,7 +44,7 @@ Responses-compatible streaming output. - 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result. - 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge. - 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item. -- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` and tools replaced by hosted-provider policy stay in their upstream function-call form. +- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` stays in its upstream function-call form unless the destination explicitly denies Responses custom tools; tools replaced by hosted-provider policy also stay in their upstream function-call form. - 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. - 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. @@ -52,10 +69,11 @@ Two coordinates that lower to the same wire name are treated as one tool when th a `functions` child of the same name are the duplicate the parser already tolerates — and the one `promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request. -Replayed call items are lowered whether or not this turn declares the group they name. A routed -compaction turn strips the whole tool surface before the boundary runs, and a catalog can change -mid-session, but the client is still replaying items this layer's own response restoration stamped -with a private `namespace`. Only `tool_choice` resolves a bare name through the catalog: a history +Replayed call items are lowered whether or not this turn declares the group they name. A catalog can +be absent or change mid-session, but the client is still replaying items this layer's own response +restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing +the tool surface so request-local aliases remain available for response restoration. Only +`tool_choice` resolves a bare name through the catalog: a history item records which tool actually ran, so re-pointing it at a same-named namespace child would rewrite that record on a coincidence rather than translate it. @@ -101,6 +119,14 @@ alone never opt a gateway in. and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. +[Decision Log] +- 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. +- 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. +- 검토한 주요 대안: Shorten the global stall timeout; treat every later abort as success; settle only when the HTTP stream emits end; make the clean Connect envelope authoritative. +- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, run any already-armed drained client-tool finalizer before protocol cleanup clears its grace timer only while the call set is still drained, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. +- 다른 대안 대신 이 방식을 선택한 이유: The protocol envelope is upstream's explicit terminal signal. Timeout changes only hide the race, and globally swallowing aborts would mask genuine mid-turn cancellation. +- 장점, 단점 및 영향: Completed Cursor responses no longer wait for the 300-second watchdog when the HTTP body stays open; incomplete tool calls still emit their existing truncation error, and error-bearing Connect terminals remain failures. + A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent base64, so they always lower to plain user messages. A native blob is relayed only when there is no @@ -162,6 +188,14 @@ not a separate tier policy. One write sets or clears the Grok 4.5 and 4.6 entrie preserving unrelated overrides; a pre-existing one-entry state is reported as mixed until the next switch write normalizes both. +[Decision Log] +- 목적과 의도: Keep Codex hosted web search usable on xAI's public Responses endpoint without forwarding private OpenAI-only fields that xAI rejects. +- 기존 구현 및 제약 조건: Codex emits `external_web_access`, `search_context_size`, `search_content_types`, and `user_location`; xAI documents a live-only `web_search` tool with domain filters and image flags, while Codex cached mode explicitly forbids external access. +- 검토한 주요 대안: Strip only the first rejected field; pass every hosted-search field unchanged; disable web search for all xAI turns; normalize only the exact official xAI API destination. +- 선택한 방식: On `https://api.x.ai` Responses traffic, lower live search to xAI's public shape, map image content requests to `enable_image_search`, remove unsupported OpenAI-private fields, and omit cached/index-only search plus stale selectors because xAI has no non-live equivalent. +- 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. +- 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. + OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 21cef4e954..0fd70b3b58 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,34 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +Pool affinity preserves the existing `x-codex-parent-thread-id` supplied by ordinary Codex clients. +The parent id is trimmed and bounded under the same 512-byte component limit as the Desktop +fallback. When Codex Desktop omits it or sends an unusable value, the complete bounded `session-id` +plus `thread-id` pair is mapped to an opaque HMAC under a random process-local key. Missing or +oversized components remain unbound, raw identifiers and durable hashes are never stored, and +account-qualified selectors skip both lookup and mutation. Selection, subagent fallback preview, +and terminal outcome accounting carry the same key so route planning cannot preview one account +and authenticate another, and a transient failure clears the binding that actually selected the +account. + +[Decision Log] +- 목적과 의도: Keep Desktop reconnects on the account selected for the App task without persisting + or exposing its session and thread identifiers. +- 기존 구현 및 제약 조건: Pool affinity used only `x-codex-parent-thread-id`; Desktop requests can + omit it while stable `session-id` and `thread-id` headers remain available. Exact account + selectors must stay outside automatic Pool affinity. +- 검토한 주요 대안: Leave reconnects unbound, persist a plain hash, bind from either header alone, + delete App turn metadata, or derive one process-local key from the complete pair. +- 선택한 방식: Preserve the parent-thread key when present; otherwise HMAC the two bounded headers + under a random per-process key and carry that opaque value through selection, subagent preview, + and outcome handling. +- 다른 대안 대신 이 방식을 선택한 이유: A complete pair avoids weak partial identities, a + process-local HMAC prevents durable correlation or dictionary recovery, and no upstream metadata + needs to be mutated before the first-403 cause is proven. +- 장점, 단점 및 영향: Reconnects stop rotating among Pool accounts and failure accounting clears + the correct binding. Affinity intentionally resets on process restart, and requests missing either + component retain the prior unbound behavior. + An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset-derived native-model 429 is advisory and remains within its confirmed quota group: `gpt-5.3-codex-spark` is separate from the shared native group (including GPT-5.6 Terra/Luna). This allows a same-account combo to test an diff --git a/tests/bun-stream-caps.test.ts b/tests/bun-stream-caps.test.ts index 93c79808db..e373683c35 100644 --- a/tests/bun-stream-caps.test.ts +++ b/tests/bun-stream-caps.test.ts @@ -47,8 +47,8 @@ describe("compareBunVersions", () => { }); describe("bunHasAsyncPullCancelFix", () => { - test("no min-fixed threshold → never fixed (today's shipped state)", () => { - expect(MIN_FIXED_BUN_VERSION).toBeNull(); + test("shipped threshold is Bun 1.4.0; a null threshold is never fixed", () => { + expect(MIN_FIXED_BUN_VERSION).toBe("1.4.0"); expect(bunHasAsyncPullCancelFix("99.0.0", null)).toBe(false); }); diff --git a/tests/catalog-vision-sidecar-modalities.test.ts b/tests/catalog-vision-sidecar-modalities.test.ts index a9e4f1cd85..f5ff51431d 100644 --- a/tests/catalog-vision-sidecar-modalities.test.ts +++ b/tests/catalog-vision-sidecar-modalities.test.ts @@ -5,6 +5,8 @@ import type { OcxProviderConfig } from "../src/types"; import { deriveComboCatalogModel } from "../src/codex/catalog"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; import { enrichProviderFromRegistry } from "../src/providers/derive"; +import { CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS } from "../src/adapters/cursor/discovery"; +import { modelInList } from "../src/types"; import type { CatalogModel } from "../src/types"; const base: OcxProviderConfig = { @@ -257,3 +259,22 @@ describe("vision-capable provider models feed combo modalities", () => { expect(hinted.inputModalities).toEqual(["text", "image"]); }); }); + +describe("Cursor native vs sidecar vision registry", () => { + test("curates noVisionModels for Auto/Composer/GLM while advertising image for all static ids", () => { + const cursor = PROVIDER_REGISTRY.find(entry => entry.id === "cursor"); + expect(cursor?.noVisionModels).toEqual([...CURSOR_NO_VISION_MODELS]); + for (const model of ["auto", "composer-1", "composer-2.5", "composer-2.5-fast", "glm-5.2", "glm-5.3"]) { + expect(modelInList(cursor?.noVisionModels, model), `${model} should match noVision`).toBe(true); + } + for (const model of ["auto", "composer-2.5", "glm-5.2", "glm-5.3", "gpt-5.5", "gemini-3-pro", "grok-4.5", "kimi-k3"]) { + expect(cursor?.modelInputModalities?.[model]).toEqual(["text", "image"]); + } + for (const model of ["gpt-5.5", "gemini-3-pro", "grok-4.5", "kimi-k3"]) { + expect(modelInList(cursor?.noVisionModels, model)).toBe(false); + } + for (const model of CURSOR_STATIC_MODELS) { + expect(cursor?.modelInputModalities?.[model.id]).toEqual(["text", "image"]); + } + }); +}); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index becd15dbdc..46a66b0d6c 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -133,7 +133,13 @@ describe("GitHub Actions hardening", () => { expect(`${name}:${typeof job?.["timeout-minutes"]}`).toBe(`${name}:number`); } expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); - expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); + // Bun setup moved into .github/actions/setup-project-bun so the runtime + // version has a single source (package.json). The SHA pin still has to + // exist — it just lives in the composite action now, and this workflow + // must reference that local action rather than a third-party one. + expect(workflow).toContain("./.github/actions/setup-project-bun"); + expect(await readText(".github/actions/setup-project-bun/action.yml")) + .toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"); expect(workflow).toContain("bun test --isolate tests"); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); @@ -161,8 +167,9 @@ describe("GitHub Actions hardening", () => { // keys closes all three — a hardcoded list rots on the next job added. const gate = ci.jobs?.ci as { if?: unknown; needs?: string[] } | undefined; expect(gate?.if).toBe("always()"); + const ungated = new Set(["ci"]); expect([...(gate?.needs ?? [])].sort()) - .toEqual(Object.keys(ci.jobs ?? {}).filter(name => name !== "ci").sort()); + .toEqual(Object.keys(ci.jobs ?? {}).filter(name => !ungated.has(name)).sort()); // The focused doctor contract config is ADDITIVE evidence. It must never // replace the repository-wide strict typecheck: doing so made the aggregate @@ -380,7 +387,8 @@ describe("GitHub Actions hardening", () => { }; jobs?: Record | undefined>; }; - expect([...(ci.on?.push?.branches ?? [])].sort()).toEqual(["dev", "main", "preview"]); + expect([...(ci.on?.push?.branches ?? [])].sort()) + .toEqual(["dev", "main", "preview"]); // The PR trigger must carry NO base-branch filter, and the two triggers // differ on purpose. GitHub matches `branches:` against the BASE ref, so @@ -700,7 +708,11 @@ describe("GitHub Actions hardening", () => { // Immutable action references. expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); - expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); + // Same move as the CI workflow: the pinned setup-bun reference now lives in + // the shared composite action. + expect(workflow).toContain("./.github/actions/setup-project-bun"); + expect(await readText(".github/actions/setup-project-bun/action.yml")) + .toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); diff --git a/tests/claude-code-thought-signature-scope.test.ts b/tests/claude-code-thought-signature-scope.test.ts new file mode 100644 index 0000000000..d6a3de65f6 --- /dev/null +++ b/tests/claude-code-thought-signature-scope.test.ts @@ -0,0 +1,125 @@ +/** + * Regression coverage for the Claude Code thought-signature replay scope: + * + * Claude Code speaks Anthropic Messages and does not send Codex's + * `x-codex-parent-thread-id`. The server must still create a reasoning-replay + * scope for a real per-session `prompt_cache_key` (derived from + * `metadata.user_id`) so Gemini/Antigravity thought signatures can be remembered + * by call_id. The shared Desktop `prompt_cache_key` cohort must NOT get a scope. + */ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import type { ProviderAdapter } from "../src/adapters/base"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const actualResolver = await import("../src/server/adapter-resolve"); + +let adapterFactory: ((provider: OcxProviderConfig) => ProviderAdapter) | undefined; + +mock.module("../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { + return adapterFactory?.(provider) ?? actualResolver.resolveAdapter(provider, cacheRetention); + }, +})); + +const { handleResponses } = await import("../src/server/responses"); + +afterEach(() => { + adapterFactory = undefined; +}); + +function captureAdapter(captured: OcxParsedRequest[]): ProviderAdapter { + return { + name: "capture-replay-scope", + buildRequest: () => ({ url: "https://capture.test", method: "POST", headers: {}, body: "{}" }), + async *parseStream(): AsyncGenerator { + yield { type: "done" }; + }, + async runTurn(parsed: OcxParsedRequest, _incoming, emit) { + captured.push(parsed); + emit({ type: "done" }); + }, + }; +} + +function testConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://capture.test", + authMode: "key", + apiKey: "capture-key", + models: ["m1"], + }, + }, + } as OcxConfig; +} + +async function drive(options: { + promptCacheKey?: string; + promptCacheKeyIsSharedCohort?: boolean; +}): Promise { + const captured: OcxParsedRequest[] = []; + adapterFactory = () => captureAdapter(captured); + const body: Record = { + model: "m1", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }], + }; + if (options.promptCacheKey !== undefined) body.prompt_cache_key = options.promptCacheKey; + + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + testConfig(), + { model: "", provider: "" }, + { + inboundWire: "anthropic", + ...(options.promptCacheKeyIsSharedCohort === undefined + ? {} + : { promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort }), + }, + ); + await response.text(); + expect(captured.length).toBe(1); + return captured[0]!; +} + +describe("Claude Code Anthropic inbound reasoning-replay scope", () => { + test("a real per-session prompt_cache_key creates a call_id replay scope", async () => { + const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); + expect(parsed._clientThreadId).toBeUndefined(); + expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + }); + + test("the shared Desktop prompt_cache_key cohort does not create a scope", async () => { + const parsed = await drive({ promptCacheKey: "shared-cohort-key", promptCacheKeyIsSharedCohort: true }); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); + + test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { + const parsed = await drive({}); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); + + test("an overlong prompt_cache_key is hashed, not stored raw", async () => { + const overlong = "k".repeat(200); + const parsed = await drive({ promptCacheKey: overlong, promptCacheKeyIsSharedCohort: false }); + const scope = parsed._reasoningReplayScope?.clientThreadId; + expect(scope).toBeDefined(); + expect(scope).not.toBe(overlong); + expect(scope!.length).toBeLessThanOrEqual(128); + }); + + test("a whitespace-only prompt_cache_key does not create a scope", async () => { + const parsed = await drive({ promptCacheKey: " ", promptCacheKeyIsSharedCohort: false }); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); +}); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index ea1e49718b..8998d644a3 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -293,7 +293,7 @@ describe("CLI subcommand help", () => { test("invalid service and codex-shim usage include remove alias", () => { const cases = [ - { args: ["service", "nope"], expected: "Usage: ocx service [install|repair|start|stop|status|uninstall|remove]" }, + { args: ["service", "nope"], expected: "Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove]" }, { args: ["codex-shim", "nope"], expected: "Usage: ocx codex-shim " }, ]; diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index b4e9f77d86..5a3372c137 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -497,6 +497,187 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("Desktop session and thread headers derive one opaque reconnect affinity", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + const headers = new Headers({ + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(first).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(first.kind).toBe("pool"); + if (first.kind !== "pool") throw new Error("expected pool context"); + expect(first.affinityKey?.startsWith("app:")).toBe(true); + expect(first.affinityKey?.includes("desktop-session-private")).toBe(false); + expect(first.affinityKey?.includes("desktop-thread-private")).toBe(false); + + cfg.activeCodexAccountId = "pool-b"; + const reconnect = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(reconnect).toMatchObject({ + kind: "pool", + accountId: "pool-a", + affinityKey: first.affinityKey, + }); + }); + + test("the canonical parent-thread affinity stays authoritative over Desktop fallback headers", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + const headers = new Headers({ + "x-codex-parent-thread-id": " canonical-parent-thread ", + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const resolved = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(resolved).toMatchObject({ + kind: "pool", + accountId: "pool-a", + affinityKey: "canonical-parent-thread", + }); + }); + + test("an oversized parent-thread id falls back to the bounded Desktop pair", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + const headers = new Headers({ + "x-codex-parent-thread-id": "p".repeat(513), + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }); + + const resolved = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(resolved).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(resolved.kind).toBe("pool"); + if (resolved.kind !== "pool") throw new Error("expected pool context"); + expect(resolved.affinityKey?.startsWith("app:")).toBe(true); + expect(resolved.affinityKey).not.toContain("desktop-session-private"); + expect(resolved.affinityKey).not.toContain("desktop-thread-private"); + }); + + test("incomplete or oversized Desktop affinity headers remain unbound", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + + for (const headers of [ + new Headers({ "session-id": "session-only" }), + new Headers({ "thread-id": "thread-only" }), + new Headers({ "session-id": "s".repeat(513), "thread-id": "bounded-thread" }), + ]) { + clearThreadAccountMap(); + cfg.activeCodexAccountId = "pool-a"; + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(first).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(first.kind === "pool" ? first.affinityKey : undefined).toBeUndefined(); + + cfg.activeCodexAccountId = "pool-b"; + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + } + }); + + test("exact account selection does not create Desktop Pool affinity", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.activeCodexAccountId = "pool-b"; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + const headers = new Headers({ + "session-id": "exact-desktop-session", + "thread-id": "exact-desktop-thread", + }); + + const exact = await resolveCodexAuthContext(headers, cfg, "pool", { accountId: "pool-a" }); + expect(exact).toMatchObject({ kind: "pool", accountId: "pool-a", fixedAccount: true }); + expect(exact.kind === "pool" ? exact.affinityKey : undefined).toBeUndefined(); + + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + + test("late transient failure cannot delete a newer Desktop affinity binding", async () => { + const cfg = config(); + cfg.autoSwitchThreshold = 0; + cfg.upstreamFailoverThreshold = 3; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}_token`, + refreshToken: `${id}_refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}_acc`, + }); + } + const headers = new Headers({ + "session-id": "failure-desktop-session", + "thread-id": "failure-desktop-thread", + }); + const first = await resolveCodexAuthContext(headers, cfg, "pool"); + if (first.kind !== "pool") throw new Error("expected pool context"); + expect(first.accountId).toBe("pool-a"); + + for (let attempt = 0; attempt < 3; attempt += 1) { + recordCodexUpstreamOutcome(cfg, "pool-a", 500, { + now: 1_800_000_000_000 + attempt, + threadId: first.affinityKey, + }); + } + const rebound = await resolveCodexAuthContext(headers, cfg, "pool"); + expect(rebound).toMatchObject({ kind: "pool", accountId: "pool-b" }); + + recordCodexUpstreamOutcome(cfg, "pool-a", 500, { + now: 1_800_000_000_100, + threadId: first.affinityKey, + }); + clearCodexUpstreamHealth(); + cfg.activeCodexAccountId = "pool-a"; + await expect(resolveCodexAuthContext(headers, cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("selection order never bypasses an exact account selector", async () => { // Regression: `codexAccountPriorities` narrows the pool to the highest tier, but it // is an ordering boundary over the pool path only. A request that names an account diff --git a/tests/codex-coordinator-doctor.test.ts b/tests/codex-coordinator-doctor.test.ts new file mode 100644 index 0000000000..49e9be23ce --- /dev/null +++ b/tests/codex-coordinator-doctor.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + inspectCodexCoordinator, + recoverZeroByteCodexCoordinator, +} from "../src/codex/coordinator-doctor"; +import { + codexWriteCoordinationEligibility, + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS, +} from "../src/codex/inject-coordination"; +import { + openCodexCoordinatorTransaction, +} from "../src/codex/transition-state"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { formatCoordinatorDoctorLines } from "../src/cli/doctor"; + +let codexHome = ""; +let opencodexHome = ""; +let coordinatorPath = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + codexHome = mkdtempSync(join(tmpdir(), "ocx-coordinator-doctor-codex-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-coordinator-doctor-ocx-")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${coordinatorPath}${suffix}`, { force: true }); + } + rmSync(codexHome, { recursive: true, force: true }); + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +function privateFile(path: string, bytes = ""): void { + writeFileSync(path, bytes); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +test("doctor classifies and explicitly backs up a stable zero-byte coordinator", () => { + privateFile(coordinatorPath); + const diagnostic = inspectCodexCoordinator(); + expect(diagnostic.kind).toBe("zero-byte"); + if (diagnostic.kind !== "zero-byte") return; + expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( + "ocx doctor --recover-zero-byte-coordinator --yes", + ); + expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( + "size: 0 bytes; user_version: 0", + ); + + const recovered = recoverZeroByteCodexCoordinator(new Date("2026-08-21T12:00:00.000Z")); + expect(recovered.ok).toBe(true); + if (!recovered.ok) return; + expect(recovered.backupPath).toEndWith(".zero-byte-backup-20260821T120000000Z"); + expect(existsSync(coordinatorPath)).toBe(false); + expect(existsSync(recovered.backupPath)).toBe(true); + rmSync(recovered.backupPath, { force: true }); +}); + +test("doctor distinguishes unversioned, rowless, and authoritative coordinators", () => { + let database = new Database(coordinatorPath, { create: true }); + database.exec("CREATE TABLE temporary_probe (id INTEGER); DROP TABLE temporary_probe"); + database.close(); + if (process.platform !== "win32") chmodSync(coordinatorPath, 0o600); + expect(inspectCodexCoordinator().kind).toBe("unversioned-empty"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is unversioned-empty, not a recoverable zero-byte remnant", + }); + + database = new Database(coordinatorPath, { readwrite: true, create: false }); + database.exec("PRAGMA user_version = 1; CREATE TABLE codex_transition_state (singleton INTEGER PRIMARY KEY)"); + database.close(); + expect(inspectCodexCoordinator().kind).toBe("rowless"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is rowless, not a recoverable zero-byte remnant", + }); + + database = new Database(coordinatorPath, { readwrite: true, create: false }); + database.exec("INSERT INTO codex_transition_state (singleton) VALUES (1)"); + database.close(); + const malformed = inspectCodexCoordinator(); + expect(malformed.kind).toBe("unreadable"); + expect(formatCoordinatorDoctorLines(malformed).join("\n")).toContain("user_version: 1"); + expect(formatCoordinatorDoctorLines(malformed).join("\n")).toContain("transition rows: 1"); + + rmSync(coordinatorPath, { force: true }); + const transaction = openCodexCoordinatorTransaction(coordinatorPath); + transaction.commit(); + transaction.close(); + expect(inspectCodexCoordinator().kind).toBe("ready"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is ready, not a recoverable zero-byte remnant", + }); +}); + +test("doctor inspection is immutable and refuses sidecars, unsafe modes, and symlinks", () => { + privateFile(coordinatorPath); + expect(inspectCodexCoordinator().kind).toBe("zero-byte"); + for (const suffix of ["-journal", "-wal", "-shm"]) { + expect(existsSync(`${coordinatorPath}${suffix}`)).toBe(false); + } + + privateFile(`${coordinatorPath}-wal`, "active"); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + rmSync(`${coordinatorPath}-wal`, { force: true }); + + if (process.platform !== "win32") { + chmodSync(coordinatorPath, 0o644); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + chmodSync(coordinatorPath, 0o600); + + const target = `${coordinatorPath}.target`; + privateFile(target); + rmSync(coordinatorPath, { force: true }); + symlinkSync(target, coordinatorPath); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + rmSync(coordinatorPath, { force: true }); + rmSync(target, { force: true }); + } +}); + +test("recovery refuses a zero-byte coordinator with an active SQLite writer sidecar", () => { + privateFile(coordinatorPath); + const holder = new Database(coordinatorPath, { readwrite: true, create: false }); + holder.exec("PRAGMA journal_mode = OFF; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + expect(recoverZeroByteCodexCoordinator()).toMatchObject({ + ok: false, + reason: expect.stringContaining("active SQLite journal sidecar"), + }); + expect(existsSync(coordinatorPath)).toBe(true); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } +}); + +test("zero-byte residue uses the legacy boundary while clean homes still initialize", () => { + privateFile(coordinatorPath); + const afterStableAge = () => Date.now() + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 1; + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: afterStableAge, + })).toEqual({ + kind: "legacy-uncoordinated", + reason: "the coordinator is a zero-byte non-authoritative remnant and this routed home has not been adopted yet", + }); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "clean" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: afterStableAge, + })).toEqual({ kind: "coordinated" }); + + privateFile(coordinatorPath, "not-empty"); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + })).toEqual({ kind: "coordinated" }); +}); + +test("a fresh zero-byte coordinator stays on the locked path until it is stable", () => { + privateFile(coordinatorPath); + const fresh = codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: () => Date.now(), + }); + expect(fresh).toEqual({ kind: "coordinated" }); + + const settled = codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: () => Date.now() + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 1, + }); + expect(settled.kind).toBe("legacy-uncoordinated"); +}); diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 5603137bd2..9054d5bacf 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -8,9 +8,14 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { STABLE_ZERO_BYTE_COORDINATOR_AGE_MS } from "../src/codex/inject-coordination"; const repoRoot = join(import.meta.dir, ".."); const CHILD = join(repoRoot, "tests", "helpers", "codex-inject-race-child.ts"); @@ -20,6 +25,7 @@ let root = ""; let codexHome = ""; let opencodexHome = ""; const cleanup: string[] = []; +const coordinatorCleanup: string[] = []; function seedNative(): void { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); @@ -50,6 +56,12 @@ beforeEach(() => { }); afterEach(() => { + while (coordinatorCleanup.length) { + const path = coordinatorCleanup.pop()!; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${path}${suffix}`, { force: true }); + } + } while (cleanup.length) { const dir = cleanup.pop()!; // `force` covers a missing path, not a locked one: a child that is still exiting @@ -184,6 +196,35 @@ describe("homes the coordinator cannot adopt keep working", () => { expect(result.success).toBeTrue(); expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); }); + + test("a zero-byte coordinator remnant does not wedge a pre-substrate routed home", () => { + writeFileSync(join(codexHome, "config.toml"), [ + 'model_provider = "opencodex"', + 'model = "gpt-5.5"', + "", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'wire_api = "responses"', + "", + ].join("\n")); + const coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); + coordinatorCleanup.push(coordinatorPath); + writeFileSync(coordinatorPath, ""); + if (process.platform !== "win32") chmodSync(coordinatorPath, 0o600); + // Fresh zero-byte files remain on the coordinated path because they may + // belong to a live SQLite creator. This fixture represents an old remnant. + Bun.sleepSync(STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 100); + + const result = runInject(10100); + + expect(result.success).toBeTrue(); + expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); + expect(readFileSync(coordinatorPath)).toHaveLength(0); + }); }); describe("the transition is resolved, not left pending", () => { diff --git a/tests/codex-native-residue.test.ts b/tests/codex-native-residue.test.ts index 604afb27f2..5fd0fb52e3 100644 --- a/tests/codex-native-residue.test.ts +++ b/tests/codex-native-residue.test.ts @@ -519,7 +519,6 @@ test("every non-string TOML type for model_catalog_json is indeterminate", () => ["boolean", "true"], ["array", '["custom.json"]'], ["inline table", '{ path = "custom.json" }'], - ["datetime", "1979-05-27T07:32:00Z"], ] as const; for (const [type, value] of nonStringTomlValues) { @@ -532,6 +531,31 @@ test("every non-string TOML type for model_catalog_json is indeterminate", () => } }); +// TOML datetime is deliberately not in the list above: which SURFACE reports the +// problem is runtime-dependent, and neither answer is a defect. +// +// Bun 1.3.14 has no datetime support, so the document fails to parse and the +// config surface reports it. Bun 1.4 added datetime and yields a string, so +// `model_catalog_json` becomes a syntactically valid path, the classifier +// resolves it like any other, and the CATALOG surface reports it as absent. +// Pinning `surface: "config"` for both fails on 1.4 for a reason unrelated to +// this repository — found during Bun 1.4 canary qualification (#1691). +// +// The property worth pinning survives both: a datetime literal is never +// accepted as a usable catalog. It is either unparseable config or a path that +// does not exist, and `indeterminate` is what refuses coordinator +// initialization in both cases. +test("a TOML datetime for model_catalog_json is never accepted as a usable catalog", () => { + writeFileSync(pathInCodexHome("config.toml"), "model_catalog_json = 1979-05-27T07:32:00Z\n"); + const classification = classifyNativeRoutedResidue(); + + expect(classification.kind).toBe("indeterminate"); + // 1.3.14 blames the unreadable config; 1.4 blames the catalog path it parsed. + expect(["config", "catalog"]).toContain( + (classification as { surface: string }).surface, + ); +}); + test("duplicate configured catalog paths are indeterminate", () => { writeFileSync(pathInCodexHome("config.toml"), [ 'model_catalog_json = "first.json"', diff --git a/tests/codex-runtime.test.ts b/tests/codex-runtime.test.ts index 8331347af8..55fbc2e066 100644 --- a/tests/codex-runtime.test.ts +++ b/tests/codex-runtime.test.ts @@ -1,4 +1,20 @@ import { describe, expect, test } from "bun:test"; + +/** + * PATH for tests that must stop PATH-based codex DISCOVERY while keeping their + * fake launchers runnable. + * + * These tests write `/bin/sh` scripts that call `dirname` and `cat`, so the + * child still needs the standard utilities. `PATH=""` used to work by accident: + * Bun 1.3.14 ignored an empty PATH and handed the child the parent's real one. + * Bun 1.4 passes the empty value through faithfully — the correct behaviour — + * and the scripts then die with "dirname: No such file or directory". + * + * `/usr/bin:/bin` keeps the utilities reachable and contains no `codex`, which + * is the only property these tests depend on. Found during Bun 1.4 canary + * qualification (#1691). + */ +const NO_CODEX_PATH = "/usr/bin:/bin"; import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; @@ -98,7 +114,7 @@ describe("observe-only Codex catalog gather caches", () => { const previousPath = process.env.PATH; process.env.OPENCODEX_HOME = home; process.env.CODEX_CLI_PATH = launcher; - process.env.PATH = ""; + process.env.PATH = NO_CODEX_PATH; resetCodexRuntimeResolveCacheForTests(); resetBundledCatalogCacheForTests(); @@ -701,7 +717,7 @@ describe("resolveCodexRuntime", () => { const previousPath = process.env.PATH; process.env.OPENCODEX_HOME = home; process.env.CODEX_CLI_PATH = bin; - process.env.PATH = ""; + process.env.PATH = NO_CODEX_PATH; resetCodexRuntimeResolveCacheForTests(); resetBundledCatalogCacheForTests(); @@ -879,7 +895,7 @@ describe("resolveCodexRuntime", () => { const previousCli = process.env.CODEX_CLI_PATH; const previousPath = process.env.PATH; process.env.OPENCODEX_HOME = home; - process.env.PATH = ""; + process.env.PATH = NO_CODEX_PATH; resetCodexRuntimeResolveCacheForTests(); resetBundledCatalogCacheForTests(); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index c4bdbd2e7b..cf37282c80 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -47,6 +47,7 @@ import { GetBlobArgsSchema, KvServerMessageSchema, SetBlobArgsSchema, + UserMessageSchema, } from "../src/adapters/cursor/gen/agent_pb"; beforeEach(() => { @@ -125,6 +126,35 @@ function actionText(bytes: Uint8Array): string | undefined { return action?.case === "userMessageAction" ? action.value.userMessage?.text : undefined; } +/** Minimal valid 1×1 PNG for SelectedImage fixtures (not signature-only). */ +const PNG_1X1 = Uint8Array.from( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), +); + +function activeUserMessage(bytes: Uint8Array) { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const action = run?.action?.action; + return action?.case === "userMessageAction" ? action.value.userMessage : undefined; +} + +function activeSelectedImages(bytes: Uint8Array) { + return activeUserMessage(bytes)?.selectedContext?.selectedImages; +} + +function nativeTurnUserMessages(bytes: Uint8Array) { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + return (run?.conversationState?.turns ?? []).map(turnId => { + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnId)); + if (turn.turn.case !== "agentConversationTurn") return undefined; + return fromBinary(UserMessageSchema, blobData(turn.turn.value.userMessage)); + }).filter((message): message is NonNullable => message !== undefined); +} + /** The `toolName`s advertised in the top-level AgentRunRequest.mcp_tools channel (undefined when unset). */ function mcpToolNames(bytes: Uint8Array): string[] | undefined { const msg = fromBinary(AgentClientMessageSchema, bytes); @@ -140,6 +170,221 @@ describe("Cursor blob handshake", () => { expect(Array.from(id)).toEqual(Array.from(sha256(data))); }); + test("encodeCursorRunRequest attaches selectedContext with blobIdWithData image refs on the active user turn", () => { + const imageBytes = PNG_1X1; + const expectedBlobId = sha256(imageBytes); + const bytes = encodeCursorRunRequest({ + modelId: "claude-4.6-opus-high", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "see this" }], + selectedImages: [{ + data: imageBytes, + mimeType: "image/png", + uuid: "img-uuid-1", + }], + }); + + expect(actionText(bytes)).toBe("see this"); + const userMessage = activeUserMessage(bytes); + expect(userMessage?.mode).toBe(1); + expect(userMessage?.selectedContext).toBeDefined(); + const images = activeSelectedImages(bytes); + expect(images?.length).toBe(1); + expect(images?.[0]?.uuid).toBe("img-uuid-1"); + expect(images?.[0]?.mimeType).toBe("image/png"); + expect(images?.[0]?.path).toBe("attachment-img-uuid-1.png"); + expect(images?.[0]?.dataOrBlobId.case).toBe("blobIdWithData"); + const withData = images?.[0]?.dataOrBlobId.value as { blobId: Uint8Array; data: Uint8Array }; + expect(Array.from(withData.blobId)).toEqual(Array.from(expectedBlobId)); + expect(Array.from(withData.data)).toEqual(Array.from(imageBytes)); + expect(Array.from(blobData(expectedBlobId))).toEqual(Array.from(imageBytes)); + }); + + test("encodeCursorRunRequest always sends empty selectedContext and mode=1 on text-only turns", () => { + const bytes = encodeCursorRunRequest({ + modelId: "claude-4.6-opus-high", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "hi" }], + }); + + const userMessage = activeUserMessage(bytes); + expect(userMessage?.text).toBe("hi"); + expect(userMessage?.mode).toBe(1); + expect(userMessage?.selectedContext).toBeDefined(); + expect(userMessage?.selectedContext?.selectedImages.length).toBe(0); + }); + + test("encodeCursorRunRequest keeps selectedContext only on the active user turn", () => { + const activeImageBytes = PNG_1X1; + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "user", content: "active turn" }], + rawMessages: [ + { + role: "user", + content: [ + { type: "text", text: "old turn" }, + { type: "image", imageUrl: "data:image/png;base64,old", detail: "auto" }, + ], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/composer-2.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { role: "user", content: "active turn", timestamp: 3 }, + ], + selectedImages: [{ + data: activeImageBytes, + mimeType: "image/png", + uuid: "active-img", + }], + }); + + const roots = decodeRootMessages(bytes) as Array<{ role?: string; selectedContext?: unknown }>; + expect(roots.some(root => root.selectedContext !== undefined)).toBe(false); + + const historicalUser = nativeTurnUserMessages(bytes)[0]; + expect(historicalUser?.text).toBe("old turn\n[image attached]"); + expect(historicalUser?.mode).toBe(1); + expect(historicalUser?.selectedContext).toBeDefined(); + expect(historicalUser?.selectedContext?.selectedImages.length).toBe(0); + + const activeMessage = activeUserMessage(bytes); + expect(activeMessage?.mode).toBe(1); + const images = activeSelectedImages(bytes); + expect(images?.length).toBe(1); + expect(images?.[0]?.uuid).toBe("active-img"); + }); + + test("historical image-only turns replay a short text marker, not empty text", () => { + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "follow-up" }], + rawMessages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "auto" }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/composer-2.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { role: "user", content: "follow-up", timestamp: 3 }, + ], + }); + const historicalUser = nativeTurnUserMessages(bytes)[0]; + expect(historicalUser?.text).toBe("[image attached]"); + expect(historicalUser?.selectedContext?.selectedImages.length).toBe(0); + }); + + test("external root-prompt replay keeps image-only history as the text marker", () => { + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "follow-up" }], + rawMessages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "auto" }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/grok-4.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { role: "user", content: "follow-up", timestamp: 3 }, + ], + }); + const roots = decodeRootMessages(bytes) as Array<{ + role?: string; + content?: Array<{ type?: string; text?: string }>; + }>; + const rootsJson = JSON.stringify(roots); + expect(rootsJson).toContain("[image attached]"); + expect(rootsJson).not.toContain("data:image/png;base64,"); + expect(rootsJson).not.toContain("abc"); + expect(roots).toContainEqual({ + role: "user", + content: [{ type: "text", text: "[image attached]" }], + }); + }); + + test("encodeCursorRunRequest uses userMessageAction for image-only turns with selectedImages", () => { + const imageBytes = PNG_1X1; + const bytes = encodeCursorRunRequest({ + modelId: "claude-4.6-opus-high", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "" }], + rawMessages: [{ + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "auto" }], + timestamp: 1, + }], + selectedImages: [{ + data: imageBytes, + mimeType: "image/png", + uuid: "image-only", + }], + }); + + expect(activeUserMessage(bytes)).toBeDefined(); + expect(actionText(bytes)).toBe(""); + expect(activeSelectedImages(bytes)?.length).toBe(1); + }); + + test("encodeCursorRunRequest uses userMessageAction for image-only turns after assistant reply", () => { + const imageBytes = PNG_1X1; + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c1", + system: [], + messages: [ + { role: "user", content: "first" }, + { role: "assistant", content: "ack" }, + { role: "user", content: "" }, + ], + rawMessages: [ + { role: "user", content: "first", timestamp: 1 }, + { + role: "assistant", + model: "cursor/composer-2.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "auto" }], + timestamp: 3, + }, + ], + selectedImages: [{ + data: imageBytes, + mimeType: "image/png", + uuid: "follow-up-image", + }], + }); + + expect(activeUserMessage(bytes)).toBeDefined(); + expect(actionText(bytes)).toBe(""); + expect(activeSelectedImages(bytes)?.length).toBe(1); + }); + test("encodeCursorRunRequest sends rootPromptMessagesJson as blob IDs, not inline JSON", () => { const bytes = encodeCursorRunRequest({ modelId: "claude-4.6-opus-high", @@ -668,6 +913,47 @@ describe("Cursor blob handshake", () => { const run = msg.message.case === "runRequest" ? msg.message.value : undefined; expect(run?.action?.action.case).toBe("resumeAction"); + const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; + const serialized = JSON.stringify(roots); + expect(serialized).toContain("read a file"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + }); + + test("native Auto Intelligence omits assistant-role [Tool Result] root replay", () => { + const bytes = encodeCursorRunRequest({ + modelId: "auto-intelligence", + conversationId: "c-auto-intel", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto-intelligence", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + expect(run?.action?.action.case).toBe("resumeAction"); + const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; + const serialized = JSON.stringify(roots); + expect(roots.some(root => root.role === "assistant")).toBe(false); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).toContain("read a file"); + const turnIds = run?.conversationState?.turns ?? []; + expect(turnIds).toHaveLength(1); + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnIds[0]!)); + expect(turn.turn.case).toBe("agentConversationTurn"); + const steps = turn.turn.case === "agentConversationTurn" ? turn.turn.value.steps : []; + expect(steps).toHaveLength(1); + const step = fromBinary(ConversationStepSchema, blobData(steps[0]!)); + expect(step.message.case).toBe("toolCall"); }); test("drives composer-2.5 tool-result continuations as userMessageAction", () => { diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index 9c0891beda..57cfb34901 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -4,6 +4,7 @@ import { CURSOR_DEFAULT_CONTEXT_WINDOW, CURSOR_ROUTER_MODEL_IDS, CURSOR_ROUTING_LEVELS, + CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS, cursorCodexToWireModelId, cursorCheckpointModelAffinityId, @@ -22,6 +23,23 @@ import { } from "../src/adapters/cursor/discovery"; describe("Cursor discovery metadata", () => { + test("no-vision list is a curated explicit subset of the static seed", () => { + const ids = new Set(cursorModelIds(CURSOR_STATIC_MODELS)); + expect([...CURSOR_NO_VISION_MODELS]).toEqual([ + ...CURSOR_ROUTER_MODEL_IDS, + "composer-1", + "composer-2.5", + "composer-2.5-fast", + "glm-5.2", + "glm-5.3", + ]); + for (const id of CURSOR_NO_VISION_MODELS) { + expect(ids.has(id), `${id} must be in the static Cursor seed`).toBe(true); + } + for (const id of ["grok-4.5", "grok-4.5-fast", "gpt-5.5", "claude-sonnet-5", "kimi-k3", "gemini-3-pro"]) { + expect(CURSOR_NO_VISION_MODELS as readonly string[]).not.toContain(id); + } + }); test("static seed includes Cursor's public model families plus the safe auto model", () => { const ids = cursorModelIds(CURSOR_STATIC_MODELS); diff --git a/tests/cursor-eof-terminal.test.ts b/tests/cursor-eof-terminal.test.ts index a2a59b2262..18d4da50fa 100644 --- a/tests/cursor-eof-terminal.test.ts +++ b/tests/cursor-eof-terminal.test.ts @@ -3,6 +3,7 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { describe, expect, test } from "bun:test"; import { AgentServerMessageSchema, + ExecServerMessageSchema, InteractionUpdateSchema, McpArgsSchema, McpToolCallSchema, @@ -63,6 +64,29 @@ function toolCallStartedFrame(callId: string, toolName: string): Uint8Array { return encodeConnectFrame(toBinary(AgentServerMessageSchema, message)); } +function clientToolArgsFrame(callId: string, toolName: string, argText: string): Uint8Array { + const message = create(AgentServerMessageSchema, { + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 1, + execId: `exec-${callId}`, + message: { + case: "mcpArgs", + value: create(McpArgsSchema, { + name: toolName, + toolName, + toolCallId: callId, + providerIdentifier: PROVIDER, + args: { text: new TextEncoder().encode(JSON.stringify(argText)) }, + }), + }, + }), + }, + }); + return encodeConnectFrame(toBinary(AgentServerMessageSchema, message)); +} + function turnEndedFrame(): Uint8Array { const message = create(AgentServerMessageSchema, { message: { @@ -79,6 +103,10 @@ function emptyFrame(): Uint8Array { return encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, {}))); } +function cleanConnectEndFrame(): Uint8Array { + return encodeConnectFrame(new TextEncoder().encode("{}"), { endStream: true }); +} + function runRequest(tools?: CursorRunRequest["tools"]): CursorRunRequest { return { modelId: "composer-2", @@ -96,6 +124,17 @@ const APPLY_PATCH_TOOL = [{ freeform: true, }] as unknown as CursorRunRequest["tools"]; +const ECHO_TOOL = [{ + name: "echo_a", + description: "echo text", + parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, +}] as unknown as CursorRunRequest["tools"]; + +const ECHO_AND_APPLY_PATCH_TOOLS = [ + ...(ECHO_TOOL ?? []), + ...(APPLY_PATCH_TOOL ?? []), +] as CursorRunRequest["tools"]; + async function drain(baseUrl: string, request: CursorRunRequest): Promise<{ messages: CursorServerMessage[]; failure?: Error; @@ -153,6 +192,80 @@ describe("Cursor clean-EOF terminal gate", () => { }); }); + test("clean Connect END_STREAM finishes before a held-open HTTP body (#2300)", async () => { + let fallback: ReturnType | undefined; + const startedAt = Date.now(); + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(turnEndedFrame())); + stream.write(Buffer.from(cleanConnectEndFrame())); + // Model Cursor's observed shape: the protocol has ended, but the HTTP body has not. The + // fallback keeps the pre-fix test bounded; correct code returns well before it fires. + fallback = setTimeout(() => { + try { stream.end(); } catch { /* transport already closed */ } + }, 500); + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest()); + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + }); + if (fallback) clearTimeout(fallback); + expect(Date.now() - startedAt).toBeLessThan(450); + }); + + test("clean Connect END_STREAM wins over an immediate abort-shaped body teardown (#2300)", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(turnEndedFrame())); + stream.write(Buffer.from(cleanConnectEndFrame())); + setImmediate(() => { + const abort = new Error("The operation was aborted"); + abort.name = "AbortError"; + stream.destroy(abort); + }); + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest()); + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + expect(messages.some(message => message.type === "error")).toBe(false); + }); + }); + + test("clean Connect END_STREAM preserves a drained client-tool terminal before its grace timer", async () => { + await withH2Server(respondWith([ + toolCallStartedFrame("call_client_1", "echo_a"), + clientToolArgsFrame("call_client_1", "echo_a", "A"), + cleanConnectEndFrame(), + ]), async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest(ECHO_TOOL)); + + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "tool_call_end")).toHaveLength(1); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + expect(messages.some(message => message.type === "error")).toBe(false); + }); + }); + + test("clean Connect END_STREAM keeps a later open sibling fail-closed after a client-tool drain", async () => { + await withH2Server(respondWith([ + toolCallStartedFrame("call_client_2", "echo_a"), + clientToolArgsFrame("call_client_2", "echo_a", "A"), + toolCallStartedFrame("call_open_2", "apply_patch"), + cleanConnectEndFrame(), + ]), async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest(ECHO_AND_APPLY_PATCH_TOOLS)); + + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "tool_call_end")).toHaveLength(1); + const terminal = messages.at(-1); + expect(terminal?.type).toBe("error"); + expect((terminal as { message?: string }).message).toContain("call_open_2"); + expect(messages.some(message => message.type === "done")).toBe(false); + }); + }); + test("EOF with no open tool call keeps its existing graceful finish", async () => { await withH2Server(respondWith([emptyFrame()]), async baseUrl => { const { failure } = await drain(baseUrl, runRequest()); diff --git a/tests/cursor-errors.test.ts b/tests/cursor-errors.test.ts index 80e5310f81..59a7b95399 100644 --- a/tests/cursor-errors.test.ts +++ b/tests/cursor-errors.test.ts @@ -12,9 +12,7 @@ describe("classifyCursorError", () => { expect(classifyCursorError("rate limit exceeded for model")).toBe("Cursor rate limit exceeded"); }); - test("generic resource_exhausted is quota-style rate limiting, not a too-large request", () => { - // The live retry-storm shape: no detail beyond "Error" — must map to 429 so Codex backs off. - expect(classifyCursorError("Cursor Connect error resource_exhausted: Error")).toBe("Cursor rate limit exceeded"); + test("explicit quota-cue resource_exhausted is rate limiting; bare overflow is context limit (T01)", () => { expect(classifyCursorError("resource_exhausted: too many requests")).toBe("Cursor rate limit exceeded"); expect(classifyCursorError("resource_exhausted while loading tool catalog: quota exhausted")).toBe("Cursor rate limit exceeded"); // Concurrency limits are quota shapes, not request-size overflow (a bare "limit" @@ -32,6 +30,20 @@ describe("classifyCursorError", () => { expect(classifyCursorError("resource_exhausted: request size exceeds maximum allowed limit")).toBe("Cursor resource limit exceeded"); }); + test("bare resource_exhausted with no quota cue and no size phrase is payload overflow (T01)", () => { + // senpi #1009 / #1036: a huge session hits the context window and Cursor returns a bare + // gRPC resource_exhausted end-stream with no detail. Classifying it as 429 makes Codex + // back off instead of compacting, which burns retries on an unfixable-by-retry failure. + expect(classifyCursorError("Cursor Connect error resource_exhausted: Error")).toBe("Cursor context limit exceeded"); + expect(classifyCursorError("resource_exhausted")).toBe("Cursor context limit exceeded"); + expect(classifyCursorError("resource exhausted")).toBe("Cursor context limit exceeded"); + }); + + test("explicit quota wording still maps to rate limit even without a size phrase", () => { + expect(classifyCursorError("resource_exhausted: too many requests for this model")).toBe("Cursor rate limit exceeded"); + expect(classifyCursorError("resource_exhausted while loading tool catalog: quota exhausted")).toBe("Cursor rate limit exceeded"); + }); + test("authentication / permission denied", () => { expect(classifyCursorError("unauthenticated: invalid bearer token")).toBe("Cursor authentication failed"); expect(classifyCursorError("permission_denied: account suspended")).toBe("Cursor authentication failed"); @@ -102,9 +114,10 @@ describe("safeCursorErrorMessage", () => { expect(msg).not.toContain("rate limit"); }); - test("end-to-end: quota-style resource exhaustion carries the rate-limit prefix", () => { + test("end-to-end: bare resource_exhausted carries the overflow prefix; explicit quota carries the rate-limit prefix", () => { + // Bare resource_exhausted is payload overflow (T01): the 400-class prefix lets Codex compact. expect(safeCursorErrorMessage("Cursor Connect error resource_exhausted: Error")) - .toContain("Cursor rate limit exceeded"); + .toContain("Cursor context limit exceeded"); expect(safeCursorErrorMessage("resource_exhausted: too many requests")) .toContain("Cursor rate limit exceeded"); expect(safeCursorErrorMessage("resource_exhausted while loading tool catalog: quota exhausted")) @@ -123,3 +136,34 @@ describe("isCursorInvalidArgumentError", () => { expect(isCursorInvalidArgumentError(new Error("Cursor connection failed"))).toBe(false); }); }); + +describe("bare resource_exhausted size prior (devlog 260)", () => { + const BARE = "Cursor Connect error resource_exhausted: Error"; + + test("a provably small request keeps the 429 class (plan-gated model, live probe 210)", () => { + expect(classifyCursorError(BARE, { estimatedInputTokens: 20, contextWindow: 200_000 })) + .toBe("Cursor rate limit exceeded"); + }); + + test("a plausibly large request still classifies as context overflow", () => { + expect(classifyCursorError(BARE, { estimatedInputTokens: 150_000, contextWindow: 200_000 })) + .toBe("Cursor context limit exceeded"); + }); + + test("unknown estimate or window keeps today's overflow mapping (prior only removes provable false overflows)", () => { + expect(classifyCursorError(BARE)).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, {})).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, { estimatedInputTokens: 20 })).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, { contextWindow: 200_000 })).toBe("Cursor context limit exceeded"); + }); + + test("explicit quota cues stay 429 regardless of size context", () => { + expect(classifyCursorError("resource_exhausted: quota exhausted", { estimatedInputTokens: 150_000, contextWindow: 200_000 })) + .toBe("Cursor rate limit exceeded"); + }); + + test("explicit size phrases stay resource-limit regardless of size context", () => { + expect(classifyCursorError("resource_exhausted: request body exceeds maximum allowed size", { estimatedInputTokens: 20, contextWindow: 200_000 })) + .toBe("Cursor resource limit exceeded"); + }); +}); diff --git a/tests/cursor-h2-pool-shutdown.test.ts b/tests/cursor-h2-pool-shutdown.test.ts new file mode 100644 index 0000000000..f832206f80 --- /dev/null +++ b/tests/cursor-h2-pool-shutdown.test.ts @@ -0,0 +1,62 @@ +import http2 from "node:http2"; +import { afterEach, describe, expect, test } from "bun:test"; +import { CursorH2SessionPool } from "../src/adapters/cursor/h2-pool"; +import { + resetOptionalShutdownHooksForTests, + runOptionalShutdownHooks, +} from "../src/lib/optional-shutdown-hooks"; + +afterEach(() => { + resetOptionalShutdownHooksForTests(); +}); + +async function withH2Server(run: (baseUrl: string) => Promise): Promise { + const server = http2.createServer(); + server.on("stream", stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200 }); + // hold the stream open; shutdown must not depend on server cooperation + }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("HTTP/2 fixture did not bind a TCP port"); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + +describe("CursorH2SessionPool shutdown hook (devlog 120b)", () => { + test("first request() registers a shutdown hook that closes pooled sessions", async () => { + await withH2Server(async baseUrl => { + const pool = new CursorH2SessionPool(); + const stream = pool.request(baseUrl, { ":method": "POST", ":path": "/x" }); + expect(pool.size).toBe(1); + runOptionalShutdownHooks(); + // shutdown() is fire-and-forget in the sync seam; give it a beat to settle. + await new Promise(resolve => setTimeout(resolve, 100)); + expect(pool.size).toBe(0); + expect(() => pool.request(baseUrl, { ":method": "POST", ":path": "/x" })).toThrow(/closed/); + stream.destroy(); + }); + }); + + test("running the hooks twice is safe (idempotent shutdown)", async () => { + await withH2Server(async baseUrl => { + const pool = new CursorH2SessionPool(); + pool.request(baseUrl, { ":method": "POST", ":path": "/x" }).destroy(); + runOptionalShutdownHooks(); + runOptionalShutdownHooks(); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(pool.size).toBe(0); + }); + }); +}); diff --git a/tests/cursor-http1-transport.test.ts b/tests/cursor-http1-transport.test.ts index 10ebd6c422..e995e447d8 100644 --- a/tests/cursor-http1-transport.test.ts +++ b/tests/cursor-http1-transport.test.ts @@ -284,9 +284,17 @@ describe("Cursor HTTP/1.1 compatibility transport", () => { }) as typeof fetch; const pending = runHttp1Turn(fetchImpl, "cursor_http1_ready_order"); - await Promise.resolve(); - await Promise.resolve(); - expect(paths).toEqual(["/agent.v1.AgentService/RunSSE"]); + const runSsePath = "/agent.v1.AgentService/RunSSE"; + const bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"; + const deadline = Date.now() + 2_000; + while (!paths.includes(runSsePath)) { + expect(paths).not.toContain(bidiAppendPath); + if (Date.now() >= deadline) { + throw new Error("timed out waiting for the RunSSE fetch before BidiAppend"); + } + await Bun.sleep(1); + } + expect(paths).toEqual([runSsePath]); releaseRunSse(new Response(new ReadableStream({ start(controller) { runController = controller; }, diff --git a/tests/cursor-images.test.ts b/tests/cursor-images.test.ts new file mode 100644 index 0000000000..5083951562 --- /dev/null +++ b/tests/cursor-images.test.ts @@ -0,0 +1,708 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { + CursorImageError, + CURSOR_VISION_IMAGE_OMITTED, + CURSOR_VISION_SOFT_MAX_BYTES, + CURSOR_VISION_SOFT_MAX_BYTES_HIGH, + MAX_CURSOR_IMAGE_BYTES, + MAX_CURSOR_IMAGE_DECODE_BYTES, + MAX_CURSOR_IMAGE_DECODE_EDGE, + MAX_CURSOR_IMAGE_PIXELS, + MAX_CURSOR_IMAGES, + buildSelectedImages, + cursorVisionPrepareStartIndex, + decodeCursorImageDataUrl, + prepareCursorImageForWire, + prepareCursorRawMessages, + resolveActiveCursorImages, + resolveCursorImages, + sniffCursorImageDimensions, + sniffCursorImageFormat, +} from "../src/adapters/cursor/images"; +import { + handleCursorNativeKv, + resetCursorBlobStateForTests, +} from "../src/adapters/cursor/native-exec"; +import { cursorRequestMessagesFromRaw } from "../src/adapters/cursor/request-builder"; +import { activePromptText, encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { + AgentClientMessageSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../src/adapters/cursor/gen/agent_pb"; + +/** Minimal valid 1×1 PNG (real IHDR; not a signature-only stub). */ +const PNG_BYTES = Uint8Array.from( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), +); +const PNG_DATA_URL = `data:image/png;base64,${Buffer.from(PNG_BYTES).toString("base64")}`; + +async function oversizedDecodablePng(): Promise { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const src = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + return new Uint8Array(await new Bun.Image(src).resize(2400, 2400).png().bytes()); +} + +describe("Cursor image resolver", () => { + test("rejects more than MAX_CURSOR_IMAGES in one request", async () => { + const urls = Array.from({ length: MAX_CURSOR_IMAGES + 1 }, () => PNG_DATA_URL); + await expect(resolveCursorImages(urls)).rejects.toMatchObject({ + name: "CursorImageError", + message: `Too many images in one request (max ${MAX_CURSOR_IMAGES}).`, + }); + await expect(prepareCursorRawMessages([{ + role: "user", + content: urls.map(imageUrl => ({ type: "image" as const, imageUrl })), + timestamp: 1, + }])).rejects.toMatchObject({ + name: "CursorImageError", + message: `Too many images in one request (max ${MAX_CURSOR_IMAGES}).`, + }); + }); + + test("omits data URLs above the inbound decode bomb ceiling", async () => { + const oversizedLength = Math.ceil(Math.ceil((MAX_CURSOR_IMAGE_DECODE_BYTES + 1) * 4 / 3) / 4) * 4; + const url = `data:image/png;base64,${"A".repeat(oversizedLength)}`; + // Pin the guard itself: the resolver soft-omits every failure reason identically. + expect(() => decodeCursorImageDataUrl(url)).toThrow("Image input is too large to process safely."); + // Soft-omit: one bad URL must not abort a mixed turn. + const resolved = await resolveCursorImages([url]); + expect(resolved).toEqual([]); + }); + + test("prep-before-cap accepts PNG over 1 MiB that JPEG-encodes under the soft and wire caps", async () => { + const png = await oversizedDecodablePng(); + expect(png.byteLength).toBeGreaterThan(MAX_CURSOR_IMAGE_BYTES); + const url = `data:image/png;base64,${Buffer.from(png).toString("base64")}`; + const resolved = await resolveCursorImages([url]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.mimeType).toBe("image/jpeg"); + expect(resolved[0]!.data.byteLength).toBeLessThan(png.byteLength); + expect(resolved[0]!.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(resolved[0]!.data.byteLength).toBeLessThanOrEqual(MAX_CURSOR_IMAGE_BYTES); + }); + + test("omits undecodable payloads under the decode ceiling instead of sending them", async () => { + // Length is a multiple of four, so alphabet/padding checks pass and Bun decode fails. + const junkLength = Math.ceil(Math.ceil((MAX_CURSOR_IMAGE_BYTES + 1) * 4 / 3) / 4) * 4; + const resolved = await resolveCursorImages([`data:image/png;base64,${"A".repeat(junkLength)}`]); + expect(resolved).toEqual([]); + }); + + test("decodes valid base64 data URLs through JPEG prep", async () => { + const resolved = await resolveCursorImages([PNG_DATA_URL]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.mimeType).toBe("image/jpeg"); + expect(resolved[0]!.data.byteLength).toBeGreaterThan(0); + expect(resolved[0]!.data[0]).toBe(0xff); + expect(resolved[0]!.data[1]).toBe(0xd8); + expect(resolved[0]?.uuid.length).toBeGreaterThan(0); + }); + + test("soft-omits malformed and non-image data URLs", async () => { + expect(await resolveCursorImages(["data:image/png,not-base64"])).toEqual([]); + expect(await resolveCursorImages(["data:text/plain;base64,YQ=="])).toEqual([]); + expect(await resolveCursorImages(["data:image/png;base64"])).toEqual([]); + expect(await resolveCursorImages(["data:image/png;base64,"])).toEqual([]); + }); + + test("omits remote URLs — this slice is data: only", async () => { + expect(await resolveCursorImages(["http://example.com/image.png"])).toEqual([]); + expect(await resolveCursorImages(["https://example.com/image.png"])).toEqual([]); + }); + + test("resolveActiveCursorImages selects the last user turn and ignores earlier images", async () => { + const resolved = await resolveActiveCursorImages([ + { + role: "user", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/auto", + content: [{ type: "text", text: "seen" }], + timestamp: 2, + }, + { + role: "user", + content: [ + { type: "text", text: "active" }, + { type: "image", imageUrl: PNG_DATA_URL }, + ], + timestamp: 3, + }, + ]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.mimeType).toBe("image/jpeg"); + }); + + test("resolveActiveCursorImages supports developer turns", async () => { + const resolved = await resolveActiveCursorImages([ + { + role: "developer", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + timestamp: 1, + }, + ]); + expect(resolved).toHaveLength(1); + }); + + test("resolveActiveCursorImages returns empty for text-only trailing toolResult", async () => { + const resolved = await resolveActiveCursorImages([ + { + role: "user", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "read_file", + content: "done", + isError: false, + timestamp: 2, + }, + ]); + expect(resolved).toEqual([]); + }); + + test("user message after toolResult does not promote stale tool images", async () => { + const resolved = await resolveActiveCursorImages([ + { role: "user", content: "first", timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_view", + toolName: "view_image", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + isError: false, + timestamp: 2, + }, + { role: "user", content: "new question without an image", timestamp: 3 }, + ]); + expect(resolved).toEqual([]); + }); + + test("CursorImageError carries HTTP status for callers", () => { + const error = new CursorImageError("blocked", 403); + expect(error.status).toBe(403); + expect(error.name).toBe("CursorImageError"); + }); + + test("buildSelectedImages uses blobIdWithData + attachment path and keeps KV hydrated", () => { + resetCursorBlobStateForTests(); + // Minimal PNG signature + IHDR claiming 2x3 (not Bun-decodable — stays PNG) + const png = Uint8Array.from([ + 137, 80, 78, 71, 13, 10, 26, 10, + 0, 0, 0, 13, 73, 72, 68, 82, + 0, 0, 0, 2, 0, 0, 0, 3, + 8, 2, 0, 0, 0, 0, 0, 0, 0, + ]); + expect(sniffCursorImageDimensions(png)).toEqual({ width: 2, height: 3 }); + + // Standalone RST0 before SOF0 must not be parsed as a length-bearing segment. + const jpegWithRst = Uint8Array.from([ + 0xff, 0xd8, // SOI + 0xff, 0xd0, // RST0 (no length) + 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x03, 0x00, 0x02, 0x03, 0x01, 0x11, 0x00, // SOF0 2x3 + ]); + expect(sniffCursorImageDimensions(jpegWithRst)).toEqual({ width: 2, height: 3 }); + + // Extended-sequential SOF1 (0xC1) shares the same dimension layout as SOF0. + const jpegSof1 = Uint8Array.from([ + 0xff, 0xd8, + 0xff, 0xc1, 0x00, 0x0b, 0x08, 0x00, 0x03, 0x00, 0x02, 0x03, 0x01, 0x11, 0x00, + ]); + expect(sniffCursorImageDimensions(jpegSof1)).toEqual({ width: 2, height: 3 }); + + const [selected] = buildSelectedImages([{ + data: png, + mimeType: "image/png", + uuid: "u-dim", + }]); + expect(selected?.dataOrBlobId.case).toBe("blobIdWithData"); + expect(selected?.path).toBe("attachment-u-dim.png"); + expect(selected?.dimension?.width).toBe(2); + expect(selected?.dimension?.height).toBe(3); + const withData = selected!.dataOrBlobId.value as { blobId: Uint8Array; data: Uint8Array }; + expect(Array.from(withData.blobId)).toEqual(Array.from(createHash("sha256").update(png).digest())); + expect(Array.from(withData.data)).toEqual(Array.from(png)); + + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId: withData.blobId }) }, + }))); + const kv = reply.message.case === "kvClientMessage" ? reply.message.value : undefined; + const data = kv?.message.case === "getBlobResult" ? kv.message.value.blobData : undefined; + expect(Array.from(data ?? [])).toEqual(Array.from(png)); + }); + + test("prepareCursorImageForWire re-encodes large PNG as JPEG under the soft cap", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + expect(png.byteLength).toBeGreaterThan(CURSOR_VISION_SOFT_MAX_BYTES); + + const prepared = await prepareCursorImageForWire({ + data: png, + mimeType: "image/png", + uuid: "big-png", + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready"); + expect(prepared.image.mimeType).toBe("image/jpeg"); + expect(prepared.image.data.byteLength).toBeLessThan(png.byteLength); + expect(prepared.image.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(prepared.image.data[0]).toBe(0xff); + expect(prepared.image.data[1]).toBe(0xd8); + }); + + test("detail original/high uses a higher soft tier than auto", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + const auto = await prepareCursorImageForWire({ + data: png, + mimeType: "image/png", + uuid: "auto", + detail: "auto", + }); + const original = await prepareCursorImageForWire({ + data: png, + mimeType: "image/png", + uuid: "original", + detail: "original", + }); + expect(auto.status).toBe("ready"); + expect(original.status).toBe("ready"); + if (auto.status !== "ready" || original.status !== "ready") throw new Error("expected ready"); + expect(original.image.data.byteLength).toBeGreaterThan(auto.image.data.byteLength); + expect(auto.image.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(original.image.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES_HIGH); + expect(original.image.data.byteLength).toBeLessThanOrEqual(MAX_CURSOR_IMAGE_BYTES); + }); + + test("exotic MIME and corrupt PNG fail closed", async () => { + const bmp = await prepareCursorImageForWire({ + data: new Uint8Array([0x42, 0x4d, 0, 0, 0, 0]), + mimeType: "image/bmp", + uuid: "bmp", + }); + expect(bmp).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + + const corrupt = await prepareCursorImageForWire({ + data: new Uint8Array(128).fill(0x41), + mimeType: "image/png", + uuid: "corrupt", + }); + expect(corrupt).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + + // Soft-cap-sized labeled JPEG must still decode; junk under the soft max is omitted. + const fakeJpeg = await prepareCursorImageForWire({ + data: new Uint8Array(128).fill(0xff), + mimeType: "image/jpeg", + uuid: "fake-jpeg", + }); + expect(fakeJpeg).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("prepareCursorRawMessages JPEG-preps active-turn user data URLs", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + const imageUrl = `data:image/png;base64,${Buffer.from(png).toString("base64")}`; + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image", imageUrl, detail: "auto" }, + ], + timestamp: 1, + }, + ]); + const user = prepared.messages?.[0]; + expect(user?.role).toBe("user"); + if (user?.role !== "user" || typeof user.content === "string") throw new Error("expected image parts"); + const part = user.content.find(item => item.type === "image"); + expect(part?.type).toBe("image"); + if (part?.type !== "image") throw new Error("expected image"); + expect(part.imageUrl.startsWith("data:image/jpeg;base64,")).toBe(true); + const payload = part.imageUrl.slice(part.imageUrl.indexOf(",") + 1); + const bytes = Buffer.from(payload, "base64"); + expect(bytes.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(bytes[0]).toBe(0xff); + expect(bytes[1]).toBe(0xd8); + }); + + test("prepareCursorRawMessages replaces exotic images with omission text", async () => { + const bmpUrl = `data:image/bmp;base64,${Buffer.from([0x42, 0x4d, 0, 0]).toString("base64")}`; + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: bmpUrl }], + timestamp: 1, + }, + ]); + const user = prepared.messages?.[0]; + expect(user?.role).toBe("user"); + if (user?.role !== "user" || typeof user.content === "string") throw new Error("expected parts"); + expect(user.content).toEqual([{ type: "text", text: CURSOR_VISION_IMAGE_OMITTED }]); + }); + + test("cursorRequestMessagesFromRaw surfaces omission text after prepare", async () => { + const bmpUrl = `data:image/bmp;base64,${Buffer.from([0x42, 0x4d, 0, 0]).toString("base64")}`; + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: bmpUrl }], + timestamp: 1, + }, + ]); + const raw = prepared.messages; + const messages = cursorRequestMessagesFromRaw(raw); + expect(messages).toEqual([{ role: "user", content: CURSOR_VISION_IMAGE_OMITTED }]); + expect(activePromptText({ + modelId: "grok-4.5", + conversationId: "cursor_test", + system: [], + messages, + rawMessages: raw, + })).toBe(CURSOR_VISION_IMAGE_OMITTED); + }); + + test("live-transport image phase: prepare rawMessages then resolve SelectedImage", async () => { + // Mirrors live-transport.ts: prepareCursorRawMessages → resolveActiveCursorImages. + const rawIn = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "What is in this image?" }, + { type: "image" as const, imageUrl: PNG_DATA_URL, detail: "high" }, + ], + timestamp: 1, + }, + ]; + const prepared = await prepareCursorRawMessages(rawIn); + const rawMessages = prepared.messages; + const messages = cursorRequestMessagesFromRaw(rawMessages); + const selectedImages = await resolveActiveCursorImages(rawMessages, undefined, prepared.images); + expect(selectedImages).toHaveLength(1); + + resetCursorBlobStateForTests(); + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c-wire", + system: ["You are helpful."], + messages, + rawMessages, + selectedImages, + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + expect(run?.action?.action.case).toBe("userMessageAction"); + if (run?.action?.action.case !== "userMessageAction") throw new Error("expected userMessageAction"); + expect(run.action.action.value.userMessage?.text).toContain("What is in this image?"); + expect(run.action.action.value.userMessage?.selectedContext?.selectedImages.length).toBe(1); + }); + + test("resolveActiveCursorImages reuses prepared bytes instead of re-encoding", async () => { + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", imageUrl: PNG_DATA_URL, detail: "high" }, + ], + timestamp: 1, + }, + ]); + expect(prepared.images).toHaveLength(1); + const selectedImages = await resolveActiveCursorImages( + prepared.messages, + undefined, + prepared.images, + ); + expect(selectedImages).toHaveLength(1); + expect(selectedImages[0]).toBe(prepared.images[0]); + expect(selectedImages[0]?.data).toBe(prepared.images[0]?.data); + }); + + test("image-only remote soft-omit yields userMessageAction with omission text", async () => { + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: "https://example.com/missing.png" }], + timestamp: 1, + }, + ]); + const raw = prepared.messages; + const messages = cursorRequestMessagesFromRaw(raw); + expect(messages).toEqual([{ role: "user", content: CURSOR_VISION_IMAGE_OMITTED }]); + const selectedImages = await resolveActiveCursorImages(raw, undefined, prepared.images); + expect(selectedImages).toEqual([]); + resetCursorBlobStateForTests(); + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c-https-omit", + system: [], + messages, + rawMessages: raw, + selectedImages, + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + expect(run?.action?.action.case).toBe("userMessageAction"); + expect(actionTextFrom(bytes)).toBe(CURSOR_VISION_IMAGE_OMITTED); + }); + + test("remote image with valid text continues text-only", async () => { + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [ + { type: "text", text: "what color is the sky?" }, + { type: "image", imageUrl: "https://example.com/missing.png" }, + ], + timestamp: 1, + }, + ]); + const raw = prepared.messages; + const messages = cursorRequestMessagesFromRaw(raw); + expect(typeof messages[0]?.content).toBe("string"); + expect(messages[0]?.content).toContain("what color is the sky?"); + expect(messages[0]?.content).toContain(CURSOR_VISION_IMAGE_OMITTED); + expect(await resolveActiveCursorImages(raw, undefined, prepared.images)).toEqual([]); + }); + + test("strict base64 rejects truncated and wrong-alphabet payloads", () => { + expect(() => decodeCursorImageDataUrl("data:image/png;base64,iVBOR")).toThrow(CursorImageError); + expect(() => decodeCursorImageDataUrl("data:image/png;base64,!!!!")).toThrow(CursorImageError); + // Signature-only 8-byte stub is valid base64 but must not bypass prepare (no ≤64 passthrough). + const stubUrl = `data:image/png;base64,${Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64")}`; + const decoded = decodeCursorImageDataUrl(stubUrl); + expect(decoded.data.byteLength).toBe(8); + }); + + test("signature-only PNG stub is omitted by prepare (no ≤64 bypass)", async () => { + const outcome = await prepareCursorImageForWire({ + data: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + mimeType: "image/png", + uuid: "stub", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("oversize sniffed dimensions omit without Bun decode bomb", async () => { + // PNG IHDR with absurd width/height; sniff rejects before Bun.Image. + const ihdr = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x01, 0x00, 0x00, // width 65536 + 0x00, 0x01, 0x00, 0x00, // height 65536 + ]); + expect(sniffCursorImageDimensions(ihdr)).toEqual({ width: 65536, height: 65536 }); + expect(65536).toBeGreaterThan(MAX_CURSOR_IMAGE_DECODE_EDGE); + expect(65536 * 65536).toBeGreaterThan(MAX_CURSOR_IMAGE_PIXELS); + const outcome = await prepareCursorImageForWire({ + data: ihdr, + mimeType: "image/png", + uuid: "huge", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("truncated FF D8 JPEG under soft cap is omitted (no SOI-only fast path)", async () => { + const truncated = new Uint8Array([0xff, 0xd8, 0x00, 0x00]); + expect(sniffCursorImageFormat(truncated)).toBe("jpeg"); + expect(sniffCursorImageDimensions(truncated)).toBeUndefined(); + const outcome = await prepareCursorImageForWire({ + data: truncated, + mimeType: "image/jpeg", + uuid: "soi-only", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("truncated JPEG with a valid SOF is omitted (no header-only passthrough)", async () => { + // SOI + SOF0 claiming 2x3, then EOF. Sniff succeeds; Bun.Image must still reject it. + const sofOnly = Uint8Array.from([ + 0xff, 0xd8, + 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x03, 0x00, 0x02, 0x03, 0x01, 0x11, 0x00, + ]); + expect(sniffCursorImageFormat(sofOnly)).toBe("jpeg"); + expect(sniffCursorImageDimensions(sofOnly)).toEqual({ width: 2, height: 3 }); + const outcome = await prepareCursorImageForWire({ + data: sofOnly, + mimeType: "image/jpeg", + uuid: "sof-only", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("PNG bytes labeled image/jpeg are re-encoded as JPEG, not passthrough", async () => { + const outcome = await prepareCursorImageForWire({ + data: PNG_BYTES, + mimeType: "image/jpeg", + uuid: "mislabeled", + }); + expect(outcome.status).toBe("ready"); + if (outcome.status !== "ready") throw new Error("expected ready"); + expect(outcome.image.mimeType).toBe("image/jpeg"); + expect(outcome.image.data[0]).toBe(0xff); + expect(outcome.image.data[1]).toBe(0xd8); + expect(sniffCursorImageFormat(outcome.image.data)).toBe("jpeg"); + }); + + test("oversized WebP VP8X header omits before Bun decode", async () => { + // RIFF....WEBP + VP8X with canvas size 65536x65536 (stored as size-1). + const webp = new Uint8Array(30); + webp.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF + webp.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + webp.set([0x56, 0x50, 0x38, 0x58], 12); // VP8X + // width-1 / height-1 as 24-bit LE at 24..29 → 65535 → displayed 65536 + webp[24] = 0xff; + webp[25] = 0xff; + webp[26] = 0x00; + webp[27] = 0xff; + webp[28] = 0xff; + webp[29] = 0x00; + expect(sniffCursorImageFormat(webp)).toBe("webp"); + expect(sniffCursorImageDimensions(webp)).toEqual({ width: 65536, height: 65536 }); + const outcome = await prepareCursorImageForWire({ + data: webp, + mimeType: "image/webp", + uuid: "huge-webp", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("truncated PNG IHDR omits before Bun decode (no trusted dimensions)", async () => { + const truncated = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x02, + ]); + expect(sniffCursorImageFormat(truncated)).toBe("png"); + expect(sniffCursorImageDimensions(truncated)).toBeUndefined(); + const outcome = await prepareCursorImageForWire({ + data: truncated, + mimeType: "image/png", + uuid: "truncated-png", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("truncated GIF header omits before Bun decode (no trusted dimensions)", async () => { + const truncated = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x02, 0x00]); + expect(sniffCursorImageFormat(truncated)).toBe("gif"); + expect(sniffCursorImageDimensions(truncated)).toBeUndefined(); + const outcome = await prepareCursorImageForWire({ + data: truncated, + mimeType: "image/gif", + uuid: "truncated-gif", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("unsupported WebP chunk omits before Bun decode (no trusted dimensions)", async () => { + const webp = new Uint8Array(16); + webp.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF + webp.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + webp.set([0x41, 0x4e, 0x49, 0x4d], 12); // ANIM — not a dimension-bearing chunk + expect(sniffCursorImageFormat(webp)).toBe("webp"); + expect(sniffCursorImageDimensions(webp)).toBeUndefined(); + const outcome = await prepareCursorImageForWire({ + data: webp, + mimeType: "image/webp", + uuid: "unsupported-webp", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("prepareCursorImageForWire never returns ready JPEG above the detail soft cap", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + const oversized = await oversizedDecodablePng(); + for (const [label, input, detail, softMax] of [ + ["auto grumpy", png, "auto", CURSOR_VISION_SOFT_MAX_BYTES], + ["high grumpy", png, "high", CURSOR_VISION_SOFT_MAX_BYTES_HIGH], + ["auto oversized", oversized, "auto", CURSOR_VISION_SOFT_MAX_BYTES], + ] as const) { + const outcome = await prepareCursorImageForWire({ + data: input, + mimeType: "image/png", + uuid: label, + detail, + }); + expect(outcome.status, label).toBe("ready"); + if (outcome.status !== "ready") throw new Error(`expected ready for ${label}`); + expect(outcome.image.data.byteLength, label).toBeLessThanOrEqual(softMax); + } + }); + + test("omits when shrink ladder best JPEG still exceeds soft cap", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + const outcome = await prepareCursorImageForWire({ + data: png, + mimeType: "image/png", + uuid: "soft-cap-miss", + }, undefined, { softMaxBytes: 5_000 }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("prepareCursorRawMessages leaves historical images untouched on a later user turn", async () => { + const oldUrl = `data:image/png;base64,${Buffer.from([...PNG_BYTES, 1]).toString("base64")}`; + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: oldUrl }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/grok-4.5", + content: [{ type: "text", text: "seen" }], + timestamp: 2, + }, + { role: "user", content: "thanks, no image", timestamp: 3 }, + ]); + expect(prepared.messages?.[0]).toEqual({ + role: "user", + content: [{ type: "image", imageUrl: oldUrl }], + timestamp: 1, + }); + expect(cursorVisionPrepareStartIndex(prepared.messages ?? [])).toBe(2); + }); + + test("aborted image-phase signal stops further local prepare work", async () => { + const controller = new AbortController(); + controller.abort(); + await expect(prepareCursorImageForWire({ + data: PNG_BYTES, + mimeType: "image/png", + uuid: "aborted", + }, controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + + await expect(prepareCursorRawMessages([ + { + role: "user", + content: [ + { type: "image", imageUrl: PNG_DATA_URL }, + { type: "image", imageUrl: PNG_DATA_URL }, + ], + timestamp: 1, + }, + ], controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +function actionTextFrom(bytes: Uint8Array): string | undefined { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const action = run?.action?.action; + return action?.case === "userMessageAction" ? action.value.userMessage?.text : undefined; +} diff --git a/tests/cursor-native-exec.test.ts b/tests/cursor-native-exec.test.ts index e3365ce040..be1ac99c47 100644 --- a/tests/cursor-native-exec.test.ts +++ b/tests/cursor-native-exec.test.ts @@ -253,12 +253,39 @@ describe("Cursor native exec bridge", () => { } }); - test("unknown exec cases return empty reply instead of throwing (#116 hardening)", async () => { + test("unknown exec cases reply with ExecClientThrow + streamClose instead of silence (T05)", async () => { const result = await handleCursorNativeExec(execMessage({ case: undefined, value: undefined, })); - expect(result).toEqual([]); + // T05 (senpi contract): a frame that cannot be answered gets a typed in-band error + // + stream-close so the server unblocks with a known failure. #116 was about an + // unhandled throw propagating to failAndClear and killing the whole gRPC connection; + // a typed ExecClientThrow does not do that. + expect(result).toHaveLength(2); + + // Control messages use a different top-level case; decode them directly from the wire. + const throwMsg = fromBinary(AgentClientMessageSchema, result[0]); + const closeMsg = fromBinary(AgentClientMessageSchema, result[1]); + expect(throwMsg.message.case).toBe("execClientControlMessage"); + if (throwMsg.message.case === "execClientControlMessage") { + expect(throwMsg.message.value.message.case).toBe("throw"); + if (throwMsg.message.value.message.case === "throw") { + expect(throwMsg.message.value.message.value.error).toContain("Unknown exec message variant"); + } + } + expect(closeMsg.message.case).toBe("execClientControlMessage"); + if (closeMsg.message.case === "execClientControlMessage") { + expect(closeMsg.message.value.message.case).toBe("streamClose"); + } + }); + + test("unknown exec cases do NOT kill the gRPC connection (#116 hardening preserved)", async () => { + // The T05 typed reply must not propagate into failAndClear. The transport-level + // contract is that handleCursorNativeExec returns bytes (not throws), which is + // what live-transport writes back. This test pins that boundary. + const replies = await handleCursorNativeExec(execMessage({ case: undefined, value: undefined })); + expect(replies.length).toBeGreaterThan(0); }); test("rejects native write and delete when apply_patch is available", async () => { diff --git a/tests/cursor-oauth.test.ts b/tests/cursor-oauth.test.ts index 19abe77e4f..c4f3b2651b 100644 --- a/tests/cursor-oauth.test.ts +++ b/tests/cursor-oauth.test.ts @@ -57,6 +57,32 @@ describe("Cursor OAuth core flow", () => { await expect(pollCursorAuth("uuid", "ver", ctrl.signal, 1)).rejects.toThrow(/cancel/i); }); + test("pollCursorAuth fails on the FIRST terminal status without retrying (T07)", async () => { + for (const status of [400, 401, 403, 410]) { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response("", { status }); + }) as typeof fetch; + const err = await pollCursorAuth("uuid", "ver", undefined, 1).catch((e: unknown) => e as Error); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain(String(status)); + expect((err as Error).message).toMatch(/new login/i); + expect(calls).toBe(1); + } + }); + + test("pollCursorAuth keeps the 3-strike retry for server errors (500)", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response("", { status: 500 }); + }) as typeof fetch; + const err = await pollCursorAuth("uuid", "ver", undefined, 1).catch((e: unknown) => e as Error); + expect((err as Error).message).toMatch(/consecutive errors/i); + expect(calls).toBe(3); + }); + test("refreshCursorToken posts the refresh token as a Bearer and returns new creds", async () => { let seenAuth = ""; globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { diff --git a/tests/cursor-pool.test.ts b/tests/cursor-pool.test.ts new file mode 100644 index 0000000000..25881e8c48 --- /dev/null +++ b/tests/cursor-pool.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { CursorCredentialRouter, NoAvailableCursorCredentialError } from "../src/providers/cursor-pool"; + +describe("CursorCredentialRouter", () => { + test("weighted round-robin distributes picks proportionally", () => { + const router = new CursorCredentialRouter([ + { id: "a", weight: 3 }, + { id: "b", weight: 1 }, + ]); + const picks: Record = { a: 0, b: 0 }; + for (let i = 0; i < 40; i++) { + const cred = router.pick(); + picks[cred.id] = (picks[cred.id] ?? 0) + 1; + } + // 3:1 ratio should be roughly 30:10 + expect(picks.a).toBeGreaterThan(picks.b * 2); + }); + + test("disable + cooldown excludes the credential", () => { + const router = new CursorCredentialRouter([{ id: "a", weight: 1 }]); + router.disable("a"); + expect(() => router.pick()).toThrow(NoAvailableCursorCredentialError); + }); + + test("failover picks a different credential when one is disabled", () => { + const router = new CursorCredentialRouter([ + { id: "a", weight: 1 }, + { id: "b", weight: 1 }, + ]); + router.disable("a"); + const cred = router.pick(); + expect(cred.id).toBe("b"); + }); +}); diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 1f8e1bfbec..d03d7724d1 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -8,6 +8,7 @@ import { McpArgsSchema, McpToolCallSchema, PartialToolCallUpdateSchema, + TextDeltaUpdateSchema, TokenDeltaUpdateSchema, ToolCallCompletedUpdateSchema, ToolCallSchema, @@ -1102,3 +1103,39 @@ describe("request-local input estimate (#373)", () => { expect(usage?.inputTokens).toBe(0); }); }); + +describe("textual pseudo tool-call marker normalization (#2305)", () => { + function textDelta(text: string) { + return interaction({ case: "textDelta", value: create(TextDeltaUpdateSchema, { text }) }); + } + + test("display alias inside [TOOL_CALL]...[ARGS] markers folds to the wire name", () => { + const state = createCursorProtobufEventState(); + const events = mapCursorProtobufServerMessage( + textDelta('[TOOL_CALL]mcp_opencodex-responses_grep[ARGS]{"pattern":"OpenCodex"}'), + state, + ); + expect(events).toEqual([{ type: "text", text: '[TOOL_CALL]grep[ARGS]{"pattern":"OpenCodex"}' }]); + }); + + test("prose mentioning the display alias without markers stays untouched", () => { + const state = createCursorProtobufEventState(); + const prose = "You could call mcp_opencodex-responses_grep here."; + const events = mapCursorProtobufServerMessage(textDelta(prose), state); + expect(events).toEqual([{ type: "text", text: prose }]); + }); + + test("markers with a non-opencodex provider prefix are not rewritten", () => { + const state = createCursorProtobufEventState(); + const other = "[TOOL_CALL]mcp_other-provider_grep[ARGS]{}"; + const events = mapCursorProtobufServerMessage(textDelta(other), state); + expect(events).toEqual([{ type: "text", text: other }]); + }); + + test("already-short names inside markers pass through unchanged", () => { + const state = createCursorProtobufEventState(); + const short = "[TOOL_CALL]grep[ARGS]{}"; + const events = mapCursorProtobufServerMessage(textDelta(short), state); + expect(events).toEqual([{ type: "text", text: short }]); + }); +}); diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index 6820ebc048..21548e26f4 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -302,7 +302,7 @@ describe("Cursor request builder", () => { ]); }); - test("uses an explicit image placeholder for unsupported image parts", () => { + test("omits image parts from text — they ride SelectedImage, not markers", () => { const request = createCursorRequest({ ...base, context: { @@ -320,13 +320,58 @@ describe("Cursor request builder", () => { }); expect(request.messages[0]?.content).toContain("see"); - // A USER-message image is still flattened here (this path builds the plain-text prompt). - // The tool-result ENCODER does build real McpImageContent, so the placeholder no longer - // claims the encoder as a whole is unable to send images. (Neither kind reaches Cursor in - // production today: every Cursor model is in noVisionModels, so the vision sidecar runs - // first — see devlog/_plan/260817_cursor_toolcall_decode/020_*.md.) - expect(request.messages[0]?.content).toContain("image omitted from this Cursor text prompt"); - expect(request.messages[0]?.content).toContain("high"); + expect(request.messages[0]?.content).not.toContain("image input unsupported"); + expect(request.messages[0]?.content).not.toContain("data:image/png"); + }); + + test("preserves image-only user turns as empty-string active messages", () => { + const request = createCursorRequest({ + ...base, + context: { + messages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "high" }], + timestamp: 1, + }, + ], + }, + }); + + expect(request.messages).toEqual([{ role: "user", content: "" }]); + expect(request.rawMessages?.length).toBe(1); + }); + + test("preserves image-only active user turn after assistant reply", () => { + const request = createCursorRequest({ + ...base, + context: { + messages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "high" }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/composer-2.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,def", detail: "high" }], + timestamp: 3, + }, + ], + }, + }); + + expect(request.messages).toEqual([ + { role: "user", content: "" }, + { role: "assistant", content: "ack" }, + { role: "user", content: "" }, + ]); }); test("preserves Responses tools and tool choice for Cursor request context", () => { diff --git a/tests/cursor-static-catalog.test.ts b/tests/cursor-static-catalog.test.ts index 4dbd772e86..1775989bf5 100644 --- a/tests/cursor-static-catalog.test.ts +++ b/tests/cursor-static-catalog.test.ts @@ -111,5 +111,43 @@ describe("Cursor static Codex catalog", () => { ]); expect(entries.find(item => item.slug === "cursor/glm-5.2")?.supported_reasoning_levels) .toMatchObject([{ effort: "high" }, { effort: "max" }, { effort: "ultra" }]); + + for (const modelId of ["auto", "composer-2.5", "gpt-5.5", "gemini-3-pro"]) { + expect( + entries.find(item => item.slug === `cursor/${modelId}`)?.input_modalities, + `cursor/${modelId} should advertise image input`, + ).toEqual(["text", "image"]); + } + }); +}); + +describe("Opus Fast catalog families (devlog 300, live-verified 260822)", () => { + test("all three -fast families are present with tier pickers", async () => { + const { CURSOR_STATIC_MODELS } = await import("../src/adapters/cursor/discovery"); + for (const id of ["claude-opus-4-7-fast", "claude-opus-4-8-fast", "claude-opus-5-fast"]) { + const entry = CURSOR_STATIC_MODELS.find(model => model.id === id); + expect(entry, `${id} missing from static catalog`).toBeDefined(); + expect(entry?.supportsReasoningEffort, `${id} must carry a tier picker — the bare wire id is not_found`).toBe(true); + } + }); + + test("tier ladders match the 260822 GetUsableModels dump", async () => { + const { cursorModelEffortLadder } = await import("../src/adapters/cursor/effort-map"); + expect(cursorModelEffortLadder("claude-opus-4-7-fast")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(cursorModelEffortLadder("claude-opus-4-8-fast")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(cursorModelEffortLadder("claude-opus-5-fast")).toEqual(["low", "medium", "high"]); + }); + + test("wire-id derivation produces the live-verified suffixed forms and never a bare -fast id", async () => { + const { cursorWireModelIdWithEffort, cursorEffortSuffix } = await import("../src/adapters/cursor/effort-map"); + expect(cursorWireModelIdWithEffort("claude-opus-4-8-fast", "high")).toBe("claude-opus-4-8-high-fast"); + expect(cursorWireModelIdWithEffort("claude-opus-5-fast", "medium")).toBe("claude-opus-5-medium-fast"); + expect(cursorWireModelIdWithEffort("claude-opus-4-7-fast", "max")).toBe("claude-opus-4-7-max-fast"); + // No-effort requests must still resolve to a suffix (bare id is not_found on the wire). + for (const id of ["claude-opus-4-7-fast", "claude-opus-4-8-fast", "claude-opus-5-fast"]) { + expect(cursorEffortSuffix(id, undefined), `${id} must never send bare`).toBeTruthy(); + } + // Out-of-ladder effort clamps within the family ladder (opus-5-fast has no xhigh). + expect(cursorEffortSuffix("claude-opus-5-fast", "xhigh")).toBe("high"); }); }); diff --git a/tests/cursor-stream-health.test.ts b/tests/cursor-stream-health.test.ts new file mode 100644 index 0000000000..d59c1147ae --- /dev/null +++ b/tests/cursor-stream-health.test.ts @@ -0,0 +1,210 @@ +import http2 from "node:http2"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { describe, expect, test } from "bun:test"; +import { + AgentServerMessageSchema, + ConversationStateStructureSchema, + HeartbeatUpdateSchema, + InteractionUpdateSchema, + TextDeltaUpdateSchema, + TurnEndedUpdateSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import { encodeConnectFrame } from "../src/adapters/cursor/framing"; +import { createLiveCursorTransport } from "../src/adapters/cursor/live-transport"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { CursorRunRequest, CursorServerMessage } from "../src/adapters/cursor/types"; + +/** + * T04 (devlog 260822_senpi_cursor_transfer/110): inbound stream-health watchdog. + * A turn that received its first frame but then goes silent (or heartbeat-only) + * must fail at the transport with a typed stall error instead of waiting for the + * 300s bridge stall watchdog (issue #2210 class). + */ + +function agentFrame(message: Parameters>[1]): Uint8Array { + return encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, message))); +} + +function textDeltaFrame(textValue: string): Uint8Array { + return agentFrame({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "textDelta", value: create(TextDeltaUpdateSchema, { text: textValue }) }, + }), + }, + }); +} + +function heartbeatFrame(): Uint8Array { + return agentFrame({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "heartbeat", value: create(HeartbeatUpdateSchema, {}) }, + }), + }, + }); +} + +function checkpointFrame(): Uint8Array { + return agentFrame({ + message: { + case: "conversationCheckpointUpdate", + value: create(ConversationStateStructureSchema, {}), + }, + }); +} + +function turnEndedFrame(): Uint8Array { + return agentFrame({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }), + }, + }); +} + +async function withH2Server( + handler: (stream: http2.ServerHttp2Stream) => void, + run: (baseUrl: string) => Promise, +): Promise { + const server = http2.createServer(); + server.on("stream", handler); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("HTTP/2 fixture did not bind a TCP port"); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + +function runRequest(): CursorRunRequest { + return { + modelId: "composer-2", + conversationId: "cursor_stream_health_test", + system: [], + messages: [{ role: "user", content: "hello" }], + } as CursorRunRequest; +} + +async function drain(baseUrl: string, knobs: { streamSilenceFailMs?: number; streamHeartbeatOnlyFailMs?: number }): Promise<{ + messages: CursorServerMessage[]; + failure?: Error; +}> { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + firstFrameTimeoutMs: 2_000, + ...knobs, + }); + const messages: CursorServerMessage[] = []; + let failure: Error | undefined; + try { + for await (const message of transport.run(runRequest())) messages.push(message); + } catch (err) { + failure = err instanceof Error ? err : new Error(String(err)); + } finally { + await transport.close?.(); + } + return { messages, failure }; +} + +describe("Cursor inbound stream-health watchdog (T04)", () => { + test("silence after the first frame fails the turn with the stall error", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(textDeltaFrame("hi"))); + // then: silence — never end the stream + }, async baseUrl => { + const { failure } = await drain(baseUrl, { streamSilenceFailMs: 300, streamHeartbeatOnlyFailMs: 10_000 }); + expect(failure).toBeDefined(); + expect(failure!.message).toContain("no inbound frames"); + }); + }, 15_000); + + test("heartbeat-only traffic survives the silence threshold but fails at the heartbeat-only threshold", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(textDeltaFrame("hi"))); + const ping = setInterval(() => { + try { + stream.write(Buffer.from(heartbeatFrame())); + stream.write(Buffer.from(checkpointFrame())); + } catch { clearInterval(ping); } + }, 100); + stream.on("close", () => clearInterval(ping)); + }, async baseUrl => { + const { failure } = await drain(baseUrl, { streamSilenceFailMs: 400, streamHeartbeatOnlyFailMs: 900 }); + expect(failure).toBeDefined(); + expect(failure!.message).toContain("heartbeat-only"); + }); + }, 15_000); + + test("meaningful frames keep resetting both clocks; turnEnded finishes cleanly", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + let count = 0; + const tick = setInterval(() => { + count += 1; + try { + if (count < 6) { + stream.write(Buffer.from(textDeltaFrame(`part-${count}`))); + } else { + stream.write(Buffer.from(turnEndedFrame())); + stream.end(); + clearInterval(tick); + } + } catch { clearInterval(tick); } + }, 150); + stream.on("close", () => clearInterval(tick)); + }, async baseUrl => { + // Each 150ms text delta must reset the 400ms silence clock: six ticks ≈ 900ms total, + // far past a NON-resetting 400ms deadline. + const { messages, failure } = await drain(baseUrl, { streamSilenceFailMs: 400, streamHeartbeatOnlyFailMs: 10_000 }); + expect(failure).toBeUndefined(); + expect(messages.some(message => message.type === "text")).toBe(true); + expect(messages.some(message => message.type === "done")).toBe(true); + }); + }, 15_000); + + test("turnEnded disarms the watchdog even when the server holds the stream open", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(textDeltaFrame("hi"))); + stream.write(Buffer.from(turnEndedFrame())); + // hold open: the T03 turnEnded close owns this case; the watchdog must not fire first + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, { streamSilenceFailMs: 300, streamHeartbeatOnlyFailMs: 10_000 }); + expect(failure).toBeUndefined(); + expect(messages.some(message => message.type === "done")).toBe(true); + }); + }, 15_000); + + test("no watchdog before the first frame: the first-frame timeout still owns dial silence", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + // no frames at all + }, async baseUrl => { + const { failure } = await drain(baseUrl, { streamSilenceFailMs: 60_000, streamHeartbeatOnlyFailMs: 60_000 }); + expect(failure).toBeDefined(); + expect(failure!.message).toContain("before first response"); + }); + }, 15_000); +}); diff --git a/tests/cursor-tool-continuation.test.ts b/tests/cursor-tool-continuation.test.ts index 6880d741cb..2772245035 100644 --- a/tests/cursor-tool-continuation.test.ts +++ b/tests/cursor-tool-continuation.test.ts @@ -39,7 +39,7 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = { role: "toolResult", toolCallId: "call_1", toolName: "read_file", toolNamespace: "mcp__fs", content: "FILE CONTENTS HERE", isError: false, timestamp: 3 }, ]; - test("tool result text is present in rootPromptMessagesJson, not only in turns[]", () => { + test("external-continuation tool result text is present in rootPromptMessagesJson, not only in turns[]", () => { const bytes = encodeCursorRunRequest({ modelId: "composer-2.5", conversationId: "c1", @@ -49,14 +49,29 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = }); const roots = decodeRoots(bytes); const serialized = JSON.stringify(roots); - // The model prompt (rootPromptMessagesJson) MUST carry the tool result, or ResumeAction has - // nothing model-visible to resume from. Reference: danger-pi buildRootPromptMessagesJson. + // composer-2.5 still continues as userMessageAction, so the model prompt must carry the + // tool result. Reference: danger-pi buildRootPromptMessagesJson. expect(serialized).toContain("FILE CONTENTS HERE"); expect(serialized).toContain("call_1"); // The prior user turn must also be replayed (not system-only). expect(serialized).toContain("read a file"); }); + test("native resume models keep tool results on turns[], not as assistant-role root text", () => { + const bytes = encodeCursorRunRequest({ + modelId: "auto-intelligence", + conversationId: "c-auto", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], + rawMessages, + }); + const serialized = JSON.stringify(decodeRoots(bytes)); + expect(serialized).toContain("read a file"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).not.toContain("FILE CONTENTS HERE"); + }); + test("rootPromptMessagesJson still leads with the system prompt blob", () => { const bytes = encodeCursorRunRequest({ modelId: "composer-2.5", @@ -82,11 +97,25 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = // "[Tool Call]" text. The model few-shot-mimics that marker and emits later parallel/mixed tool // calls as inert text instead of real tool frames (halting multi-tool continuations). expect(serialized).not.toContain("[Tool Call]"); - // ...but the tool's model-visible continuation context (call id + output) must still survive via - // the paired tool RESULT echo, so the model can continue from it. + // composer-2.5 still needs the paired tool RESULT echo in the model-visible prompt. expect(serialized).toContain("FILE CONTENTS HERE"); expect(serialized).toContain("call_1"); }); + + test("native resume models do not few-shot [Tool Result] as assistant chat", () => { + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5-fast", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], + rawMessages, + }); + const serialized = JSON.stringify(decodeRoots(bytes)); + expect(serialized).not.toContain("[Tool Call]"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).toContain("read a file"); + }); }); import { create as createPb } from "@bufbuild/protobuf"; diff --git a/tests/cursor-vision-wire-harness.test.ts b/tests/cursor-vision-wire-harness.test.ts new file mode 100644 index 0000000000..6b26c36931 --- /dev/null +++ b/tests/cursor-vision-wire-harness.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { fromBinary } from "@bufbuild/protobuf"; +import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { CURSOR_VISION_IMAGE_HISTORY_MARKER } from "../src/adapters/cursor/images"; +import { + AgentClientMessageSchema, + ConversationStepSchema, + ConversationTurnStructureSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import { handleCursorNativeKv, resetCursorBlobStateForTests } from "../src/adapters/cursor/native-exec"; +import { GetBlobArgsSchema, KvServerMessageSchema } from "../src/adapters/cursor/gen/agent_pb"; +import { create } from "@bufbuild/protobuf"; + +function blobData(blobId: Uint8Array): Uint8Array { + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, + }))); + const kv = reply.message.case === "kvClientMessage" ? reply.message.value : undefined; + const result = kv?.message.case === "getBlobResult" ? kv.message.value.blobData : undefined; + if (!result) throw new Error("missing blob data"); + return result; +} + +function activeSelectedImageBytes(bytes: Uint8Array): Uint8Array | undefined { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const action = run?.action?.action; + const image = action?.case === "userMessageAction" + ? action.value.userMessage?.selectedContext?.selectedImages[0] + : undefined; + if (!image) return undefined; + if (image.dataOrBlobId.case === "data") return image.dataOrBlobId.value; + if (image.dataOrBlobId.case === "blobId") return blobData(image.dataOrBlobId.value); + if (image.dataOrBlobId.case === "blobIdWithData") return image.dataOrBlobId.value.data; + return undefined; +} + +function anyMcpImageContent(bytes: Uint8Array): boolean { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + for (const turnId of run?.conversationState?.turns ?? []) { + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnId)); + if (turn.turn.case !== "agentConversationTurn") continue; + for (const stepId of turn.turn.value.steps) { + const step = fromBinary(ConversationStepSchema, blobData(stepId)); + if (step.message.case !== "toolCall") continue; + const tool = step.message.value.tool; + if (tool.case !== "mcpToolCall") continue; + const result = tool.value.result?.result; + if (result?.case !== "success") continue; + for (const item of result.value.content) { + if (item.content.case === "image") return true; + } + } + } + return false; +} + +describe("Cursor vision wire harness", () => { + test("grok attach keeps non-empty PNG bytes on the wire; tool-result images stay text-only", () => { + resetCursorBlobStateForTests(); + const imageBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); + const imageUrl = `data:image/png;base64,${Buffer.from(imageBytes).toString("base64")}`; + + const attachBytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "see this" }], + selectedImages: [{ uuid: "img-uuid-1", mimeType: "image/png", data: imageBytes }], + }); + const attachWireBytes = activeSelectedImageBytes(attachBytes); + expect(attachWireBytes).toBeDefined(); + expect(attachWireBytes!.byteLength).toBeGreaterThan(0); + expect(Array.from(attachWireBytes!.slice(0, 4))).toEqual([137, 80, 78, 71]); + + const viewBytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_view\nname: view_image\nis_error: false\noutput:" }], + rawMessages: [ + { role: "user", content: "describe the image", timestamp: 1 }, + { + role: "assistant", + model: "cursor/grok-4.5", + timestamp: 2, + content: [{ type: "toolCall", id: "call_view", name: "view_image", arguments: { path: "/tmp/x.png" } }], + }, + { + role: "toolResult", + toolCallId: "call_view", + toolName: "view_image", + content: [{ type: "image", imageUrl, detail: "auto" }], + isError: false, + timestamp: 3, + }, + ], + }); + // Tool-result image promotion is out of scope in this slice: no McpImageContent on the wire. + expect(anyMcpImageContent(viewBytes)).toBe(false); + // Image bytes must never be serialized into text. Scan the whole encoded frame. + const base64Payload = imageUrl.slice(imageUrl.indexOf(",") + 1); + expect(new TextDecoder().decode(viewBytes)).not.toContain(base64Payload); + expect(new TextDecoder().decode(viewBytes)).not.toContain("data:image/png;base64,"); + const blobText = hydratedTurnText(viewBytes); + expect(blobText).not.toContain(base64Payload); + expect(blobText).not.toContain("data:image/png;base64,"); + expect(blobText).toContain(CURSOR_VISION_IMAGE_HISTORY_MARKER); + }); +}); + +function hydratedTurnText(bytes: Uint8Array): string { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const parts: string[] = []; + for (const turnId of run?.conversationState?.turns ?? []) { + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnId)); + if (turn.turn.case !== "agentConversationTurn") continue; + parts.push(new TextDecoder().decode(blobData(turn.turn.value.userMessage))); + for (const stepId of turn.turn.value.steps) { + parts.push(new TextDecoder().decode(blobData(stepId))); + } + } + return parts.join("\n"); +} diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 4d2a500857..d04535581d 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -14,6 +14,67 @@ function convertedInputDescription(name: string): string | undefined { } describe("routed custom-tool compatibility", () => { + test.each([ + ["absent", undefined], + ["true", true], + ] as const)("keeps apply_patch byte-identical when custom-tool support is %s", (_label, support) => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + const before = JSON.stringify(raw); + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, support); + + expect(rewritten.body).toBe(raw); + expect(JSON.stringify(rewritten.body)).toBe(before); + expect(rewritten.names).toEqual(new Set()); + }); + + test("lowers apply_patch declarations and replay items on an explicit capability denial", () => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, false); + const body = rewritten.body as typeof raw; + + expect(rewritten.names).toEqual(new Set(["apply_patch"])); + expect(body.tools[0]).toMatchObject({ + type: "function", + name: "apply_patch", + parameters: { required: ["input"] }, + }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(body.input[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch", + output: "done", + }); + }); + + test.each([undefined, true, false])("keeps lowering other custom tools when support is %p", support => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [{ type: "custom", name: "review_patch", description: "Review", format: { type: "text" } }], + }, support); + const body = rewritten.body as { tools: Array> }; + + expect(body.tools[0]).toMatchObject({ type: "function", name: "review_patch" }); + expect(rewritten.names).toEqual(new Set(["review_patch"])); + }); + test("converted exec preserves the JavaScript input contract", () => { const description = convertedInputDescription("exec"); expect(description).toContain("JavaScript"); diff --git a/tests/fastwire-policy.test.ts b/tests/fastwire-policy.test.ts index 412d0524a4..a29debcb34 100644 --- a/tests/fastwire-policy.test.ts +++ b/tests/fastwire-policy.test.ts @@ -288,6 +288,7 @@ describe("resolveFastPolicy matrix", () => { settledCallerTier: undefined, }, { + // B2: key-auth Chat Completions is a documented Priority Processing transport. name: "xAI API-key default", providerName: "xai", modelIds: ["grok-4.6", "grok-4.5"], @@ -297,7 +298,7 @@ describe("resolveFastPolicy matrix", () => { authMode: "key" as const, }, adapter: "openai-chat", - forwardCallerTier: false, + forwardCallerTier: true, callerTier: undefined, settledCallerTier: undefined, }, diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index c914825bee..571ce27c1a 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -118,6 +118,20 @@ describe("#1735 thought signature survives history replay", () => { .toBe(SIGNATURE); }); + test("a functionCall part with nested extra_content.google.thought_signature is read", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(firstTurn()); + const events = await adapter.parseResponse!(new Response(JSON.stringify(googleBody([ + { + functionCall: { name: "shell_command", args: { command: "pwd" } }, + extra_content: { google: { thought_signature: SIGNATURE } }, + }, + ])))); + const start = events.find((e: AdapterEvent) => e.type === "tool_call_start"); + expect(start && "providerMetadata" in start ? start.providerMetadata?.google?.thoughtSignature : undefined) + .toBe(SIGNATURE); + }); + test("parallel calls each keep their own signature", async () => { const adapter = createGoogleAdapter(provider); await adapter.buildRequest(firstTurn()); diff --git a/tests/helpers/cursor-grumpy-fixture.png b/tests/helpers/cursor-grumpy-fixture.png new file mode 100644 index 0000000000..a06cdc84a2 Binary files /dev/null and b/tests/helpers/cursor-grumpy-fixture.png differ diff --git a/tests/install-scripts.test.ts b/tests/install-scripts.test.ts index f3cbde242e..7fd6fed318 100644 --- a/tests/install-scripts.test.ts +++ b/tests/install-scripts.test.ts @@ -52,7 +52,7 @@ describe("install scripts", () => { expect(pkg.exports?.["."]?.default).toBe("./bin/package-main.mjs"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); - expect(pkg.devDependencies?.["@types/bun"]).toBe("1.3.14"); + expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); expect(pkg.scripts?.dev).toBe("bun run src/cli/index.ts start"); expect(pkg.scripts?.["dev:proxy"]).toBe("bun run src/cli/index.ts start"); expect(pkg.scripts?.["dev:gui"]).toBe("cd gui && bun run dev"); diff --git a/tests/management-api-logs-metrics.test.ts b/tests/management-api-logs-metrics.test.ts index f7077a8951..0255005e2f 100644 --- a/tests/management-api-logs-metrics.test.ts +++ b/tests/management-api-logs-metrics.test.ts @@ -95,6 +95,31 @@ describe("GET /api/logs display metrics", () => { expect(dto!.displayMetrics.cost.estimateReasons).toContain("cache_detail_missing"); }); + test("confirmed xAI priority plus long context is exposed as a cost lower bound", async () => { + addRequestLog(baseEntry({ + provider: "xai", + model: "grok-4.6", + usage: { + inputTokens: 200_000, + outputTokens: 10_000, + cacheReadInputTokens: 50_000, + }, + tierOutcome: { + canonical: "priority", + wireKind: "service-tier", + wireValue: "priority", + fastOutcome: "applied", + confirmation: "confirmed", + responseServiceTier: "priority", + }, + })); + const [dto] = await readLogs(); + expect(dto!.displayMetrics.cost.kind).toBe("value"); + expect(dto!.displayMetrics.cost.estimate.priorityLowerBound).toBe(true); + expect(dto!.displayMetrics.cost.estimate.cost.total).toBeCloseTo(0.77, 9); + expect(dto!.displayMetrics.cost.estimateReasons).toContain("priority_lower_bound"); + }); + test("unmatched price is unavailable instead of zero", async () => { addRequestLog(baseEntry({ provider: "no-such-provider", diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index 6db5a921af..baee2a34d2 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; import { handleManagementAPI } from "../src/server/management-api"; import { OPENCODE_API_KEY_ENV, @@ -332,12 +333,17 @@ describe("GET /api/client-config", () => { test("an accepted override still resolves through the route", async () => { const previous = process.env.PI_CODING_AGENT_DIR; - process.env.PI_CODING_AGENT_DIR = "/tmp/opencodex-pi-route-fixture"; + // One binding for the override, so the env value and the expectation cannot + // drift apart, and `join` for the separator: the resolver builds the + // destination with `join`, which is `\` on win32, so a hard-coded POSIX + // string asserted the platform rather than the override taking effect. + const overrideDir = "/tmp/opencodex-pi-route-fixture"; + process.env.PI_CODING_AGENT_DIR = overrideDir; try { const response = await clientConfigApi(baseConfig(), "?client=pi"); expect(response.status).toBe(200); const body = await response.json() as ClientConfigEnvelope; - expect(body.destination).toBe("/tmp/opencodex-pi-route-fixture/models.json"); + expect(body.destination).toBe(join(overrideDir, "models.json")); } finally { if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previous; diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 45a4157808..83367a8ed8 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -198,9 +198,8 @@ describe("Responses namespace tool compatibility", () => { expect(flatten([functionsGroup], [bare])).toEqual([bare]); }); - // The routed compaction turn strips the whole tool surface before this runs, and a catalog can - // change mid-session — but the client is still replaying items this layer's own restoration - // stamped with a private `namespace`. + // A catalog can be absent or change mid-session, but the client can still replay items this + // layer's own restoration stamped with a private `namespace`. test("lowers replayed calls even when this turn declares no namespace", () => { const body = rewriteRoutedNamespaceToolsForUpstream({ input: [ diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index 8bbc3d0510..5027076633 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -6,6 +6,8 @@ import { loadConfig } from "../src/config"; import { OAUTH_PROVIDERS, reconcileOAuthProviders, upsertOAuthProvider } from "../src/oauth"; import { getCredential, saveCredential } from "../src/oauth/store"; import { routeModel } from "../src/router"; +import { CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS, cursorModelIds } from "../src/adapters/cursor/discovery"; +import { modelInList } from "../src/types"; import type { OcxConfig } from "../src/types"; const originalHome = process.env.OPENCODEX_HOME; @@ -18,6 +20,33 @@ afterEach(() => { }); describe("OAuth provider reconciliation", () => { + test("heals a stale Cursor all-models noVisionModels stamp down to the curated list", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-cursor-novision-reconcile-")); + homes.push(home); + process.env.OPENCODEX_HOME = home; + const preset = OAUTH_PROVIDERS.cursor.providerConfig; + const stale = cursorModelIds(CURSOR_STATIC_MODELS); + expect(stale.length).toBeGreaterThan((preset.noVisionModels ?? []).length); + const config = { + port: 10100, + defaultProvider: "cursor", + providers: { + cursor: { + ...structuredClone(preset), + authMode: "oauth", + noVisionModels: [...stale], + }, + }, + } satisfies OcxConfig; + + expect(reconcileOAuthProviders(config)).toBe(true); + expect(config.providers.cursor.noVisionModels).toEqual(preset.noVisionModels); + expect(config.providers.cursor.noVisionModels).toEqual([...CURSOR_NO_VISION_MODELS]); + expect(config.providers.cursor.noVisionModels).not.toContain("grok-4.5"); + expect(config.providers.cursor.noVisionModels).toContain("auto"); + expect(modelInList(config.providers.cursor.noVisionModels, "composer-2.5")).toBe(true); + expect(reconcileOAuthProviders(config)).toBe(false); + }); test("refreshes a saved Antigravity 3.5 preset without touching credentials or user fields", async () => { const home = mkdtempSync(join(tmpdir(), "ocx-gemini-36-reconcile-")); homes.push(home); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 3da353dac0..27254db95f 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -3,6 +3,7 @@ import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterP import { openaiResponsesUrl } from "../src/adapters/openai-responses-url"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { routeModel } from "../src/router"; import { handleResponses, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { encodeCompactionSummary, @@ -248,6 +249,369 @@ describe("DeepSeek Responses endpoint contract", () => { }); }); +describe("Responses custom-tool destination capability", () => { + test("xAI explicitly denies native custom tools and registry enrichment preserves an override", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.supportsResponsesCustomTools).toBe(false); + + const inherited = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + } as Parameters[1]; + enrichProviderFromRegistry("xai", inherited); + expect(inherited.supportsResponsesCustomTools).toBe(false); + + const explicit = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + supportsResponsesCustomTools: true, + } as Parameters[1]; + enrichProviderFromRegistry("xai", explicit); + expect(explicit.supportsResponsesCustomTools).toBe(true); + + const routed = routeModel({ + port: 0, + defaultProvider: "xai", + providers: { + xai: { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" }, + }, + } as OcxConfig, "xai/grok-4.6"); + expect(routed.provider.supportsResponsesCustomTools).toBe(false); + }); + + test("noncanonical forward destinations that deny custom tools lower apply_patch", () => { + const rawBody = { + model: "routed-model", + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "routed-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(request.headers.authorization).toBe("Bearer provider-static"); + expect(body.tools[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect([...(request.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + }); + + test("the canonical Codex forward surface never lowers custom tools, even with an explicit denial", () => { + const rawBody = { + model: "gpt-5.6-sol", + stream: true, + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + // Exact canonical Codex forward base URL: isCanonicalOpenAiForwardProvider is true, + // so the lowering gate must be unreachable regardless of the capability flag. + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(body.tools[0]).toMatchObject({ type: "custom", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ type: "custom_tool_call", call_id: "c1", name: "apply_patch" }); + expect(request.convertedRoutedCustomToolNames ?? []).toEqual([]); + }); +}); + +describe("routed compaction lowering order", () => { + const baseInput = [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + { type: "custom_tool_call", call_id: "c1", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "c1", output: "ok" }, + { + type: "tool_search_call", + call_id: "c2", + execution: "client", + arguments: { query: "database" }, + }, + { + type: "tool_search_output", + call_id: "c2", + execution: "client", + status: "completed", + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + { + type: "function_call", + call_id: "c3", + namespace: "collaboration", + name: "spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + { + type: "additional_tools", + role: "developer", + tools: [{ + type: "function", + name: "extra", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + ]; + const rawBody = (compaction: boolean) => ({ + model: "routed-model", + stream: false, + input: [ + ...baseInput, + ...(compaction ? [{ type: "compaction_trigger" }] : []), + ], + tools: [ + { + type: "custom", + name: "apply_patch", + description: "Apply patch", + format: { type: "text" }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "tool_search", + execution: "client", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", parameters: { type: "object" } }], + }, + ], + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + }); + const loweredReplay = [ + { + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + { + type: "function_call", + call_id: "c2", + name: "opencodex_tool_search", + arguments: JSON.stringify({ query: "database" }), + }, + { + type: "function_call_output", + call_id: "c2", + output: JSON.stringify({ + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + status: "completed", + }), + }, + { + type: "function_call", + call_id: "c3", + name: "collaboration__spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + ]; + const loweredTools = [ + { + type: "function", + name: "apply_patch", + description: "Apply patch", + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Raw input for this client-executed custom tool.", + }, + }, + required: ["input"], + additionalProperties: false, + }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "function", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + name: "opencodex_tool_search", + }, + { + type: "function", + name: "collaboration__spawn_agent", + parameters: { type: "object" }, + }, + { type: "function", name: "loaded_tool", parameters: { type: "object" } }, + ]; + + function build(compaction: boolean) { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "test-key", + supportsResponsesCustomTools: false, + }); + return adapter.buildRequest({ + modelId: "routed-model", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: rawBody(compaction), + ...(compaction ? { _compactionRequest: true } : {}), + }, { headers: new Headers() }); + } + + test("lowers replayed calls before removing the compaction tool surface", () => { + const built = build(true); + const body = JSON.parse(built.body) as Record & { + input: Array>; + }; + + expect(body.input.slice(0, -1)).toEqual([ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_text", text: "[image omitted for compaction]" }, + ], + }, + ...loweredReplay, + ]); + expect(body.input.at(-1)).toEqual({ + type: "message", + role: "user", + content: [{ + type: "input_text", + text: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION"), + }], + }); + + expect(body).not.toHaveProperty("tools"); + expect(body).not.toHaveProperty("tool_choice"); + expect(body).not.toHaveProperty("parallel_tool_calls"); + expect(body).not.toHaveProperty("text"); + expect(body.input.some(item => item.type === "compaction_trigger")).toBe(false); + expect(body.input.some(item => item.type === "additional_tools")).toBe(false); + expect(JSON.stringify(body)).not.toContain("input_image"); + expect(JSON.stringify(body)).not.toContain("data:image/png"); + expect(body.input.find(item => item.call_id === "c3")).not.toHaveProperty("namespace"); + + expect([...(built.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + expect([...(built.convertedRoutedToolSearchNames ?? [])]).toEqual(["opencodex_tool_search"]); + expect([...(built.convertedRoutedNamespaceToolAliases ?? new Map()).entries()]).toEqual([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + }); + + test("leaves the non-compaction serialized body byte-identical", () => { + const built = build(false); + expect(built.body).toBe(JSON.stringify({ + model: "routed-model", + stream: false, + input: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + ...loweredReplay, + { + type: "additional_tools", + role: "developer", + tools: [{ type: "function", name: "extra", parameters: { type: "object" } }], + }, + ], + tools: loweredTools, + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + })); + }); +}); + describe("OpenAI Responses passthrough sanitization", () => { const deferredToolBody = { model: "routed-model", @@ -976,7 +1340,7 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toMatchObject({ type: "image_generation" }); }); - test("drops ChatGPT's external_web_access hint but keeps routed web search", () => { + test("normalizes xAI top-level and additional web search without stale tool choice", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", @@ -998,21 +1362,18 @@ describe("OpenAI Responses passthrough sanitization", () => { tools: [{ type: "web_search", external_web_access: true, search_context_size: "medium" }], }], tools: [{ type: "web_search", external_web_access: false, filters: { allowed_domains: ["example.com"] } }], + tool_choice: { type: "web_search" }, }, }, { headers: new Headers() }); const body = JSON.parse(request.body) as { - tools: Record[]; + tools?: Record[]; input: Array<{ type: string; tools: Record[] }>; + tool_choice: Record; }; - expect(body.tools).toEqual([{ - type: "web_search", - filters: { allowed_domains: ["example.com"] }, - }]); - expect(body.input[0]?.tools).toEqual([{ - type: "web_search", - search_context_size: "medium", - }]); + expect(body.tools).toBeUndefined(); + expect(body.input[0]?.tools).toEqual([{ type: "web_search" }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); }); test("preserves external_web_access on the canonical OpenAI forward route", () => { @@ -1070,7 +1431,7 @@ describe("OpenAI Responses passthrough sanitization", () => { input: Array<{ tools: Record[] }>; }; - expect(body.tools[0]).toEqual({ type: "web_search_preview" }); + expect(body.tools[0]).toEqual({ type: "web_search" }); expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); expect(body.tools[1]).not.toHaveProperty("defer_loading"); expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 1c4e733683..dffa089c56 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -593,11 +593,11 @@ describe("fetchProviderQuotaReports", () => { expect(rejectedRefresh.reports).toEqual([]); }); - function keyQuotaConfig(name: string, baseUrl: string): OcxConfig { + function keyQuotaConfig(name: string, baseUrl: string, apiKey = `${name}-secret`): OcxConfig { return { defaultProvider: name, providers: { - [name]: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey: `${name}-secret` }, + [name]: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey }, }, } as OcxConfig; } @@ -881,6 +881,79 @@ describe("fetchProviderQuotaReports", () => { const url = String(input); const headers = init?.headers as Record | undefined; seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); + return new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 40.5, currentValue: 405, usage: 1000, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 52, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 12.3, nextResetTime: 1789000000000 }, + ], + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 40.5, + fiveHourResetAt: 1789000000000, + weeklyPercent: 52, + weeklyResetAt: 1789600000000, + monthlyPercent: 12.3, + monthlyResetAt: 1789000000000, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.z.ai/api/monitor/usage/quota/limit"); + expect(seen[0]?.authorization).toBe("Bearer zai-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("Z.AI quota probes the BigModel region from the provider's own host", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); + // Weekly row omits `percentage`: the fallback derives it from currentValue/usage. + return new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "CREDIT_LIMIT", unit: 3, number: 5, percentage: 20, currentValue: 200, usage: 1000, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, currentValue: 156, usage: 300, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 7.5, nextResetTime: 1789000000000 }, + ], + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/coding/paas/v4", "zai-secret"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 20, + weeklyPercent: 52, + monthlyPercent: 7.5, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); + expect(seen[0]?.authorization).toBe("Bearer zai-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("Z.AI quota falls back to legacy field-name payloads", async () => { + const seen: Array<{ url: string; authorization?: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization }); return new Response(JSON.stringify({ success: true, data: { fiveHourPercent: 40.5, weeklyPercent: 52, monthlyMCPUsage: 12.3 }, @@ -898,8 +971,41 @@ describe("fetchProviderQuotaReports", () => { }); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://api.z.ai/api/monitor/usage/quota/limit"); + }); + + test("Z.AI quota probes the BigModel Responses endpoint at /api/v1", async () => { + const seen: Array<{ url: string; authorization?: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization }); + return new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 30, currentValue: 300, usage: 1000, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 60, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 9.5, nextResetTime: 1789000000000 }, + ], + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/v1", "zai-secret"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 30, + weeklyPercent: 60, + monthlyPercent: 9.5, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit"); expect(seen[0]?.authorization).toBe("Bearer zai-secret"); - expect(seen[0]?.redirect).toBe("error"); }); test("Z.AI quota treats an unsuccessful payload as a no-report", async () => { @@ -928,6 +1034,103 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); + test("Z.AI quota never probes the BigModel pay-as-you-go endpoint", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("zhipu-bigmodel-coding", "https://open.bigmodel.cn/api/paas/v4"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("Z.AI quota ignores token rows whose window length does not match", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 2, percentage: 40, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 2, percentage: 52, nextResetTime: 1789600000000 }, + { type: "TIME_LIMIT", percentage: 12.3, nextResetTime: 1789000000000 }, + ], + }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ monthlyPercent: 12.3 }); + expect(result.reports[0]?.quota.fiveHourPercent).toBeUndefined(); + expect(result.reports[0]?.quota.weeklyPercent).toBeUndefined(); + }); + + test("Z.AI quota does not fall back to legacy fields when limits is present but empty", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + success: true, + data: { limits: [], fiveHourPercent: 40.5, weeklyPercent: 52, monthlyMCPUsage: 12.3 }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toEqual([]); + }); + + test("Z.AI quota renders a real v2 coding-plan response (monthly MCP TIME_LIMIT)", async () => { + // Sanitized live response captured from the /api/monitor/usage/quota/limit probe + // (level=max, v2 protocol): the TIME_LIMIT row is the 30-day MCP tool budget + // (search-prime / web-reader / zread), independent of the token windows. + const v2Response = { + limits: [ + { type: "TIME_LIMIT", unit: 5, number: 1, usage: 4000, currentValue: 0, remaining: 4000, percentage: 0, nextResetTime: 1788073095998, + usageDetails: [{ modelCode: "search-prime", usage: 0 }, { modelCode: "web-reader", usage: 0 }, { modelCode: "zread", usage: 0 }] }, + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 100, nextResetTime: 1787056863927 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 20, nextResetTime: 1787641095989 }, + ], + level: "max", + }; + globalThis.fetch = (async () => new Response(JSON.stringify({ success: true, data: v2Response }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 100, + fiveHourResetAt: 1787056863927, + weeklyPercent: 20, + weeklyResetAt: 1787641095989, + monthlyPercent: 0, + monthlyResetAt: 1788073095998, + }); + }); + + test("Z.AI quota renders a real new-protocol response without the monthly MCP row", async () => { + // Sanitized live response (level=pro, newer protocol): CREDIT_LIMIT rows only, + // no TIME_LIMIT row — the monthly MCP bar must not render. + const newProtocolResponse = { + limits: [ + { type: "CREDIT_LIMIT", unit: 3, number: 5, usage: 12000, currentValue: 0, remaining: 12000, percentage: 0 }, + { type: "CREDIT_LIMIT", unit: 6, number: 1, usage: 60000, currentValue: 0, remaining: 60000, percentage: 0, nextResetTime: 1787649214999 }, + ], + level: "pro", + }; + globalThis.fetch = (async () => new Response(JSON.stringify({ success: true, data: newProtocolResponse }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 0, + weeklyPercent: 0, + }); + expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); + }); + test("MiniMax quota drops the row when the API omits the plan total after having it", async () => { // A valid row (with total) exists; a later valid response omitting the // total is a DELIBERATE contract change — the stale row must be dropped diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 7211943037..4ec3bc33b3 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { buildCatalogEntries } from "../src/codex/catalog"; +import { CURSOR_NO_VISION_MODELS } from "../src/adapters/cursor/discovery"; import { getModelMetadata, resolveMetadataProvider } from "../src/generated/model-metadata"; import { buildInitProviders } from "../src/cli/init"; import { OAUTH_PROVIDERS } from "../src/oauth"; @@ -659,6 +660,13 @@ describe("provider registry parity", () => { expect(seed.modelContextWindows?.["gpt-5.6-luna"]).toBe(1_000_000); expect(seed.modelReasoningEfforts?.["gpt-5.5"]).toEqual(["low", "medium", "high"]); expect(seed.modelReasoningEfforts?.["gpt-5.6-sol"]).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(cursor?.noVisionModels).toEqual([...CURSOR_NO_VISION_MODELS]); + expect(seed.noVisionModels).toEqual([...CURSOR_NO_VISION_MODELS]); + expect(seed.noVisionModels).toContain("composer-2.5"); + expect(seed.noVisionModels).toContain("glm-5.3"); + expect(seed.noVisionModels).not.toContain("grok-4.5"); + expect(seed.modelInputModalities?.auto).toEqual(["text", "image"]); + expect(seed.modelInputModalities?.["composer-2.5"]).toEqual(["text", "image"]); const savedCursor: OcxProviderConfig = { adapter: "cursor", baseUrl: "https://api2.cursor.sh" }; enrichProviderFromCatalog("cursor", savedCursor); diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index 679ce64401..d5ce3fa614 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -14,8 +14,14 @@ const releaseScriptPath = join(repoRoot, "scripts", "release.ts"); interface LoggedCall { args: string[]; name: string; + /** Only the ssh override is recorded: the release deploy-key path is the reason it exists. */ + gitSshCommand?: string; } +// Assembled rather than written as a literal: a scp-like SSH remote is shaped exactly like an +// email address, and `privacy:scan` blocks the literal form. +const sshTarget = `${"git"}@${"github.com"}:lidge-jun/opencodex.git`; + interface ReleaseScenario { branch?: string; npmLatest?: string; @@ -25,6 +31,14 @@ interface ReleaseScenario { privacyExitCode?: number; testExitCode?: number; typecheckExitCode?: number; + releaseSshKey?: string; + releaseSshRepo?: string; + pendingBump?: boolean; + originUrl?: string; +} + +interface SshInvocation { + args: string[]; } function writeExecutable(path: string, contents: string): void { @@ -57,7 +71,7 @@ process.exit(exitCode); return `import { appendFileSync } from "node:fs"; const args = process.argv.slice(2); -appendFileSync(process.env.FAKE_RELEASE_LOG, JSON.stringify({ name: "git", args }) + "\\n"); +appendFileSync(process.env.FAKE_RELEASE_LOG, JSON.stringify({ name: "git", args, ...(process.env.GIT_SSH_COMMAND ? { gitSshCommand: process.env.GIT_SSH_COMMAND } : {}) }) + "\\n"); const headSha = process.env.FAKE_GIT_HEAD_SHA ?? "abc123def456"; const branch = process.env.FAKE_GIT_BRANCH ?? "main"; @@ -69,8 +83,16 @@ if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") process.exit(0); } +if (args[0] === "remote" && args[1] === "get-url") { + stdout((process.env.FAKE_GIT_ORIGIN_URL ?? "https://github.com/lidge-jun/opencodex.git") + "\\n"); + process.exit(0); +} + if (args[0] === "status" && args[1] === "--porcelain") { - stdout((process.env.FAKE_GIT_STATUS ?? "") + "\\n"); + // The clean-tree preflight and the pendingBump probe both land here. Only the second one + // passes a path, so a scenario can report a pending bump without failing the first gate. + const pathScoped = args.length > 2; + stdout((pathScoped ? (process.env.FAKE_GIT_PENDING_BUMP ?? "") : (process.env.FAKE_GIT_STATUS ?? "")) + "\\n"); process.exit(0); } @@ -209,7 +231,12 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { // script aborted before logging a single call. Strip every case variant, then // set exactly one. const inheritedEnv = Object.fromEntries( - Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path"), + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path" + // A real release EXPORTS the deploy-key variables, and the preflight runs this suite as a + // child that inherits them — so an inherited value would make the "no key configured" + // scenario run WITH a key and fail the release at its own preflight. Scrub them the same + // way PATH is scrubbed, then let the scenario add back exactly what it asked for. + && key !== "OCX_RELEASE_SSH_KEY" && key !== "OCX_RELEASE_SSH_REPO"), ); const pathKey = process.platform === "win32" ? "Path" : "PATH"; const pathValue = `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? process.env.Path ?? ""}`; @@ -228,6 +255,10 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { FAKE_BUN_PRIVACY_EXIT_CODE: String(scenario.privacyExitCode ?? 0), ...(scenario.npmLatest ? { FAKE_NPM_LATEST: scenario.npmLatest } : {}), ...(scenario.npmPreview ? { FAKE_NPM_PREVIEW: scenario.npmPreview } : {}), + ...(scenario.releaseSshKey ? { OCX_RELEASE_SSH_KEY: scenario.releaseSshKey } : {}), + ...(scenario.releaseSshRepo ? { OCX_RELEASE_SSH_REPO: scenario.releaseSshRepo } : {}), + ...(scenario.pendingBump ? { FAKE_GIT_PENDING_BUMP: " M package.json" } : {}), + ...(scenario.originUrl ? { FAKE_GIT_ORIGIN_URL: scenario.originUrl } : {}), }, encoding: "utf8", }); @@ -237,6 +268,51 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { return { calls, result }; } +/** + * Run the exact command string emitted by the release helper through real Git and a fake SSH. + * + * The release shim proves which string was placed in the environment, but Git owns the parsing + * contract for `GIT_SSH_COMMAND`. Exercising a real Git process here catches quoting that looks + * correct in text yet splits, substitutes, or reinterprets the private-key path before SSH sees it. + */ +function executeGitSshCommand(gitSshCommand: string): { calls: SshInvocation[]; result: ReturnType } { + const shimDir = mkdtempSync(join(tmpdir(), "ocx-release-ssh-")); + const logPath = join(shimDir, "ssh-log.jsonl"); + const jsPath = join(shimDir, "ssh.js"); + writeFileSync(logPath, "", "utf8"); + writeFileSync(jsPath, `import { appendFileSync } from "node:fs"; +appendFileSync(process.env.FAKE_SSH_LOG, JSON.stringify({ args: process.argv.slice(2) }) + "\\n"); +process.exit(0); +`, "utf8"); + + // Use a native executable directly on every platform. A Windows `.cmd` shim that forwards `%*` + // reparses quoting and can make a broken GIT_SSH_COMMAND look correct after the damage, turning + // this regression into a false green. Only replace the executable token; Git still parses the + // exact emitted `-i` argument and hostile key path. + expect(gitSshCommand.startsWith("ssh ")).toBe(true); + const quote = (value: string) => `"${value.replace(/(["\\`$])/g, "\\$1")}"`; + const nativeFakeCommand = `${quote(process.execPath)} ${quote(jsPath)}${gitSshCommand.slice(3)}`; + + const inheritedEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== "GIT_SSH" && key !== "GIT_SSH_COMMAND"), + ); + const result = spawnSync("git", ["ls-remote", "ssh://example.invalid/owner/repository.git"], { + cwd: repoRoot, + env: { + ...inheritedEnv, + FAKE_SSH_LOG: logPath, + GIT_SSH_COMMAND: nativeFakeCommand, + }, + encoding: "utf8", + }); + const raw = readFileSync(logPath, "utf8").trim(); + const calls = raw + ? raw.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line) as SshInvocation) + : []; + rmSync(shimDir, { recursive: true, force: true }); + return { calls, result }; +} + describe("release helper", () => { test("preflight runs the shared audit, typecheck, test suite, and privacy scan before version bump", () => { const { calls, result } = runRelease("9.9.9"); @@ -324,6 +400,182 @@ describe("release helper", () => { )).toBeGreaterThanOrEqual(0); }); + /** + * `main` and `preview` carry rulesets whose admin bypass is `pull_request` — enough to merge a + * PR, not enough to push. That is where v2.29.0 died. The carve-out is a dedicated write deploy + * key registered as a `DeployKey` bypass actor, selected for this one push and nothing else. + * + * Pin both halves: the key path must reach git as `GIT_SSH_COMMAND` with `IdentitiesOnly` (an + * ssh-agent holding the maintainer's key would otherwise authenticate as the maintainer and be + * rejected by the ruleset again), and the default path must stay byte-identical so a contributor + * or CI clone without the variable is unaffected. + */ + test("the protected push uses the release deploy key only when one is configured", () => { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/ocx-release-key", + releaseSshRepo: sshTarget, + pendingBump: true, + }); + + expect(result.status).toBe(0); + const push = calls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push).toBeDefined(); + expect(push?.args).toEqual(["push", sshTarget, "HEAD:main"]); + expect(push?.gitSshCommand).toBe('ssh -i "/tmp/ocx-release-key" -o IdentitiesOnly=yes'); + }); + + /** + * Git parses `GIT_SSH_COMMAND` with shell-style word splitting rather than exec'ing it, so a + * bare interpolation splits any key path containing a space — the Windows default + * (`C:\Users\Jun Kim\.ssh\...`) is exactly that shape, and ssh would read the tail as its next + * flag. Assert the whole command string, not a substring: `toContain` passes on the broken form. + */ + test("a key path with spaces and backslashes stays a single ssh argument", () => { + const { calls } = runRelease("9.9.9", { + releaseSshKey: "C:\\Users\\Jun Kim\\.ssh\\ocx release key", + pendingBump: true, + }); + + const push = calls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push?.gitSshCommand).toBe('ssh -i "C:\\\\Users\\\\Jun Kim\\\\.ssh\\\\ocx release key" -o IdentitiesOnly=yes'); + }); + + test("Git passes the emitted deploy-key path to SSH as one literal argument", () => { + const keyPath = 'C:\\Users\\Jun Kim\\.ssh\\ocx "quoted" $HOME $(not-run) `not-run`; key'; + const { calls: releaseCalls } = runRelease("9.9.9", { + releaseSshKey: keyPath, + releaseSshRepo: sshTarget, + pendingBump: true, + }); + const push = releaseCalls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push?.gitSshCommand).toBeDefined(); + + const { calls } = executeGitSshCommand(push?.gitSshCommand ?? ""); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + const identityIndex = call.args.indexOf("-i"); + expect(identityIndex).toBeGreaterThanOrEqual(0); + expect(call.args[identityIndex + 1]).toBe(keyPath); + } + }); + + /** + * The SSH target is derived from `origin` rather than hardcoded, so a fork's release pushes to + * the fork instead of silently targeting upstream. + */ + test("the ssh push target follows the configured origin remote", () => { + const { calls } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + originUrl: "https://github.com/someone-else/opencodex.git", + pendingBump: true, + }); + + const push = calls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push?.args[1]).toBe(`${"git"}@${"github.com"}:someone-else/opencodex.git`); + }); + + /** + * A credential-bearing origin must not be transplanted into the SSH target: `runLoud` prints the + * failing command, so a folded `user:token@` would put the token on the terminal and in the + * release log. Refuse instead of building a target. + */ + test("an origin carrying credentials is refused rather than transplanted", () => { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + originUrl: `https://x-access-token:SECRET@${"github.com"}/lidge-jun/opencodex.git`, + pendingBump: true, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain("origin carries credentials"); + expect(result.stderr + result.stdout).not.toContain("SECRET"); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); + }); + + test("a malformed OCX_RELEASE_SSH_REPO override is refused instead of pushed to", () => { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + releaseSshRepo: "not-a-remote", + pendingBump: true, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain("OCX_RELEASE_SSH_REPO"); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); + }); + + test("credential-bearing SSH targets are rejected without logging the credential", () => { + for (const scenario of [ + { releaseSshRepo: "ssh://git:SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://git%3ASECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "git@SECRET@example.test:owner/repository.git" }, + { releaseSshRepo: "ssh://git:@example.test/owner/repository.git" }, + { releaseSshRepo: "git@example.test:owner/repository.git?token=SECRET" }, + { originUrl: "ssh://git:SECRET@example.test/owner/repository.git" }, + { originUrl: "git:SECRET@example.test:owner/repository.git" }, + ]) { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + pendingBump: true, + ...scenario, + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + expect(result.status).not.toBe(0); + expect(output).not.toContain("SECRET"); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); + } + }); + + test("credential-free ssh URL and scp-like release targets remain accepted", () => { + for (const releaseSshRepo of [ + "ssh://git@example.test/owner/repository.git", + "ssh://example.test/owner/repository.git", + "git@example.test:owner/repository.git", + ]) { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + releaseSshRepo, + pendingBump: true, + }); + expect(result.status).toBe(0); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")?.args[1]) + .toBe(releaseSshRepo); + } + }); + + test("an ssh origin is reused verbatim rather than rewritten", () => { + const { calls } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + originUrl: `${"git"}@${"github.com"}:lidge-jun/opencodex.git`, + pendingBump: true, + }); + + const push = calls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push?.args[1]).toBe(`${"git"}@${"github.com"}:lidge-jun/opencodex.git`); + }); + + test("an origin that yields no ssh target aborts instead of guessing one", () => { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + originUrl: "/srv/git/opencodex.git", + pendingBump: true, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain("no SSH push target"); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); + }); + + test("without a configured key the push is unchanged and carries no ssh override", () => { + const { calls, result } = runRelease("9.9.9", { pendingBump: true }); + + expect(result.status).toBe(0); + const push = calls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push?.args).toEqual(["push", "origin", "main"]); + expect(push?.gitSshCommand).toBeUndefined(); + }); + test("aborts before dispatch when the remote branch moved during the CI wait", () => { const { calls, result } = runRelease("9.9.9", { headSha: "abc123def456", diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index a5fdafabee..923d52af44 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -528,6 +528,210 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses lowers and restores apply_patch when the destination denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses lowers apply_patch for a noncanonical forward destination that denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + let outboundAuthorization: string | null = null; + let outboundUrl = ""; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (input, init) => { + outboundUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + outboundBody = JSON.parse(String(init?.body)) as Record; + outboundAuthorization = new Headers(init?.headers).get("authorization"); + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer caller-secret" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundUrl).toBe("https://provider.example/v1/responses"); + expect(outboundAuthorization).toBe("Bearer provider-static"); + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses continuation rewrites custom_tool_call_output and keeps call_id ordered", async () => { const savedFetch = globalThis.fetch; const outboundBodies: Array> = []; diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index f6dc65ac27..3f67df88f4 100644 --- a/tests/responses-routed-web-search-fields.test.ts +++ b/tests/responses-routed-web-search-fields.test.ts @@ -50,6 +50,32 @@ describe("stripOpenAiOnlyWebSearchFields", () => { const clean = { model: "m", tools: [{ type: "web_search" }] }; expect(stripOpenAiOnlyWebSearchFields(clean)).toBe(clean); }); + + test("strips a nested cached declaration even when no top-level tools exist", () => { + const body = { + model: "m", + input: [{ + type: "additional_tools", + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "low", + filters: { allowed_domains: ["example.com"] }, + }], + }], + }; + + expect(stripOpenAiOnlyWebSearchFields(body)).toEqual({ + model: "m", + input: [{ + type: "additional_tools", + tools: [{ + type: "web_search", + filters: { allowed_domains: ["example.com"] }, + }], + }], + }); + }); }); describe("Responses buildRequest web_search capability", () => { @@ -69,17 +95,69 @@ describe("Responses buildRequest web_search capability", () => { }]); }); - test("registry xAI traffic strips fields its Responses API rejects", () => { + test("registry xAI traffic normalizes Codex search fields for its public Responses API", () => { const entry = getProviderRegistryEntry("xai"); if (!entry) throw new Error("xAI registry entry missing"); const provider = { ...providerConfigSeed(entry), adapter: "openai-responses" }; enrichProviderFromRegistry("xai", provider); const body = buildWebSearchBody(provider); + expect(body.tools).toEqual([{ type: "web_search" }]); + }); + + test("non-xAI classified gateways use generic field stripping, not xAI cached-search policy", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://responses.example.com/v1", + authMode: "key", + apiKey: "test-gateway-key", + supportsOpenAiWebSearchToolFields: false, + }; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: [{ + type: "additional_tools", + role: "developer", + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "low", + user_location: { type: "approximate", country: "KR" }, + filters: { excluded_domains: ["blocked.example"] }, + }], + }], + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "medium", + user_location: { type: "approximate" }, + filters: { allowed_domains: ["example.com"] }, + }], + tool_choice: { type: "web_search" }, + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as Record; + expect(body.tools).toEqual([{ type: "web_search", user_location: { type: "approximate" }, + filters: { allowed_domains: ["example.com"] }, + }]); + expect(body.input).toEqual([{ + type: "additional_tools", + role: "developer", + tools: [{ + type: "web_search", + user_location: { type: "approximate", country: "KR" }, + filters: { excluded_domains: ["blocked.example"] }, + }], }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); }); }); @@ -106,7 +184,7 @@ describe("routedProviderConfig web_search capability backfill", () => { expect(routed.supportsOpenAiWebSearchToolFields).toBe(false); }); - test("the routed row actually strips the fatal fields at the adapter", () => { + test("the routed row actually normalizes the search tool at the adapter", () => { const routed = routedProviderConfig("xai", { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", @@ -115,10 +193,7 @@ describe("routedProviderConfig web_search capability backfill", () => { }); const body = buildWebSearchBody({ ...routed, adapter: "openai-responses" }); - expect(body.tools).toEqual([{ - type: "web_search", - user_location: { type: "approximate" }, - }]); + expect(body.tools).toEqual([{ type: "web_search" }]); }); test("an explicit saved value still overrides the registry default", () => { diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index 49c051b8dc..fd1a418602 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -7,15 +7,22 @@ * ever receiving an injection (PR #860 family). */ import { afterEach, describe, expect, test } from "bun:test"; -import { applyProviderConfigHints } from "../src/codex/catalog"; +import { applyProviderConfigHints, buildCatalogEntries, gatherRoutedModels } from "../src/codex/catalog"; import { applyCatalogModelMetadata } from "../src/codex/catalog/effort"; import type { RawEntry } from "../src/codex/catalog/parsing"; import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { decideTier } from "../src/providers/fastwire"; import type { RequestLogContext } from "../src/server/request-log"; import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; -import { canForwardServiceTierForModel, serviceTierSupportForModel, supportsServiceTierForModel } from "../src/providers/service-tier"; -import { serviceTierAdapterForModel } from "../src/providers/service-tier"; +import { + canForwardServiceTierForModel, + fastPolicyForModel, + serviceTierAdapterForModel, + serviceTierSupportForModel, + serviceTierSupportFromPolicy, + supportsServiceTierForModel, +} from "../src/providers/service-tier"; import { candidateCapabilityEvidence } from "../src/routing/capability"; import { resolveProductionBehaviorValues } from "../src/routing/compatibility/behavior"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -74,6 +81,92 @@ describe("registry capability reaches saved configs without overriding them", () }); }); +describe("xAI Fast capability follows the captured authentication transport", () => { + function xaiProvider( + authMode: "key" | "oauth", + overrides: Partial = {}, + ): OcxProviderConfig { + return { + ...providerConfigSeed(getProviderRegistryEntry("xai")!), + authMode, + apiKey: authMode === "key" ? "xai-test-key" : "oauth-test-token", + liveModels: false, + models: ["grok-4.6"], + ...overrides, + }; + } + + async function catalogEntry(provider: OcxProviderConfig) { + const models = await gatherRoutedModels({ + providers: { xai: provider }, + } as unknown as OcxConfig); + return buildCatalogEntries(null, [], models) + .find(entry => entry.slug === "xai/grok-4.6"); + } + + test("registry declares a key-auth overlay without classifying OAuth", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.keyAuthServiceTier).toEqual({ + supportsServiceTier: true, + chatServiceTier: true, + }); + expect(entry.supportsServiceTier).toBeUndefined(); + expect(entry.chatServiceTier).toBeUndefined(); + + const keyPolicy = fastPolicyForModel(xaiProvider("key"), "grok-4.6", "xai"); + expect(keyPolicy).toMatchObject({ + capability: true, + eligibility: "eligible", + forwardCallerTier: true, + fastTierDescription: "Priority processing, 2x token price", + }); + + const oauthPolicy = fastPolicyForModel(xaiProvider("oauth"), "grok-4.6", "xai"); + expect(oauthPolicy.capability).toBeUndefined(); + expect(oauthPolicy.eligibility).toBe("unclassified"); + expect(oauthPolicy.forwardCallerTier).toBe(false); + }); + + test("catalog and runtime publish the same key/OAuth conclusion", async () => { + const keyProvider = xaiProvider("key"); + const keyPolicy = fastPolicyForModel(keyProvider, "grok-4.6", "xai"); + const keyCatalog = await catalogEntry(keyProvider); + expect(serviceTierSupportFromPolicy(keyPolicy)).toBe(true); + expect(keyCatalog?.service_tiers).toEqual([{ + id: "priority", + name: "Fast", + description: "Priority processing, 2x token price", + }]); + expect(keyCatalog?.additional_speed_tiers).toEqual(["fast"]); + expect(decideTier(keyPolicy, true, undefined)).toEqual({ kind: "set", value: "priority" }); + + const oauthProvider = xaiProvider("oauth"); + const oauthPolicy = fastPolicyForModel(oauthProvider, "grok-4.6", "xai"); + const oauthCatalog = await catalogEntry(oauthProvider); + expect(serviceTierSupportFromPolicy(oauthPolicy)).toBe(false); + expect(oauthCatalog).not.toHaveProperty("service_tiers"); + expect(oauthCatalog).not.toHaveProperty("additional_speed_tiers"); + expect(decideTier(oauthPolicy, true, undefined)).toEqual({ kind: "drop" }); + }); + + test("explicit supportsServiceTier=false wins in policy and catalog for both transports", async () => { + for (const authMode of ["key", "oauth"] as const) { + const provider = xaiProvider(authMode, { supportsServiceTier: false }); + const policy = fastPolicyForModel( + provider, + "grok-4.6", + "xai", + ); + expect(policy.capability).toBe(false); + expect(policy.eligibility).toBe("capability-unsupported"); + expect(decideTier(policy, true, undefined)).toEqual({ kind: "drop" }); + const catalog = await catalogEntry(provider); + expect(catalog).not.toHaveProperty("service_tiers"); + expect(catalog).not.toHaveProperty("additional_speed_tiers"); + } + }); +}); + describe("service-tier capability is exact-model and provider-scoped", () => { test("an exact model entry overrides the provider fallback in both directions", () => { const provider: OcxProviderConfig = { @@ -264,6 +357,16 @@ describe("the gate fires on the live handleResponses path", () => { ({ ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }); const openAiKeyProvider = (): OcxProviderConfig => ({ ...providerConfigSeed(getProviderRegistryEntry("openai-apikey")!), apiKey: "sk-test" }); + const xaiKeyProvider = (): OcxProviderConfig => ({ + ...providerConfigSeed(getProviderRegistryEntry("xai")!), + authMode: "key", + apiKey: "xai-test-key", + }); + const xaiOAuthProvider = (): OcxProviderConfig => ({ + ...providerConfigSeed(getProviderRegistryEntry("xai")!), + authMode: "oauth", + apiKey: "xai-oauth-test-token", + }); const openRouterProvider = (overrides: Partial = {}): OcxProviderConfig => { const provider: OcxProviderConfig = { ...providerConfigSeed(getProviderRegistryEntry("openrouter")!), @@ -321,6 +424,23 @@ describe("the gate fires on the live handleResponses path", () => { expect(body.service_tier).toBe("flex"); }); + test("xAI API-key runtime injects priority while OAuth does not", async () => { + const keyBody = await drive("xai", xaiKeyProvider(), "grok-4.6", {}, true); + expect(keyBody.service_tier).toBe("priority"); + const oauthBody = await drive("xai", xaiOAuthProvider(), "grok-4.6", {}, true); + expect(oauthBody).not.toHaveProperty("service_tier"); + for (const provider of [xaiKeyProvider(), xaiOAuthProvider()]) { + const optedOut = await drive( + "xai", + { ...provider, supportsServiceTier: false }, + "grok-4.6", + {}, + true, + ); + expect(optedOut).not.toHaveProperty("service_tier"); + } + }); + test("an unclassified custom Responses provider keeps caller values; only explicit false strips", async () => { const custom = (): OcxProviderConfig => ({ adapter: "openai-responses", baseUrl: "https://gateway.example.com/v1", apiKey: "sk-test" }); const preserved = await drive("custom-gw", custom(), "some-model", { service_tier: "priority" }); diff --git a/tests/service.test.ts b/tests/service.test.ts index 8ee4cd243b..69ef8209db 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -5,7 +5,7 @@ import { isAbsolute, join, posix, win32 } from "node:path"; import * as serviceModule from "../src/service"; import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, prepareServiceInstall, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; @@ -89,19 +89,94 @@ describe("service listen-port bake", () => { }); describe("systemd service unit", () => { - test("bare service command defaults to the install/update/start path", async () => { + test("bare service installs only when absent and otherwise selects no-admin repair", async () => { expect(normalizeServiceSubcommand()).toBe("install"); + expect(normalizeServiceSubcommand("restart")).toBe("repair"); expect(normalizeServiceSubcommand("start")).toBe("start"); expect(normalizeServiceSubcommand("nope")).toBe("nope"); + const bare = parseServiceArgs([]); + expect(selectServiceSubcommand(bare, { hasExplicitSubcommand: false, installed: false })).toBe("install"); + expect(selectServiceSubcommand(bare, { hasExplicitSubcommand: false, installed: true })).toBe("repair"); + expect(selectServiceSubcommand(parseServiceArgs(["install"]), { + hasExplicitSubcommand: true, + installed: true, + })).toBe("install"); + expect(selectServiceSubcommand(parseServiceArgs(["--native"]), { + hasExplicitSubcommand: false, + installed: true, + })).toBe("install"); + + let probes = 0; + const installed = planServiceCommand([], { + probeInstallation: () => { probes += 1; return { state: "installed" }; }, + }); + expect(installed).toMatchObject({ ok: true, command: "repair" }); + expect(probes).toBe(1); + + const absent = planServiceCommand([], { + probeInstallation: () => ({ state: "absent" }), + }); + expect(absent).toMatchObject({ ok: true, command: "install" }); + + const unknown = planServiceCommand([], { + probeInstallation: () => ({ state: "unknown", detail: "query failed" }), + }); + expect(unknown).toMatchObject({ ok: false }); + if (!unknown.ok) expect(unknown.message).toContain("Could not safely determine"); + + probes = 0; + const invalid = planServiceCommand(["--bogus"], { + probeInstallation: () => { probes += 1; return { state: "installed" }; }, + }); + expect(invalid).toMatchObject({ ok: false, message: "Unknown service option: --bogus" }); + expect(probes).toBe(0); + + const explicitInstall = planServiceCommand(["install"], { + probeInstallation: () => { probes += 1; return { state: "unknown" }; }, + }); + expect(explicitInstall).toMatchObject({ ok: true, command: "install" }); + expect(probes).toBe(0); + const service = await readText("src/service.ts"); const serviceCommand = service.slice(service.indexOf("export async function serviceCommand")); - // Args flow through parseServiceArgs (which applies the install default) into the switch. - expect(serviceCommand).toContain("const parsed = parseServiceArgs("); - expect(serviceCommand).toContain("const command = parsed.sub;"); + expect(serviceCommand).toContain("const plan = planServiceCommand(filteredArgs);"); + expect(serviceCommand).toContain("const { parsed, command } = plan;"); expect(serviceCommand).toContain("switch (command)"); }); + test("Windows install presence distinguishes unknown queries from proven absence", () => { + const present = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "present" }), + nativeStatus: () => "unknown", + }); + expect(present.state).toBe("installed"); + + const absent = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "absent" }), + nativeStatus: () => "nonexistent", + }); + expect(absent.state).toBe("absent"); + + const schedulerUnknown = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "unknown", detail: "localized query failure" }), + nativeStatus: () => "nonexistent", + }); + expect(schedulerUnknown).toMatchObject({ state: "unknown" }); + expect(schedulerUnknown.detail).toContain("localized query failure"); + + const nativeUnknown = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "absent" }), + nativeStatus: () => "unknown", + }); + expect(nativeUnknown).toMatchObject({ state: "unknown" }); + expect(nativeUnknown.detail).toContain("WinSW status"); + }); + test("uses unquoted append targets for service logs", () => { const unit = buildUnit(); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 79744f588a..937b0aadeb 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -27,7 +27,7 @@ import { resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; -import type { CodexAuthContext } from "../src/codex/auth-context"; +import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; import { isEagerRelaySseResponse } from "../src/server/relay"; import type { OcxConfig } from "../src/types"; @@ -670,6 +670,111 @@ describe("subagent fallback final-route normalization", () => { }); describe("native fallback account preview", () => { + test("Desktop fallback affinity drives the subagent preview and final native account", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + defaultProvider: "xai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + subagentModelFallback: ["gpt-5.6-terra"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const desktopHeaders = { + "session-id": "desktop-session-private", + "thread-id": "desktop-thread-private", + }; + const bound = await resolveCodexAuthContext(new Headers(desktopHeaders), cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(bound).toMatchObject({ kind: "pool", accountId: "pool-a" }); + if (bound.kind !== "pool") throw new Error("expected pool context"); + cfg.activeCodexAccountId = "pool-b"; + // The binding above was made under codexQuotaScopeForModel("gpt-5.6-sol") === "shared". + // The preview inside handleResponses must derive the SAME scope from the route model — + // an undefined scope reads the "legacy" slot and would miss the binding entirely. + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, "shared")).toBe("pool-a"); + // With no binding in the legacy slot the preview falls through to rotation/active selection, + // so it returns a DIFFERENT account than the affinity-bound one — that divergence is exactly + // what the route-model scope derivation inside handleResponses prevents. + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, undefined)).not.toBe("pool-a"); + + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("xai/grok-4.5", "429", cfg); + noteSubagentModelFailure("grok-4.5", "429", cfg); + + let finalAuth: CodexAuthContext | undefined; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "xai/grok-4.5", input: readableAgentInput(), stream: false }, + { onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; } }, + { model: "", provider: "" }, + desktopHeaders, + ); + + expect(response.status).toBe(200); + expect(capture.urls.some((url) => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(capture.auths.some((auth) => auth?.includes("pool-a_token"))).toBe(true); + }); + + test("subagent preview reads the route-model quota scope, not the legacy slot", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + defaultProvider: "xai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + subagentModelFallback: ["gpt-5.6-terra"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const desktopHeaders = { + "session-id": "scope-session-private", + "thread-id": "scope-thread-private", + }; + const bound = await resolveCodexAuthContext(new Headers(desktopHeaders), cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(bound).toMatchObject({ kind: "pool", accountId: "pool-a" }); + cfg.activeCodexAccountId = "pool-b"; + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("xai/grok-4.5", "429", cfg); + noteSubagentModelFailure("grok-4.5", "429", cfg); + + let finalAuth: CodexAuthContext | undefined; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "xai/grok-4.5", input: readableAgentInput(), stream: false }, + { onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; } }, + { model: "", provider: "" }, + desktopHeaders, + ); + + // The preview inside handleResponses derives its quota scope from the route model, so the + // affinity binding made under "shared" is found and the fallback authenticates pool-a — + // the same account that bound the thread — even though the active account is now pool-b. + expect(response.status).toBe(200); + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-a" }); + }); + test("uses healthier pool account B when active A is above threshold", async () => { const now = 1_800_000_000_000; Date.now = () => now; diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index b6da980fe2..4ceac1f02d 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -212,8 +212,14 @@ describe("terminal guard", () => { // The guard must not let liveness markers change what the continuation decides or sends. expect(analyzeTerminalTurn(request, padded).assistantText) .toBe(analyzeTerminalTurn(request, clean).assistantText); - expect(JSON.stringify(buildContinuationRequest(request, padded).context.messages)) - .toBe(JSON.stringify(buildContinuationRequest(request, clean).context.messages)); + // Compare the CONTENT of the two rebuilds, not their wall-clock stamps. Each call reads the + // clock once (see the next test), but two separate calls legitimately land in different + // milliseconds — comparing raw JSON made this assert the scheduler rather than the heartbeat + // contract, and it failed intermittently on CI for exactly that reason. + const withoutTimestamps = (events: AdapterEvent[]) => + JSON.stringify(buildContinuationRequest(request, events).context.messages + .map(({ timestamp: _timestamp, ...rest }) => rest)); + expect(withoutTimestamps(padded)).toBe(withoutTimestamps(clean)); }); // The rebuild used to read the clock twice — once for the assistant message, once for the diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 11fa3d77fc..362868f4f2 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { createAdapterTierMetadata } from "../src/providers/fastwire"; import { calculateCost, estimateAttemptCost, @@ -12,8 +13,10 @@ import { import { EXPECTED_PRICE_OVERLAYS, PRIORITY_MULTIPLIERS, + PRIORITY_PRICING_RULES, CONTEXT_TIERS, findExpectedPriceOverlay, + findPriorityPricingRule, resolvePriorityMultiplier, type ExpectedPriceOverlay, } from "../src/usage/expected-prices"; @@ -564,6 +567,169 @@ describe("priority (Fast) service tier multiplier", () => { }); }); +describe("xAI Priority Processing pricing", () => { + const usage = { + inputTokens: 100_000, + outputTokens: 10_000, + cacheReadInputTokens: 20_000, + }; + + function outcome(responseServiceTier?: string) { + const tracker = createAdapterTierMetadata( + { + capability: true, + eligibility: "eligible", + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "verbatim", + }, + demandDecision: "force-fast", + }, + { kind: "set", value: "priority" }, + "service-tier", + "priority", + )!; + if (responseServiceTier !== undefined) tracker.observeResponseServiceTier(responseServiceTier); + return tracker.outcome; + } + + function estimate(tierOutcome: ReturnType, requestUsage = usage) { + return estimateAttemptCost({ + ordinal: 1, + provider: "xai", + model: "grok-4.6", + usageStatus: "reported", + usage: requestUsage, + tierOutcome, + })!; + } + + test("xAI rules declare exact 2x premiums with official provenance", () => { + const xaiRules = PRIORITY_PRICING_RULES.filter(rule => rule.provider === "xai"); + expect(xaiRules.map(rule => rule.modelId)).toEqual(["grok-4.5", "grok-4.6"]); + expect(xaiRules.every(rule => rule.multiplier === 2)).toBe(true); + expect(xaiRules.every(rule => rule.requiresResponseConfirmation === true)).toBe(true); + expect(xaiRules.every(rule => rule.source === "https://docs.x.ai/developers/advanced-api-usage/priority-processing")).toBe(true); + expect(findPriorityPricingRule("xai", "grok-4.6")?.multiplier).toBe(2); + expect(findPriorityPricingRule("openrouter", "grok-4.6")).toBeUndefined(); + expect(resolveMatchedPrice("openrouter", "grok-4.6")?.cost4).toEqual({ + input: 2, + output: 6, + cacheRead: 0.3, + cacheWrite: 0, + }); + expect(resolveMatchedPrice("cursor", "grok-4.6")?.cost4).toEqual({ + input: 2, + output: 6, + cacheRead: 0.3, + cacheWrite: 0, + }); + }); + + test("grok-4.6 standard and confirmed priority prices include the official cache rate", () => { + expect(resolveMatchedPrice("xai", "grok-4.6")?.cost4).toEqual({ + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }); + const confirmedOutcome = outcome("priority"); + const confirmed = estimate(confirmedOutcome); + expect(confirmedOutcome).toMatchObject({ + canonical: "priority", + fastOutcome: "applied", + confirmation: "confirmed", + }); + expect(confirmed.cost.total).toBeCloseTo(0.46, 9); + expect(confirmed.cost.cacheRead).toBeCloseTo(0.02, 9); + expect(confirmed.priorityMultiplier).toBe(2); + }); + + test("an assumed priority outcome stays at the standard price", () => { + const assumedOutcome = outcome(); + const assumed = estimate(assumedOutcome); + expect(assumedOutcome).toMatchObject({ + canonical: "priority", + fastOutcome: "applied", + confirmation: "assumed", + }); + expect(assumed.cost.total).toBeCloseTo(0.23, 9); + expect(assumed.priorityMultiplier).toBeUndefined(); + }); + + test("missing provenance and a requested tier do not prove the xAI premium", () => { + for (const serviceTier of [ + "priority", + { requestedServiceTier: "priority" }, + { configuredServiceTier: "priority" }, + ] as const) { + const unconfirmed = estimateRequestCost({ + provider: "xai", + model: "grok-4.6", + usageStatus: "reported", + usage, + serviceTier, + })!; + expect(unconfirmed.cost.total).toBeCloseTo(0.23, 9); + expect(unconfirmed.priorityMultiplier).toBeUndefined(); + } + }); + + test("an echoed default records a downgrade and bills the standard price", () => { + const downgradedOutcome = outcome("default"); + const downgraded = estimate(downgradedOutcome); + expect(downgradedOutcome).toMatchObject({ + fastOutcome: "downgraded", + fastDowngradeReason: "response-declined", + confirmation: "downgraded", + responseServiceTier: "default", + }); + expect(downgradedOutcome).not.toHaveProperty("canonical"); + expect(downgraded.cost.total).toBeCloseTo(0.23, 9); + expect(downgraded.priorityMultiplier).toBeUndefined(); + }); + + test("confirmed priority at 200k uses the long-context price as a marked lower bound", () => { + const long = estimate(outcome("priority"), { + inputTokens: 200_000, + outputTokens: 10_000, + cacheReadInputTokens: 50_000, + }); + expect(long.contextTier).toBe("long"); + expect(long.priorityMultiplier).toBeUndefined(); + expect(long.priorityLowerBound).toBe(true); + expect(long.cost).toMatchObject({ + input: 0.6, + cacheRead: 0.05, + output: 0.12, + }); + expect(long.cost.total).toBeCloseTo(0.77, 9); + }); + + test("a combo is a lower bound only when every priced attempt is a lower bound", () => { + const confirmed = outcome("priority"); + const lowerBoundAttempt = { + ordinal: 1, + provider: "xai", + model: "grok-4.6", + usageStatus: "reported" as const, + usage: { inputTokens: 200_000, outputTokens: 10_000 }, + tierOutcome: confirmed, + }; + const ordinaryAttempt = { + ordinal: 2, + provider: "xai", + model: "grok-4.6", + usageStatus: "reported" as const, + usage, + }; + + expect(estimateComboCost([lowerBoundAttempt, { ...lowerBoundAttempt, ordinal: 2 }])?.priorityLowerBound).toBe(true); + expect(estimateComboCost([lowerBoundAttempt, ordinaryAttempt])?.priorityLowerBound).toBeUndefined(); + }); +}); + describe("long-context pricing tiers (#908)", () => { const SOL: ExpectedPriceOverlay[] = [ { provider: "openai", modelId: "gpt-5.6-sol", cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, diff --git a/tests/vision-backend-union.test.ts b/tests/vision-backend-union.test.ts new file mode 100644 index 0000000000..f55b46abb3 --- /dev/null +++ b/tests/vision-backend-union.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as storeModule from "../src/oauth/store"; +import * as usabilityModule from "../src/codex/account-usability"; +import * as modelRowsModule from "../src/server/management/model-rows"; + +let accountSets: Record; activeAccountId?: string }> = {}; +let usableCodexAccounts: Set = new Set(); +let managementRows: Array> = []; + +mock.module("../src/oauth/store", () => ({ + ...storeModule, + getAccountSet: (provider: string) => accountSets[provider] ?? null, +})); +mock.module("../src/codex/account-usability", () => ({ + ...usabilityModule, + isCodexAccountUsable: (_config: unknown, accountId: string) => usableCodexAccounts.has(accountId), +})); +mock.module("../src/server/management/model-rows", () => ({ + ...modelRowsModule, + listManagementModelRows: async () => managementRows, +})); + +import { handleManagementAPI } from "../src/server/management-api"; +import { ManagementRequest as Request } from "./helpers/management-auth"; +import { + enabledVisionBackends, + visionCandidateRows, + visionDescriberIsProvablyBlind, + visionModelOptionsFrom, +} from "../src/server/management/vision-sidecar-options"; +import { activeVisionBackends } from "../src/vision/backends"; +import { visionBackendForCandidate } from "../src/vision/eligibility"; +import { resolveSidecarAuth } from "../src/sidecar/auth"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }; +const xaiOAuth: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }; +const antigravityOAuth: OcxProviderConfig = { adapter: "google-antigravity", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }; +const volc: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://ark.volces.test/v1", apiKey: "k" }; + +function config(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + providers: { openai: forward, xai: xaiOAuth, "google-antigravity": antigravityOAuth, volcengine: volc }, + ...overrides, + }; +} + +afterEach(() => { + accountSets = {}; + usableCodexAccounts = new Set(); + managementRows = []; +}); + +describe("routed vision backend (#2188 roadmap 170 revised)", () => { + test("any non-forward, non-OAuth-anthropic picker row maps to routed", () => { + const cfg = config(); + expect(visionBackendForCandidate(cfg, { provider: "xai", id: "grok-4.3" })).toBe("routed"); + expect(visionBackendForCandidate(cfg, { provider: "google-antigravity", id: "gemini-3.7-flash" })).toBe("routed"); + expect(visionBackendForCandidate(cfg, { provider: "volcengine", id: "doubao-1.8-vision" })).toBe("routed"); + expect(visionBackendForCandidate(cfg, { provider: "openai", id: "gpt-5.6-luna" })).toBe("openai"); + expect(visionBackendForCandidate(cfg, { provider: "claude", id: "claude-haiku-4-5" }, "claude")).toBe("anthropic"); + }); + + test("routed is always active; universal fallback still fires without any auth side", () => { + const cfg = config(); + const active = activeVisionBackends(resolveSidecarAuth(cfg), cfg); + expect(active).toContain("openai"); + expect(active).toContain("routed"); + expect(enabledVisionBackends(cfg, undefined)).toContain("routed"); + }); + + test("options: routed rows are NAMESPACED and image-filtered (rule 2)", async () => { + const cfg = config(); + managementRows = [ + { provider: "xai", id: "grok-4.3" }, + { provider: "xai", id: "grok-4" }, + { provider: "google-antigravity", id: "gemini-3.7-flash" }, + { provider: "volcengine", id: "doubao-1.8-vision", inputModalities: ["text", "image"] }, + { provider: "volcengine", id: "doubao-text-only", inputModalities: ["text"] }, + ]; + const candidates = await visionCandidateRows(cfg); + const options = visionModelOptionsFrom(cfg, candidates, undefined); + const values = options.map(option => option.value); + expect(values).toContain("xai/grok-4.3"); + expect(values).toContain("google-antigravity/gemini-3.7-flash"); + expect(values).toContain("volcengine/doubao-1.8-vision"); + // rule 2: provably text-only rows drop — vendor table (grok-4) and row modalities. + expect(values).not.toContain("xai/grok-4"); + expect(values).not.toContain("volcengine/doubao-text-only"); + const routedRows = options.filter(option => option.backend === "routed"); + expect(routedRows.every(option => option.value.includes("/"))).toBe(true); + }); + + test("provably-blind gate: namespaced probes its provider; bare probes all families", () => { + const cfg = config(); + expect(visionDescriberIsProvablyBlind(cfg, "xai/grok-4", [], "routed")).toBe(true); + expect(visionDescriberIsProvablyBlind(cfg, "xai/grok-4.3", [], "routed")).toBe(false); + // bare text-only grok-4 still caught without any hint (blocker B). + expect(visionDescriberIsProvablyBlind(cfg, "grok-4", [], undefined)).toBe(true); + expect(visionDescriberIsProvablyBlind(cfg, "grok-4.3", [], undefined)).toBe(false); + }); +}); + +describe("management routes: routed union + coherence", () => { + async function putVision(cfg: OcxConfig, vision: Record): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI( + new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ vision }) }), + url, cfg, + ); + if (!response) throw new Error("route did not handle PUT"); + return response; + } + + test("backend routed accepted; xai/gemini/exa literals rejected 400", async () => { + const cfg = config(); + expect((await putVision(cfg, { backend: "routed" })).status).toBe(200); + expect(cfg.visionSidecar?.backend).toBe("routed"); + for (const bad of ["xai", "gemini", "exa", "zen"]) { + expect((await putVision(cfg, { backend: bad })).status).toBe(400); + } + expect(cfg.visionSidecar?.backend).toBe("routed"); + }); + + test("coherence: namespaced model requires routed; routed requires namespaced", async () => { + const cfg = config(); + expect((await putVision(cfg, { backend: "openai", model: "xai/grok-4.3" })).status).toBe(400); + expect((await putVision(cfg, { backend: "routed", model: "grok-4.3" })).status).toBe(400); + const ok = await putVision(cfg, { backend: "routed", model: "xai/grok-4.3" }); + expect(ok.status).toBe(200); + expect(cfg.visionSidecar?.model).toBe("xai/grok-4.3"); + }); + + test("routed model provably blind via its namespaced provider → 400", async () => { + const cfg = config(); + expect((await putVision(cfg, { backend: "routed", model: "xai/grok-4" })).status).toBe(400); + }); + test("GET reports a routed backend's namespaced model verbatim (live-found regression)", async () => { + const cfg = config({ visionSidecar: { backend: "routed", model: "xai/grok-4.6" } }); + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI(new Request(url, { method: "GET" }), url, cfg); + if (!response) throw new Error("route did not handle GET"); + const body = await response.json() as { vision: { model: string; backend?: string }; visionModels: Array<{ value: string; backend: string }> }; + expect(body.vision.backend).toBe("routed"); + expect(body.vision.model).toBe("xai/grok-4.6"); + // display grandfather: the persisted pair stays selectable even when no + // matching option row exists in this fixture. + expect(body.visionModels.some(option => option.value === "xai/grok-4.6" && option.backend === "routed")).toBe(true); + }); + + test("claude-code vision override admits routed with coherence", async () => { + const cfg = config(); + const url = new URL("http://localhost/api/claude-code"); + async function putOverride(body: Record): Promise { + const response = await handleManagementAPI( + new Request(url, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ visionSidecar: body }), + }), + url, cfg, + ); + if (!response) throw new Error("route did not handle PUT"); + return response; + } + expect((await putOverride({ backend: "routed", model: "volcengine/doubao-1.8-vision" })).status).toBe(200); + expect((await putOverride({ backend: "xai" })).status).toBe(400); + expect((await putOverride({ backend: "routed", model: "bare-id" })).status).toBe(400); + expect((await putOverride({ backend: "openai", model: "volcengine/doubao-1.8-vision" })).status).toBe(400); + }); +}); + diff --git a/tests/vision-eligibility.test.ts b/tests/vision-eligibility.test.ts index 5e77cda24a..6469b6cb0a 100644 --- a/tests/vision-eligibility.test.ts +++ b/tests/vision-eligibility.test.ts @@ -245,9 +245,10 @@ describe("vision eligibility core", () => { expect(matches[0]?.baseline).toBe(true); }); - test("8. backend routing excludes image-capable rows with no executor", () => { - // cursor has no vision sidecar executor — backend is undefined and the row is absent - // from the options list even when it is image-capable. + test("8. non-forward rows map to routed; absent unless routed is enabled", () => { + // cursor has no DEDICATED describe executor — the row now belongs to the + // "routed" loopback executor (#2188 roadmap 170 revised) and appears only + // when the caller enables that backend, as a NAMESPACED value. const config = configWithProviders({ cursor: { adapter: "openai-chat", @@ -259,7 +260,7 @@ describe("vision eligibility core", () => { id: "cursor-vision-capable", inputModalities: ["text", "image"], }; - expect(visionBackendForCandidate(config, candidate)).toBeUndefined(); + expect(visionBackendForCandidate(config, candidate)).toBe("routed"); expect(isVisionEligibleModel(config, candidate)).toBe(true); const options = visionEligibleModelOptions(config, [candidate], ["openai", "anthropic"]); expect(options.some((o) => o.value === candidate.id)).toBe(false); @@ -268,5 +269,8 @@ describe("vision eligibility core", () => { BASELINE_VISION_MODELS.openai, BASELINE_VISION_MODELS.anthropic, ]); + // enabling routed surfaces the row, namespaced. + const withRouted = visionEligibleModelOptions(config, [candidate], ["openai", "anthropic", "routed"]); + expect(withRouted.some((o) => o.value === "cursor/cursor-vision-capable" && o.backend === "routed")).toBe(true); }); }); diff --git a/tests/vision-routed.test.ts b/tests/vision-routed.test.ts new file mode 100644 index 0000000000..c0f680f606 --- /dev/null +++ b/tests/vision-routed.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { resetVisionDescriptionCache } from "../src/vision"; +import { + describeImageRouted, + routedDescribeAdmissionToken, + VISION_DESCRIBE_TERMINAL_HEADER, +} from "../src/vision/routed-describe"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +// Roadmap 180 (revised): the routed describer loops back through the proxy's +// own chat surface, and its terminal marker is the depth-cap-1 recursion +// fence. The fence test drives the FULL chat-surface path (audit round 3-4: +// a predicate-only test would stay green with the marker broken). + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let upstream: ReturnType | null = null; +const originalEnvToken = process.env.OPENCODEX_API_AUTH_TOKEN; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-vision-routed-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-vision-routed-")); + process.env.OPENCODEX_HOME = testDir; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + resetVisionDescriptionCache(); +}); + +afterEach(() => { + upstream?.stop(true); + upstream = null; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (originalEnvToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = originalEnvToken; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +const PNG_DATA_URL = "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM="; +const CAPTION = "A dashboard screenshot with a vision sidecar dropdown."; +const SETTINGS = { model: "vlm/qwen-vl", reasoning: "low" as const, timeoutMs: 10_000 }; + +describe("describeImageRouted unit", () => { + test("POSTs chat wire with terminal marker and returns the caption", async () => { + let seen: { url: string; marker: string | null; auth: string | null; apiKey: string | null; body: Record } | null = null; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + seen = { + url: new URL(req.url).pathname, + marker: req.headers.get(VISION_DESCRIBE_TERMINAL_HEADER), + auth: req.headers.get("authorization"), + apiKey: req.headers.get("x-opencodex-api-key"), + body: await req.json() as Record, + }; + return Response.json({ choices: [{ message: { content: CAPTION } }] }); + }, + }); + try { + const out = await describeImageRouted( + PNG_DATA_URL, undefined, "what is this", "vlm/qwen-vl", + { port: server.port }, SETTINGS, undefined, `http://127.0.0.1:${server.port}`, + ); + expect(out.error).toBeUndefined(); + expect(out.text).toBe(CAPTION); + expect(seen!.url).toBe("/v1/chat/completions"); + expect(seen!.marker).toBe("1"); + expect(seen!.auth).toBeNull(); + expect(seen!.apiKey).toBeNull(); + expect(seen!.body.model).toBe("vlm/qwen-vl"); + expect(seen!.body.stream).toBe(false); + const messages = seen!.body.messages as Array<{ role: string; content: unknown }>; + expect(messages[0].role).toBe("system"); + const userParts = messages[1].content as Array<{ type: string }>; + expect(userParts.some(part => part.type === "image_url")).toBe(true); + } finally { + server.stop(true); + } + }); + + test("admission ladder: env token first, then first apiKeys entry, as x-opencodex-api-key", () => { + expect(routedDescribeAdmissionToken({})).toBeUndefined(); + expect(routedDescribeAdmissionToken({ + apiKeys: [{ id: "a", name: "a", key: "key-1", createdAt: "" }], + })).toBe("key-1"); + process.env.OPENCODEX_API_AUTH_TOKEN = "env-token"; + expect(routedDescribeAdmissionToken({ + apiKeys: [{ id: "a", name: "a", key: "key-1", createdAt: "" }], + })).toBe("env-token"); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + }); + + test("error taxonomy: HTTP error is redacted and never throws; invalid image rejected locally", async () => { + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch: () => new Response("upstream exploded sk-secret-123", { status: 502 }), + }); + try { + const out = await describeImageRouted( + PNG_DATA_URL, undefined, "", "vlm/qwen-vl", + { port: server.port }, SETTINGS, undefined, `http://127.0.0.1:${server.port}`, + ); + expect(out.text).toBe(""); + expect(out.error).toContain("routed describe HTTP 502"); + const bad = await describeImageRouted( + "data:application/pdf;base64,QUJD", undefined, "", "vlm/qwen-vl", + { port: server.port }, SETTINGS, undefined, `http://127.0.0.1:${server.port}`, + ); + expect(bad.error).toContain("unsupported image type"); + } finally { + server.stop(true); + } + }); +}); + +describe("chat-surface recursion fence (full path)", () => { + function textOnlyUpstream(record: (body: string) => void) { + return Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + const body = await req.text(); + record(body); + // The pipeline may re-emit upstream as a chat STREAM; serve SSE when + // asked, JSON otherwise. + if (body.includes('"stream":true')) { + const chunk = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] }; + const done = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const sse = [`data: ${JSON.stringify(chunk)}`, "", `data: ${JSON.stringify(done)}`, "", "data: [DONE]", "", ""].join("\n"); + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ + id: "chatcmpl-1", object: "chat.completion", created: 0, model: "text-only", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }, + }); + } + + test("marked POST strips images (no describe); unmarked plans/strips per legacy path", async () => { + const forwarded: string[] = []; + upstream = textOnlyUpstream(body => forwarded.push(body)); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "routed", + providers: { + routed: { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + allowPrivateNetwork: true, + apiKey: "k", + noVisionModels: ["text-only"], + }, + }, + } as OcxConfig; + saveConfig(config); + const server = startServer(0); + try { + const chatBody = { + model: "routed/text-only", + stream: false, + messages: [{ + role: "user", + content: [ + { type: "text", text: "look" }, + { type: "image_url", image_url: { url: PNG_DATA_URL } }, + ], + }], + }; + const marked = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json", [VISION_DESCRIBE_TERMINAL_HEADER]: "1" }, + body: JSON.stringify(chatBody), + }); + expect(marked.status).toBe(200); + expect(forwarded.length).toBe(1); + // The marked request must reach the upstream with the image STRIPPED — + // and, critically, without any inner describe loopback having fired + // (forwarded.length would be 2 if a describe re-entered). + expect(forwarded[0]).not.toContain(PNG_DATA_URL.slice(30, 60)); + + const unmarked = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(chatBody), + }); + expect(unmarked.status).toBe(200); + // No sidecar auth in this fixture: the legacy path fail-closes by + // stripping too, but WITHOUT the marker the vision planner ran (same + // upstream count increment, no recursion either way). + expect(forwarded.length).toBe(2); + } finally { + server.stop(true); + } + }); + + test("routed describer end-to-end: image described via loopback before the text-only main call", async () => { + const mainBodies: string[] = []; + const describerBodies: string[] = []; + upstream = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + const body = await req.text(); + const url = new URL(req.url); + if (url.port === String(upstream!.port)) { + // both providers share this fake upstream; disambiguate by model. + } + if (body.includes('"model":"vlm"')) { + describerBodies.push(body); + return Response.json({ + id: "chatcmpl-vlm", object: "chat.completion", created: 0, model: "vlm", + choices: [{ index: 0, message: { role: "assistant", content: CAPTION }, finish_reason: "stop" }], + }); + } + mainBodies.push(body); + if (body.includes('"stream":true')) { + const chunk = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: { role: "assistant", content: "done" }, finish_reason: null }] }; + const done = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 0, model: "text-only", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const sse = [`data: ${JSON.stringify(chunk)}`, "", `data: ${JSON.stringify(done)}`, "", "data: [DONE]", "", ""].join("\n"); + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ + id: "chatcmpl-1", object: "chat.completion", created: 0, model: "text-only", + choices: [{ index: 0, message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + }); + }, + }); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "routed", + visionSidecar: { backend: "routed", model: "vision/vlm" }, + providers: { + routed: { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + allowPrivateNetwork: true, + apiKey: "k", + noVisionModels: ["text-only"], + }, + vision: { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + allowPrivateNetwork: true, + apiKey: "k", + modelInputModalities: { vlm: ["text", "image"] }, + }, + }, + } as OcxConfig; + saveConfig(config); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "routed/text-only", + stream: false, + messages: [{ + role: "user", + content: [ + { type: "text", text: "what does the dashboard show" }, + { type: "image_url", image_url: { url: PNG_DATA_URL } }, + ], + }], + }), + }); + expect(res.status).toBe(200); + // The describer ran exactly once, through the loopback chat surface. + expect(describerBodies.length).toBe(1); + expect(describerBodies[0]).toContain("image_url"); + // The main call got the CAPTION text, not the raw image bytes. + expect(mainBodies.length).toBe(1); + expect(mainBodies[0]).toContain("described by a vision model"); + expect(mainBodies[0]).toContain(CAPTION.slice(0, 20)); + expect(mainBodies[0]).not.toContain("aGVsbG8taW1hZ2UtYnl0ZXM="); + } finally { + server.stop(true); + } + }); +}); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index f3f5cce06f..f89a9af2f6 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -234,6 +234,10 @@ describe("service backend CLI parsing", () => { expect(parseServiceArgs([])).toEqual({ sub: "install", backend: null, invalid: [] }); }); + test("restart aliases the existing no-admin repair path", () => { + expect(parseServiceArgs(["restart"])).toEqual({ sub: "repair", backend: null, invalid: [] }); + }); + test("--scheduler and unknown flags are recognized separately", () => { expect(parseServiceArgs(["install", "--scheduler"]).backend).toBe("scheduler"); expect(parseServiceArgs(["install", "--bogus"]).invalid).toEqual(["--bogus"]); diff --git a/tests/xai-web-search-compat.test.ts b/tests/xai-web-search-compat.test.ts new file mode 100644 index 0000000000..f8d2afc29c --- /dev/null +++ b/tests/xai-web-search-compat.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createProductionAdapter } from "../src/adapters/openai-responses"; +import { normalizeXaiResponsesWebSearch } from "../src/adapters/xai-web-search"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +function createXaiAdapter() { + return withTestTranslatorBudget(createProductionAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + headers: { authorization: "Bearer xai-oauth" }, + })); +} + +function buildBody(rawBody: Record): Record { + const request = createXaiAdapter().buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }); + return JSON.parse(request.body) as Record; +} + +describe("xAI Responses web-search compatibility", () => { + test("lowers Codex live-search fields to xAI's documented tool schema", () => { + const body = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search", + external_web_access: true, + filters: { allowed_domains: ["x.ai"] }, + user_location: { type: "approximate", country: "KR" }, + search_context_size: "high", + search_content_types: ["text", "image"], + }], + tool_choice: { type: "web_search" }, + }); + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { allowed_domains: ["x.ai"] }, + enable_image_search: true, + }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); + expect(JSON.stringify(body)).not.toContain("external_web_access"); + expect(JSON.stringify(body)).not.toContain("search_context_size"); + expect(JSON.stringify(body)).not.toContain("search_content_types"); + expect(JSON.stringify(body)).not.toContain("user_location"); + }); + + test("omits cached-only search instead of silently widening it to xAI live search", () => { + const body = buildBody({ + model: "grok-4.6", + tools: [{ type: "web_search", external_web_access: false }], + input: [ + { + type: "additional_tools", + role: "developer", + tools: [{ type: "web_search", external_web_access: false }], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search" }], + }, + }); + + expect(body.tools).toBeUndefined(); + expect(body.input).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ]); + expect(body.tool_choice).toBe("none"); + }); + + test("keeps public xAI search declarations live when the private access flag is absent", () => { + const body = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search", + filters: { excluded_domains: ["example.com"] }, + enable_image_understanding: true, + }], + }); + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { excluded_domains: ["example.com"] }, + enable_image_understanding: true, + }]); + }); + + test("normalizes the supported preview alias in declarations and selectors", () => { + const direct = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search_preview", + external_web_access: true, + search_context_size: "medium", + }], + tool_choice: { type: "web_search_preview" }, + }); + + expect(direct.tools).toEqual([{ type: "web_search" }]); + expect(direct.tool_choice).toEqual({ type: "web_search" }); + + const allowed = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ type: "web_search_preview" }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search_preview" }], + }, + }); + + expect(allowed.tools).toEqual([{ type: "web_search" }]); + expect(allowed.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search" }], + }); + }); + + test("does not rewrite OpenAI, lookalike, or nonstandard-port providers", () => { + const original = { + model: "gpt-5.6-sol", + tools: [{ type: "web_search", external_web_access: false }], + }; + for (const baseUrl of [ + "https://chatgpt.com/backend-api/codex", + "https://api.x.ai.example/v1", + "https://api.x.ai:8443/v1", + "http://api.x.ai/v1", + ]) { + expect(normalizeXaiResponsesWebSearch(original, { baseUrl })).toBe(original); + } + }); +});