From d82b2173eb1c5ffb28bc145f20e24a5466910b73 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:06:26 +0900 Subject: [PATCH 01/25] chore(release): open dev at 2.91.0 before releasing 2.90.0 (#7475) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5b8ff2963d2..33891d14bea 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.90.0" +version = "2.91.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0356f23c8a5..0016895bdde 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.90.0" +version = "2.91.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index c6b4aa73ccd..e42874a9cf6 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.90.0", + "version": "2.91.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 15ae9530fde..e884cb20cec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.90.0", + "version": "2.91.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From cf9965c3f647c45901498358e2d25ddd5df74e07 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 21:14:23 +0900 Subject: [PATCH 02/25] fix(deps): clear high-severity audit findings blocking the 2.90.0 release (#7480) Raise @modelcontextprotocol/sdk to ^1.32.1 (GHSA-22jm-h49p-29qw, GHSA-46jr-4wx8-v453) and lock gui vite at 8.2.4 (GHSA-587h-7wmj-h5pm). release.yml runs bun run audit:high before publishing, and both advisories made it fail. --- bun.lock | 4 ++-- gui/bun.lock | 38 ++++++++++++++++++++------------------ gui/package.json | 2 +- package.json | 2 +- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index 063207baa8b..b0daf0d1e7e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@bitkyc08/opencodex", "dependencies": { "@bufbuild/protobuf": "^2.14.0", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.32.1", "@napi-rs/keyring": "1.3.0", "bun": "1.4.2", "zod": "4.4.3", @@ -35,7 +35,7 @@ "@hono/node-server": ["@hono/node-server@2.1.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw=="], "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], diff --git a/gui/bun.lock b/gui/bun.lock index 2cbae98b06d..656b3ac165f 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -19,7 +19,7 @@ "oxlint": "1.78.0", "react-refresh": "^0.18.0", "typescript": "~6.0.2", - "vite": "^8.2.1", + "vite": "^8.2.4", }, }, }, @@ -29,7 +29,7 @@ "postcss": "8.5.26", }, "packages": { - "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], + "@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], @@ -69,33 +69,35 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], @@ -173,7 +175,7 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="], + "rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -185,7 +187,7 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], + "vite": ["vite@8.2.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], diff --git a/gui/package.json b/gui/package.json index 0300db693ee..b37de5dd949 100644 --- a/gui/package.json +++ b/gui/package.json @@ -31,7 +31,7 @@ "oxlint": "1.78.0", "react-refresh": "^0.18.0", "typescript": "~6.0.2", - "vite": "^8.2.1" + "vite": "^8.2.4" }, "overrides": { "brace-expansion": "5.0.9", diff --git a/package.json b/package.json index e884cb20cec..bbd5ecce9c6 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ }, "dependencies": { "@bufbuild/protobuf": "^2.14.0", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.32.1", "@napi-rs/keyring": "1.3.0", "bun": "1.4.2", "zod": "4.4.3" From 5f8bf52845eab287306a436c3fea53772530483b Mon Sep 17 00:00:00 2001 From: adirwt852-cloud <246880058+adirwt852-cloud@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:41:52 +0530 Subject: [PATCH 03/25] test: cover Claude CLI JSON schema contract (#7387) * test: cover Claude CLI JSON schema contract * test: address Claude CLI adapter review feedback --- tests/providers/claude-cli-adapter.test.ts | 579 +++++++++++++++++++++ 1 file changed, 579 insertions(+) diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts index 0566323475a..2b006903f7e 100644 --- a/tests/providers/claude-cli-adapter.test.ts +++ b/tests/providers/claude-cli-adapter.test.ts @@ -82,6 +82,41 @@ async function run(adapter: ReturnType, p: OcxPar return events; } +// The CLI-emitted name of the one caller-owned tool the capture-only bridge advertises. +const CLAUDE_CLI_MCP_TOOL = "mcp__opencodex__read_file"; + +/** A request that advertises one caller-owned tool, so the turn arms the capture-only bridge. */ +function claudeCliToolRequest(overrides: Partial = {}): OcxParsedRequest { + return parsed({ + context: { + messages: [{ role: "user", content: "Read the fixture", timestamp: 0 }], + tools: [{ + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + }], + }, + ...overrides, + }); +} + +/** One tool_use block: start, the argument delta, and the matching stop. */ +function claudeCliToolFrame(index: number, id: string | undefined, name: string, args: string): string { + return JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_start", + index, + content_block: { type: "tool_use", ...(id === undefined ? {} : { id }), name }, + }, + }) + "\n" + + JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: args } }, + }) + "\n" + + JSON.stringify({ type: "stream_event", event: { type: "content_block_stop", index } }) + "\n"; +} + describe("claude-cli is an official-harness provider, not a Messages relay", () => { test("the registry row and the adapter agree on the one canonical destination", () => { const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-cli"); @@ -462,3 +497,547 @@ describe("claude-cli runTurn streams a subscription turn", () => { ]); }); }); + +describe("claude-cli malformed/truncated protocol output (offline)", () => { + test("a malformed JSON line surfaces a protocol error", async () => { + const stdout = [enc.encode("not-json\n")]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + expect(err.message).toContain("Malformed stream-json frame received from coding-agent CLI"); + }); + + test("an incomplete final JSON line without a newline reports a protocol error", async () => { + const stdout = [enc.encode('{"type":"result","subtype":"success"')]; // incomplete, no newline + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + expect(err.message).toContain("Malformed stream-json frame received from coding-agent CLI"); + }); + + test("a final JSON line without trailing newline is still parsed", async () => { + const frame = JSON.stringify({ type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }); + const stdout = [enc.encode(frame)]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const done = events.find(e => e.type === "done") as { usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }; + expect(done).toBeDefined(); + expect(done.usage).toMatchObject({ inputTokens: 1, outputTokens: 1, totalTokens: 2 }); + }); + + test("empty output reports no terminal result frame", async () => { + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild([], { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + expect(err.message).toContain("without a terminal result frame"); + }); +}); + +describe("claude-cli cancellation (offline)", () => { + test("an already-aborted signal fails before spawning the CLI", async () => { + let spawned = 0; + const controller = new AbortController(); + controller.abort(); + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const evs: any[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), abortSignal: controller.signal }, e => evs.push(e)); + expect(spawned).toBe(0); + expect(evs[0]).toMatchObject({ type: "error", message: "Coding-agent turn was aborted before start." }); + }); +}); + +describe("claude-cli buffered completion (offline)", () => { + test("surfaces a complete assistant text message in one frame", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode(JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello world" }], + stop_reason: "stop", + usage: { input_tokens: 5, output_tokens: 2 }, + }, + }) + "\n"), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":5,"output_tokens":2}}\n'), + ]; + const child = fakeChild(stdout); + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => child as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe("Hello world"); + // The buffered path is the fallback: one complete assistant frame yields exactly one + // text_delta, not a second copy of text the client already saw. + expect(events.filter(e => e.type === "text_delta")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }); + }); +}); + +describe("claude-cli captured tool-call events preserved after protocol error (offline)", () => { + test("tool-call events emitted before a malformed final line remain intact", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode(claudeCliToolFrame(0, "tu_1", CLAUDE_CLI_MCP_TOOL, '{"path":"fixture.txt"}')), + enc.encode("not-json\n"), // malformed line after valid tool calls + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + // The call captured before the malformed line stays complete: the client still learns which + // tool was asked for and with which arguments, so it can execute it and return a result. + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts).toHaveLength(1); + expect(starts[0]).toMatchObject({ type: "tool_call_start", id: "tu_1", name: "read_file" }); + expect(events.filter(e => e.type === "tool_call_delta").map(e => (e as { arguments: string }).arguments)) + .toEqual(['{"path":"fixture.txt"}']); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + // The malformed line still surfaces a protocol error + const err = events.find(e => e.type === "error") as { code?: string; message: string }; + expect(err).toBeDefined(); + expect(err.code).toBe("protocol_error"); + }); +}); + +describe("claude-cli stream-JSON chunk boundaries (offline)", () => { + test("a valid JSON frame split across multiple chunks is still parsed", async () => { + const frame = JSON.stringify({ type: "result", subtype: "success", is_error: false, usage: { input_tokens: 3, output_tokens: 1 } }); + // Split the frame across chunks at arbitrary byte boundaries + const bytes = enc.encode(frame + "\n"); + const stdout = [bytes.slice(0, 5), bytes.slice(5)]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, parsed()); + const done = events.find(e => e.type === "done") as { usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }; + expect(done).toBeDefined(); + expect(done.usage).toMatchObject({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }); + }); +}); + +describe("claude-cli cancellation during running turn (offline)", () => { + test("abort signal during streaming kills the child process", async () => { + let spawned = 0; + let killed = false; + const controller = new AbortController(); + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}\n'), + ]; + const child = fakeChild(stdout, { emitClose: false }); + child.kill = () => { killed = true; return true; }; + let promptFile = ""; + const adapter = createClaudeCliAdapter(provider(), { + spawn: (_command, args) => { + spawned++; + promptFile = args[args.indexOf("--system-prompt-file") + 1] ?? ""; + return child as unknown as ChildProcess; + }, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 50, + }); + // Abort on the FIRST client-visible event. That is the only deterministic point that is + // both after the spawn and inside the turn: a timer would race the spawn (the turn stages + // a prompt file first) and could land before the CLI was ever launched. + const evs: any[] = []; + let aborted = false; + await adapter.runTurn!( + parsed(), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), abortSignal: controller.signal }, + (event: AdapterEvent) => { + evs.push(event); + if (!aborted) { + aborted = true; + controller.abort(); + } + }, + ); + expect(spawned).toBe(1); + expect(aborted).toBe(true); + expect(killed).toBe(true); + const err = evs.find(e => e.type === "error"); + expect(err).toBeDefined(); + expect(err.message).toContain("aborted"); + // Cleanup is part of the abort contract, not a side effect of the happy path: the private + // staged prompt file is removed even when the turn died mid-stream (adapter.ts finally block). + expect(promptFile).not.toBe(""); + expect(existsSync(promptFile)).toBe(false); + }); +}); + +describe("claude-cli JSON Schema + caller-owned tools coexistence (offline)", () => { + test("textFormat.json_object is not forwarded as any CLI schema flag", () => { + // The json_schema half of this claim is covered by the propagation trace further down; + // this one covers the second textFormat type, which no other test exercises. + const args = buildArgs(CLAUDE_CLI_PROFILE, parsed({ + options: { + textFormat: { type: "json_object" }, + }, + }), provider()); + expect(args).not.toContain("--json-schema"); + expect(args).not.toContain("--output-schema"); + expect(args).not.toContain("--schema"); + }); +}); + +describe("claude-cli missing/duplicate/unknown result IDs in tool calls (offline)", () => { + const init = enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'); + + test("a repeated tool call ID is surfaced once per block and is never rejected", async () => { + // The upstream guard for a conflicting reuse (protocol.ts `isEmittedToolCallRepeat`, and + // the `previous.name !== name` branch at protocol.ts:597) reads `state.emittedToolCalls`. + // That snapshot is filled in exactly two places — protocol.ts:352 and protocol.ts:511 — + // both inside `if (state.completeAssistantToolUse)`, which only Qoder sets + // (src/adapters/qoder/adapter.ts:196). The claude-cli adapter + // (src/adapters/claude-cli/adapter.ts:241-254) never arms it, so the snapshot stays empty + // for the whole turn and the conflicting-reuse branch is unreachable here. + // + // Consequence, recorded as observed behavior and NOT as an endorsement: a repeated ID is + // neither deduplicated nor rejected, whether the repeat carries the same arguments or + // different ones. The client sees two calls under one ID and has to resolve them itself. + for (const secondArgs of ['{"path":"a"}', '{"path":"b"}']) { + const stdout = [ + init, + enc.encode(claudeCliToolFrame(0, "dup_id", CLAUDE_CLI_MCP_TOOL, '{"path":"a"}')), + enc.encode(claudeCliToolFrame(1, "dup_id", CLAUDE_CLI_MCP_TOOL, secondArgs)), + enc.encode('{"type":"stream_event","event":{"type":"message_stop"}}\n'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts).toHaveLength(2); + expect(starts.map(e => (e as { id: string }).id)).toEqual(["dup_id", "dup_id"]); + expect(events.filter(e => e.type === "tool_call_delta").map(e => (e as { arguments: string }).arguments)) + .toEqual(['{"path":"a"}', secondArgs]); + // The guard's own error is never raised, and the turn is served successfully. + expect(events.some(e => e.type === "error")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use" }); + } + }); + + test("a tool_result whose call id matches no prior tool call is replayed to the CLI unvalidated", async () => { + // The other half of the ID contract: a RESULT id the client sends back. Nothing on this + // route matches it against the calls of the turn: `formatMessageForHistory` + // (src/adapters/coding-agent/protocol.ts:758-763) interpolates `toolCallId` into + // `TOOL RESULT (call_id: ...)` and stops there — no lookup, no rejection, no orphan + // conversion, unlike the anthropic adapter's `orphanToolResultText` + // (src/adapters/anthropic.ts:718). The unmatched result reaches the model as a labelled + // block. Recorded as observed behavior, NOT as an endorsement. + const request = parsed({ + context: { + messages: [ + { role: "user", content: "Read the fixture", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_known", name: "read_file", arguments: { path: "fixture.txt" } }], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_unknown", toolName: "read_file", content: "result of a call that never happened", isError: false, timestamp: 2 }, + { role: "toolResult", toolCallId: "call_known", toolName: "a_different_tool", content: "result whose tool name does not match the call", isError: false, timestamp: 3 }, + ], + tools: [{ name: "read_file", description: "Read a file", parameters: { type: "object" } }], + }, + }); + const child = fakeChild([ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode('{"type":"result","subtype":"success"}\n'), + ]); + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: () => child as unknown as ChildProcess, + killGraceMs: 20, + }); + const events = await run(adapter, request); + const written = child.written.join(""); + // The unmatched id is passed through verbatim, next to the call that DOES match. + expect(written).toContain("TOOL RESULT (call_id: call_unknown)"); + expect(written).toContain("[Tool call: mcp__opencodex__read_file (call_id: call_known)"); + // Both result bodies reach the CLI, so the model sees the content and not only the labels. + expect(written).toContain("result of a call that never happened"); + expect(written).toContain("result whose tool name does not match the call"); + // A result whose tool NAME disagrees with the call it claims is not rejected either, and + // the name is not even rendered: the replay carries the id and the content, nothing else. + expect(written).toContain("TOOL RESULT (call_id: call_known)"); + expect(written).not.toContain("a_different_tool"); + // And the turn is served: an unmatched result id does not fail the request. + expect(events.some(e => e.type === "error")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("a tool_use block without an id never becomes a tool call", async () => { + const stdout = [init, enc.encode(claudeCliToolFrame(0, undefined, CLAUDE_CLI_MCP_TOOL, '{"path":"a"}'))]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + // A tool_use without an id opens no block: protocol.ts:591 reads the id with + // `asString(block?.id) ?? ""` and the whole block body is behind `if (id)`. The argument + // delta that follows therefore has no open block to attribute to, and the strict capture + // path (armed because this turn has a tool bridge) fails the turn at protocol.ts:570 + // instead of dropping the arguments silently and reporting a successful tool turn. + const err = events.find(e => e.type === "error"); + expect(err).toBeDefined(); + expect(err).toMatchObject({ code: "protocol_error", status: 502 }); + expect(String((err as { message: string }).message)).toContain("cannot be attributed to an open tool block"); + expect(events.some(e => e.type === "tool_call_start")).toBe(false); + }); +}); + +/** + * The observed offline contract for this route, as established by the source and by the tests + * below. + * + * SUPPORTED: + * - a request is served by a headless `claude -p` turn with built-in tools disabled and + * user/project/plugin MCP servers excluded (--tools "", --strict-mcp-config, + * --setting-sources "") — asserted by the existing argument tests in this file; + * - a declared tool catalog reaches the CLI as the isolated capture-only MCP catalog + * (--allowedTools + --mcp-config) and its call intent is returned to the client — + * asserted by tests/providers/claude-cli-tool-capture.test.ts; + * - the completion is represented as text deltas plus a terminal done, and usage comes from + * the result frame; + * - invalid or truncated final JSON is reported as protocol_error without discarding the tool + * calls captured before it. + * + * UNSUPPORTED: the requested output schema has no consumer on this route. options.textFormat is + * parsed off the Responses API request and consumed by the anthropic, google, openai-chat, + * ollama-native and cursor adapters; the claude-cli adapter reads neither it nor any schema + * flag, so a json_schema or json_object request is served as an ordinary text turn with the + * schema silently not applied. + * + * NOT ESTABLISHED, and deliberately left open: whether the Claude Code CLI itself accepts any + * structured-output flag at the version this proxy drives. Nothing in this repository records + * such a flag, and no live CLI was exercised to find one, so this suite makes no claim either + * way. The transport/dependency decision stays with @lidge-jun. + */ +describe("claude-cli schema propagation and completion contract (offline)", () => { + // A distinctive marker so a leaked schema would be visible in argv, in the staged prompt + // file, and in the replayed stdin frame alike. + const SCHEMA = { + type: "object" as const, + properties: { ocx_schema_probe_marker: { type: "string" } }, + required: ["ocx_schema_probe_marker"], + }; + const schemaRequest = (overrides: Partial = {}): OcxParsedRequest => parsed({ + options: { textFormat: { type: "json_schema", name: "probe", schema: SCHEMA } }, + ...overrides, + }); + const textDelta = (text: string): string => JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + }) + "\n"; + + test("the requested schema reaches neither the argument builder nor the staged input", async () => { + // Source trace: `buildArgs` (src/adapters/claude-cli/adapter.ts) reads parsed.modelId and + // parsed.options.reasoning. options.textFormat has no consumer on this route, so the + // schema is dropped before argv is built — argv is byte-identical with and without it. + const plain = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); + const withSchema = buildArgs(CLAUDE_CLI_PROFILE, schemaRequest(), provider()); + expect(withSchema).toEqual(plain); + expect(withSchema).not.toContain("--json-schema"); + expect(withSchema).not.toContain("--output-schema"); + expect(withSchema).not.toContain("--schema"); + expect(JSON.stringify(withSchema)).not.toContain("ocx_schema_probe_marker"); + + // The other two inputs this route builds are the staged system-prompt file and the + // replayed stream-json stdin frame. The schema reaches neither of them either. + let promptText = ""; + const child = fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]); + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + promptText = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return child as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + const events = await run(adapter, schemaRequest()); + expect(events.some(e => e.type === "error")).toBe(false); + expect(promptText).not.toContain("ocx_schema_probe_marker"); + expect(child.written.join("")).not.toContain("ocx_schema_probe_marker"); + }); + + test("a structured payload is surfaced as plain text and is never validated against the requested schema", async () => { + const payload = '{"answer":"42"}'; + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode(textDelta(payload)), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":4,"output_tokens":2}}\n'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, schemaRequest()); + // The payload deliberately VIOLATES the requested schema — it has no + // `ocx_schema_probe_marker` — so this case is only meaningful if the route does not + // enforce what it asked for. + expect(payload).not.toContain("ocx_schema_probe_marker"); + // How this route represents a final structured response: as ordinary text deltas plus a + // terminal done. There is no schema validation, no parse, and no conformance signal — + // the requested schema never reached the CLI, so the CLI could not have honoured it. + expect(events.map(e => e.type)).toEqual(["text_delta", "done"]); + expect((events[0] as { text: string }).text).toBe(payload); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "stop", usage: { inputTokens: 4, outputTokens: 2, totalTokens: 6 } }); + }); + + test("a streamed completion emits one text_delta per CLI delta, not one coalesced frame", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode(textDelta('{"answer":')), + enc.encode(textDelta('"42"}')), + // The CLI also sends the complete assistant message it already streamed. It must not + // produce a second copy of the text. + enc.encode(JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: '{"answer":"42"}' }], + stop_reason: "stop", + usage: { input_tokens: 4, output_tokens: 2 }, + }, + }) + "\n"), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":4,"output_tokens":2}}\n'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, schemaRequest()); + // The streamed path is the primary one; the complete-assistant fallback only runs when no + // partial delta was seen. Two CLI deltas therefore mean two client-visible deltas, and + // the fallback frame does not add a third copy of the same text. + const deltas = events.filter(e => e.type === "text_delta"); + expect(deltas.map(e => (e as { text: string }).text)).toEqual(['{"answer":', '"42"}']); + expect(events.map(e => e.type)).toEqual(["text_delta", "text_delta", "done"]); + }); + + test("tool-call events captured before a truncated final frame remain intact", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode(claudeCliToolFrame(0, "tu_1", CLAUDE_CLI_MCP_TOOL, '{"path":"a"}')), + // The turn dies at EOF: a final frame that was cut off mid-write. + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":5,'), + ]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 0 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest()); + // The truncated frame is reported, but the call captured before it is not discarded: the + // client still learns which tool was asked for and with which arguments, so it can execute + // it and return the result. + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts).toHaveLength(1); + expect(starts[0]).toMatchObject({ type: "tool_call_start", id: "tu_1", name: "read_file" }); + expect(events.filter(e => e.type === "tool_call_delta").map(e => (e as { arguments: string }).arguments)) + .toEqual(['{"path":"a"}']); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + const err = events.find(e => e.type === "error"); + expect(err).toBeDefined(); + expect(err).toMatchObject({ code: "protocol_error", status: 502 }); + expect(String((err as { message: string }).message)).toContain("Malformed stream-json frame received from coding-agent CLI"); + }); + + test("a caller-owned tool catalog and its results are unaffected by a requested schema", async () => { + let capturedArgs: string[] = []; + let promptText = ""; + const child = fakeChild([ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]); + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + capturedArgs = [...args]; + promptText = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return child as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + const events = await run(adapter, claudeCliToolRequest({ + options: { textFormat: { type: "json_schema", name: "probe", schema: SCHEMA } }, + context: { + messages: [ + { role: "user", content: "Read the fixture", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "fixture.txt" } }], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "the executed result", isError: false, timestamp: 2 }, + ], + tools: [{ name: "read_file", description: "Read a file", parameters: { type: "object" } }], + }, + })); + // The catalog still reaches the CLI exactly as it does without a schema... + expect(capturedArgs[capturedArgs.indexOf("--tools") + 1]).toBe(""); + expect(capturedArgs).toContain("--strict-mcp-config"); + expect(capturedArgs).toContain("--allowedTools"); + expect(capturedArgs).toContain("--mcp-config"); + // ...and the schema still reaches neither the argv nor either input the route builds, even + // when the replayed history carries the call and the executed result. + expect(JSON.stringify(capturedArgs)).not.toContain("ocx_schema_probe_marker"); + expect(promptText).not.toContain("ocx_schema_probe_marker"); + const written = child.written.join(""); + expect(written).not.toContain("ocx_schema_probe_marker"); + expect(written).toContain("TOOL RESULT (call_id: call_1)"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("the shared capture-only MCP flow tells the CLI that built-in tools and user MCP servers are disabled", async () => { + let promptText = ""; + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + promptText = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return fakeChild([ + enc.encode('{"type":"system","subtype":"init","mcp_servers":[{"name":"opencodex","status":"connected"}]}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]) as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + await run(adapter, claudeCliToolRequest()); + // The argv half of the capture-only contract is covered by the existing argument tests + // (--tools "", --strict-mcp-config, no permission bypass). The prompt half is this line: + // the turn also tells the CLI, in the instructions it actually reads, that it has no + // built-in tools and no user-configured MCP servers. + expect(promptText).toContain("Your built-in tools and user-configured MCP servers are disabled."); + expect(promptText).toContain("The MCP process captures call intent only."); + }); +}); From 3883e1ec8e258a28d05b4ed33e059f80305c91d9 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 22:14:03 +0900 Subject: [PATCH 04/25] fix(test): arm the real-home write guard when bun test skips the preload (#7467) * fix(test): arm the real-home write guard when bun test skips the preload Bun reads bunfig.toml from the current working directory, so a bun test started outside the repository loads no tests/preload.ts: HOME stays real, OPENCODEX_HOME is unset, and the guard stayed disarmed, letting OAuth persistence write the developer's real ~/.opencodex/auth.json. isTestHomeGuardArmed() now also arms when the entry module (Bun.main) has a Bun test-file name, and publishes OCX_TEST_HOME_GUARD=1 to spawned children. No production entrypoint matches; NODE_ENV is not consulted; an explicit OCX_TEST_HOME_GUARD=0 still disarms. Writers that touched the config dir before any guard (OAuth/Nous refresh intents, Kimi device id) now assert first. Fixes #7407 * test(guard): cover spawned-child inheritance and no production refusals (#7407) * fix(guard): normalise a stray OCX_TEST_HOME_GUARD so spawned helpers inherit the arming (#7407) * test(guard): assert Kimi and Nous writers still persist in production (#7407) * test(layout): register the unpreloaded guard suite on its sibling line to stay under the layout cap --- bunfig.toml | 10 +- scripts/test-layout/layout.json | 2 +- src/lib/test-home-guard.ts | 89 +++++- src/oauth/kimi.ts | 2 + src/oauth/nous.ts | 5 +- src/oauth/store.ts | 1 + structure/ops/test-sandbox-cleanup.md | 10 + .../test-home-guard-unpreloaded.test.ts | 260 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- tests/preload.ts | 9 +- .../live-service-manager-guard.test.ts | 4 +- 11 files changed, 372 insertions(+), 23 deletions(-) create mode 100644 tests/ci-workflows/test-home-guard-unpreloaded.test.ts diff --git a/bunfig.toml b/bunfig.toml index 1a6ced2c5dc..7376005a5c5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -16,8 +16,10 @@ root = "tests" # # That qualifier is load-bearing. Bun resolves bunfig.toml from the CURRENT WORKING # DIRECTORY, so `cd /tmp && bun test /tests/x.test.ts` loads no preload at all: nothing -# is sandboxed, the guard stays disarmed, and every `getConfigDir()` in that process returns -# the developer's real `~/.opencodex`. On 2026-09-15 such a run deleted one. A test that needs -# a config directory pins its own OPENCODEX_HOME instead of trusting this line, and -# `tests/ci-workflows/test-home-guard.test.ts` enforces that for the destructive case. +# is sandboxed, and every `getConfigDir()` in that process returns the developer's real +# `~/.opencodex`. On 2026-09-15 such a run deleted one, and in #7407 another wrote auth.json. +# The write guard now arms itself from the test-file entry in that case (src/lib/test-home-guard.ts), +# so such writes throw instead of landing, but a test that needs a config directory still pins its +# own OPENCODEX_HOME instead of trusting this line, and `tests/ci-workflows/test-home-guard.test.ts` +# enforces that for the destructive case. preload = ["./tests/preload.ts"] diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 60eee142abe..d3ec63cefb5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1820,7 +1820,7 @@ "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", diff --git a/src/lib/test-home-guard.ts b/src/lib/test-home-guard.ts index 252fde3c5d8..d218b248370 100644 --- a/src/lib/test-home-guard.ts +++ b/src/lib/test-home-guard.ts @@ -13,14 +13,15 @@ * 1. It must be INERT in production. Guessing "am I a test?" from ecosystem variables * like NODE_ENV would brick `NODE_ENV=test ocx ...` for a user who did nothing * wrong — worse than the bug it prevents. Arming requires OCX_TEST_HOME_GUARD=1, - * which only this repository's test preload sets. + * which this repository's test preload sets, or a process whose entry module is a + * Bun test file (see {@link isBunTestFileProcess}); no production entrypoint is one. * 2. It must fail CLOSED for code nobody has written yet. So it denies ONE path — the * captured production home — instead of allow-listing known-good test directories. * An allowlist would have to be opted into, and the test that forgets is exactly * how this incident happened. */ import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -109,8 +110,71 @@ export function protectedClaudeConfigDirsForTests(): readonly string[] { return PROTECTED_CLAUDE_TREES.map(tree => tree.path); } +/** + * The file names Bun's test runner discovers: `.test`, `_test`, `.spec` or `_spec` before a + * JS/TS extension. Under `bun test`, `Bun.main` names the test file being evaluated, in the + * main runner and in `--parallel` workers alike. Every production entrypoint — the CLI, the + * service daemon, the compiled sidecar (`/$bunfs/...`), a release script — has a different name. + */ +const BUN_TEST_FILE = /[._](?:test|spec)\.[cm]?[jt]sx?$/i; + +/** + * Whether this process runs a Bun test file as its entry module. + * + * This exists for the run that never loads `tests/preload.ts`. Bun resolves `bunfig.toml` from + * the CURRENT WORKING DIRECTORY, so `cd /tmp && bun test /tests/x.test.ts` skips the + * preload: HOME stays real, OPENCODEX_HOME stays unset, and before this check the guard stayed + * down, so an OAuth save landed in the developer's real `~/.opencodex/auth.json` (#7407). + * Bun exposes no runner flag, and NODE_ENV=test is both settable by users and overridable by the + * runner's caller, so the entry file's name is the one signal that production never carries. + * + * A file passed by explicit path under a non-test name (`bun test ./scratch.ts`) is not + * recognised; every repository suite is a `*.test.ts`, the only name `tests/test-layout.test.ts` admits. + * + * The converse also holds: `bun run ./something.test.ts` arms, because nothing distinguishes it + * from the runner. That is accepted. Requiring NODE_ENV=test as well would exempt every developer + * whose shell exports another NODE_ENV (`bun test` keeps an existing value), reopening #7407; + * the cost of the false positive is a refused real-home write from a script named like a test. + */ +export function isBunTestFileProcess(): boolean { + const main = (globalThis as { Bun?: { main?: unknown } }).Bun?.main; + const entry = typeof main === "string" ? main : process.argv[1]; + return typeof entry === "string" && BUN_TEST_FILE.test(basename(entry)); +} + +/** + * Armed when the preload (or a harness) set OCX_TEST_HOME_GUARD=1, or when this process runs a + * Bun test file and nobody set the flag. An explicit OCX_TEST_HOME_GUARD=0 is how a test case + * exercises the production branch of a guarded call, so it disarms even inside a test file. + */ export function isTestHomeGuardArmed(): boolean { - return process.env[GUARD_ENV] === "1"; + const flag = process.env[GUARD_ENV]; + if (flag === "1") return true; + if (flag === "0") return false; + return isBunTestFileProcess(); +} + +/** + * A test process that armed through its entry file publishes the flag, so the CLI and helper + * processes it spawns — whose own entry is not a test file — inherit the arming instead of + * writing the real home they also inherited. Runs at import, which in such a run happens before + * any test body. A production process never matches, so it never reaches the assignment. Any value + * but the explicit "0" opt-out is normalised, because an empty or stray value arms this process by + * name but would leave a non-test child, which cannot match by name, disarmed. + */ +if (process.env[GUARD_ENV] !== "0" && process.env[GUARD_ENV] !== "1" && isBunTestFileProcess()) { + process.env[GUARD_ENV] = "1"; +} + +/** + * Appended to a refusal when no preload ran, because the fix there is different: the test did + * not forget OPENCODEX_HOME, the whole run started from a directory without bunfig.toml. + */ +function missingPreloadHint(): string { + if (process.env.OCX_TEST_PRELOAD_PID !== undefined) return ""; + return " This test process did not load tests/preload.ts — Bun reads bunfig.toml from the " + + "current working directory — so nothing was sandboxed. Run bun test from the repository " + + "root, or pass --preload /tests/preload.ts."; } /** @@ -144,7 +208,8 @@ export function assertNotRealHomeUnderTest(dir: string): void { throw new Error( `refusing to write the real OpenCodex home (${PROTECTED_HOME}) from a test process. ` + "Point OPENCODEX_HOME at a temp directory for this test, or inject persistence " - + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).", + + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070)." + + missingPreloadHint(), ); } @@ -177,7 +242,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void { throw new Error( `refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. ` + "Point CODEX_HOME at a temp directory for this test before writing Codex files " - + "(auth.json, the catalog, models_cache.json, the journal or config.toml).", + + "(auth.json, the catalog, models_cache.json, the journal or config.toml)." + + missingPreloadHint(), ); } @@ -351,12 +417,13 @@ export function protectedRemovalReason(target: string): string | null { /** * Throw before a removal that would reach a protected tree. * - * Deliberately NOT gated on {@link isTestHomeGuardArmed}. Arming happens in `tests/preload.ts`, - * which Bun loads from the `bunfig.toml` it finds in the CURRENT WORKING DIRECTORY — so a run - * started outside the repository arms nothing, leaves OPENCODEX_HOME unset, and resolves the - * developer's real home. That unarmed run is precisely the one that caused the incident, so the - * refusal has to hold without it. Nothing in production calls this; the callers are test - * helpers, where the only cost of an unconditional check is a path comparison. + * Deliberately NOT gated on {@link isTestHomeGuardArmed}. `tests/preload.ts` is loaded from the + * `bunfig.toml` Bun finds in the CURRENT WORKING DIRECTORY, so a run started outside the + * repository leaves OPENCODEX_HOME unset and resolves the developer's real home. Such a run now + * arms through its test-file entry, but a helper launched as a plain script does not, and the + * incident was exactly this unsandboxed shape, so the refusal has to hold without arming. + * Nothing in production calls this; the callers are test helpers, where the only cost of an + * unconditional check is a path comparison. */ export function assertRemovalOutsideProtectedTrees(target: string): void { const reason = protectedRemovalReason(target); diff --git a/src/oauth/kimi.ts b/src/oauth/kimi.ts index 1d2f346e09e..1189e9725c3 100644 --- a/src/oauth/kimi.ts +++ b/src/oauth/kimi.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { getConfigDir } from "../config"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -107,6 +108,7 @@ function getDeviceId(): string { if ((e as { code?: string })?.code !== "ENOENT") throw e; } const id = randomUUID().replace(/-/g, ""); + assertNotRealHomeUnderTest(getConfigDir()); recordOwnedConfigPath(getConfigDir(), p); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeFileSync(p, id + "\n", { mode: 0o600 }); diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 21ae5211edf..3a1a222bb8e 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -35,10 +35,11 @@ */ import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { OAuthController, OAuthCredentials } from "./types"; import { getAuthStorePath } from "./store"; import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com"; @@ -220,6 +221,8 @@ function readRefreshIntent(refreshToken: string): RefreshIntent | undefined { } function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void { + // Before the mkdir/chmod below, which would otherwise touch the real home first. + assertNotRealHomeUnderTest(dirname(getAuthStorePath())); const dir = refreshIntentDir(); // Hardened, owner-only directory + atomic (temp+rename) write. Throws on // failure so the caller can fail closed instead of refreshing blind. diff --git a/src/oauth/store.ts b/src/oauth/store.ts index ca84b1f947e..d35c07e5da0 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -203,6 +203,7 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu } export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): OAuthRefreshIntent { const dir = getConfigDir(); + assertNotRealHomeUnderTest(dir); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); hardenConfigDir(); const intent: OAuthRefreshIntent = { diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index 42c7025507d..882147d6d1c 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -52,6 +52,16 @@ protected roots resolve at check time and compare case-insensitively on macOS an directory is also a protected removal tree. `tests/claude-integration/claude-config-home-isolation.test.ts` pins each layer. +The write guard arms without the preload too. Bun reads `bunfig.toml` from the current working +directory, so `bun test` started outside the repository loads no preload and keeps the real HOME. +`isTestHomeGuardArmed()` therefore also arms when the entry module (`Bun.main`) has a Bun +test-file name, and publishes `OCX_TEST_HOME_GUARD=1` so the processes it spawns inherit the arming. No +production entrypoint has such a name, and `NODE_ENV` is never consulted. An explicit +`OCX_TEST_HOME_GUARD=0` disarms, which is how a case exercises a production branch. Such a run +refuses OAuth, Codex account-pool, refresh-intent, `auth.json` and config writes instead of +sandboxing them; `tests/ci-workflows/test-home-guard-unpreloaded.test.ts` drives each one from a +cwd without `bunfig.toml` against a fake real home, and checks that a plain `bun` process still persists (#7407). + `tests/helpers/test-sandbox-cleanup.ts` exposes case-scoped lifecycle ownership: cancellation starts listener stops while owned asynchronous work settles, and repeated close/stop calls share one promise. After teardown starts only the lifecycle's own abort reason is absorbed; any other diff --git a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts new file mode 100644 index 00000000000..9449f94d6cb --- /dev/null +++ b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts @@ -0,0 +1,260 @@ +/** + * #7407: a `bun test` that never loads tests/preload.ts must still refuse credential writes. + * + * Bun reads bunfig.toml from the CURRENT WORKING DIRECTORY, so + * `cd elsewhere && bun test /tests/x.test.ts` runs with the real HOME, no OPENCODEX_HOME, + * and (before this fix) a disarmed guard: one such run added an account to a developer's real + * ~/.opencodex/auth.json and made it active. Every case here spawns exactly that shape, a test + * file outside the repository run from a cwd with no bunfig.toml, against a temp directory + * posing as the real home, so nothing in this suite can reach the developer's own. + * + * The negative case matters as much: the same writes from a plain `bun` process, even with + * NODE_ENV=test, must still land. Production persistence is unchanged. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; + +const REPO_ROOT_URL = pathToFileURL(repoRoot() + "/").href; +const CHILD_MS = watchdogMs(20_000); +const SENTINEL_AUTH = JSON.stringify({ sentinel: { activeAccountId: "real", accounts: [] } }, null, 2) + "\n"; +const SENTINEL_CODEX_AUTH = JSON.stringify({ tokens: { access_token: "real-codex-sentinel" } }) + "\n"; + +const handles: TempHome[] = []; +afterEach(() => { for (const handle of handles.splice(0)) handle.remove(); }); + +/** A temp root this suite owns and removes after each case. */ +function owned(prefix: string): string { + const handle = createTempHome(prefix); + handles.push(handle); + return handle.root; +} + +/** A directory posing as the developer's home, seeded with live-looking credential stores. */ +function fakeRealHome(): string { + const home = owned("ocx-7407-real-home-"); + mkdirSync(join(home, ".opencodex"), { recursive: true, mode: 0o700 }); + mkdirSync(join(home, ".codex"), { recursive: true, mode: 0o700 }); + writeFileSync(join(home, ".opencodex", "auth.json"), SENTINEL_AUTH, { mode: 0o600 }); + writeFileSync(join(home, ".codex", "auth.json"), SENTINEL_CODEX_AUTH, { mode: 0o600 }); + return home; +} + +/** Every entry under the two credential homes, with its mode and content hash. */ +function snapshot(home: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + const digest = stat.isFile() ? createHash("sha256").update(readFileSync(path)).digest("hex") : ""; + out.push(relative(home, path) + " " + stat.mode.toString(8) + " " + digest); + if (stat.isDirectory()) walk(path); + } + }; + for (const tree of [".opencodex", ".codex"]) { + out.push(tree + " " + lstatSync(join(home, tree)).mode.toString(8)); + walk(join(home, tree)); + } + return out; +} + +/** A developer shell's environment: real HOME, nothing sandboxed, no preload receipts. */ +function unsandboxedEnv(home: string, extra: Record = {}): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value; + for (const key of ["OCX_TEST_HOME_GUARD", "OCX_REAL_HOME", "OCX_TEST_PRELOAD_PID", "OPENCODEX_HOME", "CODEX_HOME", "NODE_ENV"]) { + delete env[key]; + } + return { ...env, HOME: home, USERPROFILE: home, ...extra }; +} + +async function run(args: string[], cwd: string, env: Record) { + const child = Bun.spawn([process.execPath, ...args], { cwd, env, stdout: "pipe", stderr: "pipe" }); + const timer = setTimeout(() => child.kill("SIGKILL"), CHILD_MS); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + return { stdout, stderr, code }; + } finally { + clearTimeout(timer); + } +} + +function report(stdout: string, stderr: string): Record { + const line = (stdout + "\n" + stderr).split("\n").find(entry => entry.startsWith("REPORT ")); + if (!line) throw new Error("child printed no REPORT line:\n" + stdout + "\n" + stderr); + return JSON.parse(line.slice("REPORT ".length)) as Record; +} + +/** + * One call per credential-write entry path. A call counts as REFUSED only when its error chain + * carries the guard's own message, so an unrelated failure cannot pass as protection. + */ +const ATTEMPTS = String.raw` + const repo = ${JSON.stringify(REPO_ROOT_URL)}; + const out: Record = {}; + const guard = await import(repo + "src/lib/test-home-guard.ts"); + out.armed = String(guard.isTestHomeGuardArmed()); + out.flag = String(process.env.OCX_TEST_HOME_GUARD); + out.preload = String(process.env.OCX_TEST_PRELOAD_PID); + globalThis.fetch = (async () => { throw new Error("network disabled in this probe"); }) as never; + const config = await import(repo + "src/config.ts"); + const store = await import(repo + "src/oauth/store.ts"); + const codexStore = await import(repo + "src/codex/account-store.ts"); + const kimi = await import(repo + "src/oauth/kimi.ts"); + const nous = await import(repo + "src/oauth/nous.ts"); + out.configDir = config.getConfigDir(); + const cred = { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3_600_000, email: "fixture@example.test" }; + const chain = (error: unknown): string => { + const parts: string[] = []; + for (let e: any = error, i = 0; e && i < 6; e = e.cause, i++) parts.push(String(e?.message ?? e)); + return parts.join(" <- "); + }; + const attempt = async (name: string, fn: () => unknown) => { + try { await fn(); out[name] = "WROTE"; } + catch (error) { + const text = chain(error); + out[name] = /refusing to write the real (OpenCodex|Codex) home/.test(text) ? "REFUSED" : "OTHER: " + text.slice(0, 300); + if (name === "saveCredential") out.hint = String(text.includes("did not load tests/preload.ts")); + } + }; + await attempt("saveCredential", () => store.saveCredential("anthropic", cred)); + await attempt("upsertCredentialByIdentity", () => store.upsertCredentialByIdentity("kimi", cred)); + await attempt("saveAccountCredential", () => store.saveAccountCredential("kimi", "fixture-account", cred)); + await attempt("setActiveAccount", () => store.setActiveAccount("kimi", "fixture-account")); + await attempt("removeAccount", () => store.removeAccount("kimi", "fixture-account")); + await attempt("mergeAccountCredential", () => store.mergeAccountCredential("kimi", "fixture-account", cred)); + await attempt("writeOAuthRefreshIntent", () => store.writeOAuthRefreshIntent("kimi", "fixture-account", "generation")); + await attempt("refreshIntentLock", async () => (await store.createOAuthRefreshIntentLock("kimi", "fixture-account").acquire()).release()); + await attempt("saveCodexAccountCredential", () => codexStore.saveCodexAccountCredential("fixture-codex", { accessToken: "a", refreshToken: "r", accountId: "acct", expiresAt: Date.now() + 3_600_000 } as never)); + await attempt("codexAuthJsonMirror", () => config.atomicWriteFile("__CODEX_AUTH__", "{}\n")); + await attempt("saveConfig", () => config.saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never)); + await attempt("kimiDeviceId", () => kimi.refreshKimiToken("fixture-refresh")); + await attempt("nousRefreshIntent", () => nous.refreshNousToken("fixture-refresh")); + console.log("REPORT " + JSON.stringify(out)); +`; + +const WRITE_PATHS = [ + "saveCredential", "upsertCredentialByIdentity", "saveAccountCredential", "setActiveAccount", "removeAccount", + "mergeAccountCredential", "writeOAuthRefreshIntent", "refreshIntentLock", "saveCodexAccountCredential", + "codexAuthJsonMirror", "saveConfig", "kimiDeviceId", "nousRefreshIntent", +]; + +/** + * A helper whose entry is NOT a test file, spawned from inside the unpreloaded test. It cannot + * arm itself by name, so it is protected only if it inherits the flag the test process published. + */ +const CHILD_HELPER = String.raw` + const store = await import(${JSON.stringify(REPO_ROOT_URL)} + "src/oauth/store.ts"); + try { + await store.saveCredential("anthropic", { access: "child", refresh: "child", expires: Date.now() + 3_600_000, email: "child@example.test" }); + console.log("CHILD WROTE"); + } catch (error) { + console.log(/refusing to write the real OpenCodex home/.test(String((error as Error)?.message)) ? "CHILD REFUSED" : "CHILD OTHER"); + } +`; + +function writeFixture(dir: string, name: string, home: string, asTest: boolean): string { + // JSON-encoded so a Windows path keeps its backslashes inside the child's string literal. + let body = ATTEMPTS.replace('"__CODEX_AUTH__"', JSON.stringify(join(home, ".codex", "auth.json"))); + if (asTest) { + const helper = join(dir, "spawned-helper.ts"); + writeFileSync(helper, CHILD_HELPER); + body = body.replace(' console.log("REPORT "', " const spawned = Bun.spawnSync([process.execPath, \"run\", " + JSON.stringify(helper) + "], { env: process.env });\n" + + " out.child = /CHILD (\\w+)/.exec(spawned.stdout.toString())?.[1] ?? \"none\";\n" + + ' console.log("REPORT "'); + } + const source = asTest + ? 'import { test } from "bun:test";\ntest("unpreloaded writes", async () => {\n' + body + "\n}, 30_000);\n" + : body; + const path = join(dir, name); + writeFileSync(path, source); + return path; +} + +describe("bun test without the preload (#7407)", () => { + test("arms the guard and refuses every credential write to the real home", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-cwd-"); + expect(existsSync(join(outside, "bunfig.toml"))).toBe(false); + const fixture = writeFixture(outside, "unpreloaded-writes.test.ts", home, true); + const before = snapshot(home); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home)); + const seen = report(result.stdout, result.stderr); + + // The incident's shape: no preload ran and the config dir is the real one. + expect(seen.preload).toBe("undefined"); + expect(seen.configDir).toBe(join(home, ".opencodex")); + // Armed anyway, and published so CLI children it spawns inherit the arming. + expect(seen.armed).toBe("true"); + expect(seen.flag).toBe("1"); + for (const path of WRITE_PATHS) expect({ path, outcome: seen[path] }).toEqual({ path, outcome: "REFUSED" }); + expect(seen.hint).toBe("true"); + // A spawned helper with a non-test entry inherits the arming instead of writing the real home. + expect(seen.child).toBe("REFUSED"); + // Nothing landed, nothing was created, and no mode changed. + expect(snapshot(home)).toEqual(before); + expect(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")).toBe(SENTINEL_AUTH); + }, CHILD_MS + 5_000); + + test("a plain bun process with NODE_ENV=test still persists (production is unchanged)", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-prod-cwd-"); + const script = writeFixture(outside, "write-credentials.ts", home, false); + + const result = await run(["run", script], outside, unsandboxedEnv(home, { NODE_ENV: "test" })); + const seen = report(result.stdout, result.stderr); + + expect(seen.armed).toBe("false"); + expect(seen.flag).toBe("undefined"); + // fetch is disabled, so a network path may fail otherwise, but none may hit the guard. + for (const path of WRITE_PATHS) expect({ path, refused: seen[path] === "REFUSED" }).toEqual({ path, refused: false }); + expect(seen.saveCredential).toBe("WROTE"); + expect(seen.saveConfig).toBe("WROTE"); + expect(seen.codexAuthJsonMirror).toBe("WROTE"); + const auth = JSON.parse(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")) as Record; + expect(Object.keys(auth)).toContain("anthropic"); + // The writers that now assert first still persist before their network call fails. + expect(readFileSync(join(home, ".opencodex", "kimi-device-id"), "utf8").trim()).not.toBe(""); + const intents = readdirSync(join(home, ".opencodex", ".nous-refresh-intent")).filter(name => name.endsWith(".json")); + expect(intents.length).toBe(1); + expect(JSON.parse(readFileSync(join(home, ".opencodex", ".nous-refresh-intent", intents[0]!), "utf8")).status).toBeString(); + }, CHILD_MS + 5_000); + + // Only "0" opts out; any other value is normalised to "1" before a helper can inherit it. + test.each(["", "yes"])("a stray OCX_TEST_HOME_GUARD=%p is normalised so spawned helpers stay armed", async stray => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-stray-cwd-"); + const fixture = writeFixture(outside, "stray-flag.test.ts", home, true); + const before = snapshot(home); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home, { OCX_TEST_HOME_GUARD: stray })); + const seen = report(result.stdout, result.stderr); + + expect(seen.armed).toBe("true"); + expect(seen.flag).toBe("1"); + expect(seen.child).toBe("REFUSED"); + expect(snapshot(home)).toEqual(before); + }, CHILD_MS + 5_000); + + test("an explicit OCX_TEST_HOME_GUARD=0 still disarms a test file", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-optout-cwd-"); + const fixture = join(outside, "optout.test.ts"); + writeFileSync(fixture, 'import { test } from "bun:test";\ntest("x", async () => {\n' + + " const guard = await import(" + JSON.stringify(REPO_ROOT_URL + "src/lib/test-home-guard.ts") + ");\n" + + ' console.log("REPORT " + JSON.stringify({ armed: String(guard.isTestHomeGuardArmed()) }));\n});\n'); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home, { OCX_TEST_HOME_GUARD: "0" })); + + expect(report(result.stdout, result.stderr).armed).toBe("false"); + }, CHILD_MS + 5_000); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 563b2d53550..da880bb6d44 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1232,7 +1232,8 @@ "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", + "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", diff --git a/tests/preload.ts b/tests/preload.ts index ee4f990913c..0e66597830a 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -10,10 +10,11 @@ * * It does still depend on WHERE the run starts. Bun resolves bunfig.toml from the current * working directory, so a run launched outside the repository never loads this file: no - * sandbox, no arming, and getConfigDir() resolves the real ~/.opencodex. On 2026-09-15 a - * run of that shape deleted a live home. Nothing here can close that hole from inside, so - * a test that needs a config directory pins its own OPENCODEX_HOME rather than inheriting - * one, and tests/ci-workflows/test-home-guard.test.ts enforces it for destructive calls. + * sandbox, and getConfigDir() resolves the real ~/.opencodex. On 2026-09-15 a run of that + * shape deleted a live home. The write guard covers that run by arming from its test-file + * entry (#7407), which turns such writes into refusals, but nothing here can sandbox it, so a + * test that needs a config directory pins its own OPENCODEX_HOME rather than inheriting one, + * and tests/ci-workflows/test-home-guard.test.ts enforces it for destructive calls. * (devlog `_plan/260730_codex_rs_upstream_v2_live_handoff/070`.) * * Import order below is load-bearing: importing the guard captures the real home at diff --git a/tests/service/live-service-manager-guard.test.ts b/tests/service/live-service-manager-guard.test.ts index 31104da2477..60cfc1449cb 100644 --- a/tests/service/live-service-manager-guard.test.ts +++ b/tests/service/live-service-manager-guard.test.ts @@ -65,7 +65,9 @@ describe("live service-manager guard", () => { }); test("is inert in production, where the guard is not armed", () => { - delete process.env[GUARD_ENV]; + // "0", not an unset flag: a process whose entry is a test file arms itself when the flag is + // absent (#7407), so the explicit opt-out is what stands in for a production process here. + process.env[GUARD_ENV] = "0"; expect(() => assertLiveServiceManagerAllowed("systemctl --user stop opencodex-proxy.service"), ).not.toThrow(); From 944647100cd050989f25efb3b2fec8be9846ca48 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 22:14:31 +0900 Subject: [PATCH 05/25] test: make update recovery, messages cancellation, and CLI parity harnesses deterministic (#7468) * test: make service recovery, messages cancellation, and CLI parity harnesses deterministic - update-job: the service-to-direct recovery cases ran the host's lsof listener scan twice and relied on the armed test guard refusing launchctl. Inject the scan and an explicit "failed" manager outcome, and fail any scenario that starts a host process. (#7417) - alternate-main cancellation: messages ingress dialed a real Codex WebSocket before falling back to the mocked fetch. Pin upstreamWebsocket: false and assert that no case dials a socket. (#7418) - cli-headless-parity: the fake management runtime bound the wildcard address, so another process binding 127.0.0.1 on the same port received the CLI's request and answered 404. Bind 127.0.0.1, await server shutdown, and assert exclusive ownership of the port. (#7416) * test: move the host-free recovery helpers out of update-job.test.ts to stay under the file-size cap * test: refuse host process launches in recovery scenarios instead of observing them * test: keep the Windows wrapper cleanup refused but uncounted in host-free recovery scenarios --- tests/cli/cli-headless-parity.test.ts | 21 +++++++- tests/helpers/update-recovery-host-free.ts | 51 +++++++++++++++++++ ...ponses-alternate-main-cancellation.test.ts | 15 +++++- tests/update/update-job.test.ts | 36 ++++++++----- 4 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 tests/helpers/update-recovery-host-free.ts diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 47a5721e8c8..160edf50f65 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -477,14 +477,18 @@ describe("ocx agent sidecar --list (#2188)", () => { }); }); -afterEach(() => { - for (const server of servers.splice(0)) server.stop(true); +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.stop(true))); process.exitCode = 0; }); function fakeRuntime(responder?: (req: Request, body: unknown) => unknown) { const requests: Recorded[] = []; const server = Bun.serve({ + // Bind the exact address the CLI dials (#7416). A wildcard bind lets another process on a + // loaded host bind 127.0.0.1 on the same port; it then receives this fake's requests, and a + // real proxy there answers the management call with 404. + hostname: "127.0.0.1", port: 0, async fetch(req) { const url = new URL(req.url); @@ -1807,6 +1811,19 @@ describe("ownership recovery advice does not assume injection is enabled", () => } }); +test("the fake runtime owns the loopback address the CLI dials", async () => { + const { deps } = fakeRuntime(); + const port = Number(new URL(deps.baseUrl).port); + let intruder: ReturnType | undefined; + try { + intruder = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response(null, { status: 404 }) }); + } catch (error) { + expect((error as { code?: string }).code).toBe("EADDRINUSE"); + } + await intruder?.stop(true); + expect(intruder).toBeUndefined(); +}); + test("Claude intercept start uses the management POST and preserves refusal exit status", async () => { const success = fakeRuntime(); expect(await handleClaudeInterceptCommand(["start", "--json"], success.deps)).toBe(0); diff --git a/tests/helpers/update-recovery-host-free.ts b/tests/helpers/update-recovery-host-free.ts new file mode 100644 index 00000000000..9bc9aa7ea4d --- /dev/null +++ b/tests/helpers/update-recovery-host-free.ts @@ -0,0 +1,51 @@ +import { expect } from "bun:test"; + +/** + * Service-recovery scenarios answer every host probe themselves (#7417). On macOS the default + * listener scan is `lsof -iTCP`, which walks every process's descriptors: ~50ms on an idle + * host, seconds once concurrent suites fill the process table. A service-to-direct fallback + * scans twice, and that alone pushed these cases past Bun's 5s budget. The service-manager + * step is answered explicitly too, instead of relying on the armed test guard refusing the + * real launchctl/systemctl call. + */ +export const HOST_FREE_RECOVERY_IO = { + listListenPidsFn: (): number[] => [], + restartServiceManagerFn: async () => "failed" as const, +}; + +/** + * Runs a scenario with host process launches refused, and fails it if it attempted any. The + * launch is refused rather than observed, so a regressed path neither waits on the command nor + * touches the host; the record still fails the case if production code swallows the refusal. + * + * One attempt is expected on a real Windows host and only there: the recovery path's + * best-effort scheduler-wrapper cleanup (`killWindowsSchedulerWrappers`) launches PowerShell, + * scoped to this test home's own wrapper paths, and swallows any failure. It is refused like + * everything else, so it does no host work, but it is not counted against the scenario. + */ +export async function withoutHostProcesses(run: () => Promise): Promise { + const bun = Bun as unknown as { spawnSync: (...args: unknown[]) => unknown }; + const original = bun.spawnSync; + const spawned: string[] = []; + bun.spawnSync = (...args: unknown[]) => { + const first = args[0] as string[] | { cmd?: string[] } | undefined; + const command = String(Array.isArray(first) ? first[0] : first?.cmd?.[0]); + spawned.push(command); + throw new Error(`unexpected host process launch: ${command}`); + }; + let outcome: { ok: true; value: T } | { ok: false; error: unknown }; + try { + outcome = { ok: true, value: await run() }; + } catch (error) { + outcome = { ok: false, error }; + } finally { + bun.spawnSync = original; + } + expect(spawned.filter(command => !isWindowsWrapperCleanup(command))).toEqual([]); + if (!outcome.ok) throw outcome.error; + return outcome.value; +} + +function isWindowsWrapperCleanup(command: string): boolean { + return process.platform === "win32" && /[\\/]powershell\.exe$/i.test(command); +} diff --git a/tests/responses/responses-alternate-main-cancellation.test.ts b/tests/responses/responses-alternate-main-cancellation.test.ts index 0fb94bc6b9a..08576b160b9 100644 --- a/tests/responses/responses-alternate-main-cancellation.test.ts +++ b/tests/responses/responses-alternate-main-cancellation.test.ts @@ -21,6 +21,7 @@ import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; +const originalWebSocket = globalThis.WebSocket; const POOL_ID = "alternate-cancel-pool"; let root: string; let authPath: string; @@ -28,6 +29,11 @@ let previousCodexHome: string | undefined; let previousOcxHome: string | undefined; let releaseSpendHome: (() => void) | undefined; let config: OcxConfig; +// Every upstream answer comes from the mocked fetch. Before #7418 the messages ingress also +// dialed a real Codex WebSocket first and only reached the mock on fallback, so each case +// waited on live DNS/TLS; under load that alone outran the 5s budget. Any dial is recorded +// and refused, and each case asserts there were none. +let webSocketDials: string[] = []; function quotaRefusal(): Response { return Response.json({ error: { code: "usage_limit_reached", message: "fixture pool quota exhausted" } }, { @@ -93,16 +99,22 @@ beforeEach(() => { defaultProvider: "openai", activeCodexAccountId: POOL_ID, autoSwitchThreshold: 0, accountPoolStrategy: "fill-first", providers: { openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "pool", - baseUrl: "https://chatgpt.com/backend-api/codex" } }, + baseUrl: "https://chatgpt.com/backend-api/codex", upstreamWebsocket: false } }, codexAccounts: [{ id: POOL_ID, label: "fixture pool" }], } as OcxConfig; globalThis.fetch = (async () => { throw new Error("unexpected mocked network call"); }) as typeof fetch; + webSocketDials = []; + globalThis.WebSocket = function refusedWebSocket(url: string | URL) { + webSocketDials.push(String(url)); + throw new Error("unexpected WebSocket dial"); + } as unknown as typeof WebSocket; }); afterEach(() => { releaseSpendHome?.(); releaseSpendHome = undefined; globalThis.fetch = originalFetch; + globalThis.WebSocket = originalWebSocket; clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); clearAccountNeedsReauth(POOL_ID); clearAccountQuota(); @@ -234,6 +246,7 @@ for (const entry of ["retry helper", "messages ingress"] as const) { turn?.release(); budget.dispose(); } + expect(webSocketDials).toEqual([]); }); } }); diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts index 0b945ff2935..169f3abdde1 100644 --- a/tests/update/update-job.test.ts +++ b/tests/update/update-job.test.ts @@ -23,6 +23,7 @@ import { } from "../../src/update/job"; import { checkUpdatePackageIntegrity, updateCommand, updateCommandStr } from "../../src/update/index"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { HOST_FREE_RECOVERY_IO, withoutHostProcesses } from "../helpers/update-recovery-host-free"; type SpawnResult = { status: number | null; stdout: string }; function fakeSpawn(result: SpawnResult): typeof import("node:child_process").spawnSync { @@ -920,7 +921,8 @@ describe("GUI update execution decisions", () => { const prev = process.env.OCX_BAKE_PORT; delete process.env.OCX_BAKE_PORT; try { - await restartAfterUpdateForTests(job, { port: 18765, hostname: "127.0.0.1" }, { + await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 18765, hostname: "127.0.0.1" }, { + ...HOST_FREE_RECOVERY_IO, serviceInstalledFn: () => true, serviceViableFn: () => true, // The service-recovery gate now asks the port before skipping the direct-start @@ -938,7 +940,7 @@ describe("GUI update execution decisions", () => { bakeDuringInstall.push(process.env.OCX_BAKE_PORT ?? ""); return { status: 0 }; }, - }); + })); expect(waited).toEqual([{ port: 18765, hostname: "127.0.0.1" }]); expect(bakeDuringInstall).toEqual(["18765"]); expect(process.env.OCX_BAKE_PORT).toBeUndefined(); @@ -967,7 +969,8 @@ describe("GUI update execution decisions", () => { const prevService = process.env.OCX_SERVICE; delete process.env.OCX_SERVICE; try { - await restartAfterUpdateForTests(job, { port: 19999, hostname: "127.0.0.1" }, { + await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19999, hostname: "127.0.0.1" }, { + ...HOST_FREE_RECOVERY_IO, serviceInstalledFn: () => true, serviceViableFn: () => false, waitForPort: async () => true, @@ -975,7 +978,7 @@ describe("GUI update execution decisions", () => { spawnStart: (_job, _installer, port) => { spawned.push({ port: port ?? 0 }); }, - }); + })); // The fallback must fire: direct proxy start instead of throwing. expect(spawned).toEqual([{ port: 19999 }]); } finally { @@ -1011,7 +1014,8 @@ describe("GUI update execution decisions", () => { const prevService = process.env.OCX_SERVICE; process.env.OCX_SERVICE = "1"; try { - await restartAfterUpdateForTests(job, { port: 19998, hostname: "127.0.0.1" }, { + await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19998, hostname: "127.0.0.1" }, { + ...HOST_FREE_RECOVERY_IO, platform: "win32", serviceInstalledFn: () => true, serviceViableFn: () => true, @@ -1025,7 +1029,7 @@ describe("GUI update execution decisions", () => { spawnStart: (_job, _installer, port) => { spawned.push({ port: port ?? 0 }); }, - }); + })); // The refresh ran, and it ran the non-registering subcommand. expect(ranService.length).toBe(1); expect(ranService[0]).toContain("repair"); @@ -1055,7 +1059,8 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); - await expect(restartAfterUpdateForTests(job, { port: 19010, hostname: "127.0.0.1" }, { + await expect(withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19010, hostname: "127.0.0.1" }, { + ...HOST_FREE_RECOVERY_IO, platform: "win32", serviceInstalledFn: () => true, serviceViableFn: () => true, @@ -1063,7 +1068,7 @@ describe("GUI update execution decisions", () => { runService: () => ({ status: null, signal: "SIGTERM", timedOut: true }), spawnStart: (_job, _installer, port) => { spawned.push(port ?? 0); }, probeProxy: async () => false, - })).rejects.toThrow(/state unknown.*refusing a competing direct start/i); + }))).rejects.toThrow(/state unknown.*refusing a competing direct start/i); expect(spawned).toEqual([]); const log = readUpdateJob(job.id)?.log.join("\n") ?? ""; expect(log).toContain("refusing a competing direct start"); @@ -1089,7 +1094,8 @@ describe("GUI update execution decisions", () => { const prevService = process.env.OCX_SERVICE; delete process.env.OCX_SERVICE; try { - await restartAfterUpdateForTests(job, { port: 19100, hostname: "127.0.0.1" }, { + await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19100, hostname: "127.0.0.1" }, { + ...HOST_FREE_RECOVERY_IO, serviceInstalledFn: () => true, // Installed but stale/missing assets — the status line users see after a dead update. serviceViableFn: () => false, @@ -1098,7 +1104,7 @@ describe("GUI update execution decisions", () => { spawnStart: (_job, _installer, port) => { spawned.push({ port: port ?? 0 }); }, - }); + })); expect(spawned).toEqual([{ port: 19100 }]); expect(readUpdateJob(job.id)?.log.some(line => line.includes("not viable") && line.includes("direct proxy start"), @@ -1127,11 +1133,12 @@ describe("GUI update execution decisions", () => { log: [], }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); - const ok = await finishGuiUpdateRestart( + const ok = await withoutHostProcesses(() => finishGuiUpdateRestart( job, { port: 10100, hostname: "127.0.0.1", oldPid: 501 }, "npm", { + ...HOST_FREE_RECOVERY_IO, // No Background Service installed — interactive dashboard update must still recover. serviceInstalledFn: () => false, serviceViableFn: () => false, @@ -1144,7 +1151,7 @@ describe("GUI update execution decisions", () => { now: () => now, sleepMs: async (ms) => { now += ms; }, }, - ); + )); expect(ok).toBe(true); expect(restartCalls).toBe(1); expect(readUpdateJob(job.id)?.log.some(line => line.includes("skipping redundant restart"))).toBe(false); @@ -1898,7 +1905,8 @@ describe("service recovery is health-gated, not viability-gated", () => { const prevService = process.env.OCX_SERVICE; delete process.env.OCX_SERVICE; try { - await restartAfterUpdateForTests(healthGateJob(id), { port: 18765, hostname: "127.0.0.1" }, { + await withoutHostProcesses(() => restartAfterUpdateForTests(healthGateJob(id), { port: 18765, hostname: "127.0.0.1" }, { + ...HOST_FREE_RECOVERY_IO, serviceInstalledFn: () => true, runService: () => ({ status: 0 }), waitForPort: async () => true, @@ -1906,7 +1914,7 @@ describe("service recovery is health-gated, not viability-gated", () => { sleepMs: async ms => { now += ms; }, now: () => now, ...io, - }); + })); } finally { if (prevService === undefined) delete process.env.OCX_SERVICE; else process.env.OCX_SERVICE = prevService; From cdcd39ed45a6cc867ef91269943a02d7f3ecf1b7 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 22:14:59 +0900 Subject: [PATCH 06/25] fix(auth): refuse Codex runtime AgentAssertion auth and scope dual_header assertions to Direct (#7470) * fix(auth): refuse Codex runtime AgentAssertion auth and scope dual_header assertions to Direct Codex 0.164 runtime agent instances (openai/codex#55402, #55636) can send Authorization: AgentAssertion (assertion_only) or a Bearer plus x-openai-agent-assertion (dual_header). The legacy replace(/^Bearer\s+/i) reads passed an AgentAssertion through as an opaque bearer, and a pooled route then silently replaced it with the pool account's Bearer. Parse the Authorization scheme in one helper. assertion_only is refused with a typed 400 agent_assertion_unsupported before admission or routing, and again in Responses auth and Codex credential materialization. dual_header keeps its Bearer unchanged; the assertion is forwarded only on canonical first-party Direct beside the caller's own Bearer and is dropped for pool overrides, substituted credentials, routed providers and sidecars. The assertion header is redacted and blocked from forwardClientHeaders. * test(auth): cover admission-bearer substitution dropping the dual_header assertion * test(layout): register agent assertion tests in their own explicit shard --- .../content/docs/guides/codex-integration.md | 16 ++ .../test-layout/layout-agent-assertion.json | 5 + scripts/test-layout/layout.json | 2 +- src/adapters/openai-responses/transport.ts | 6 + src/codex/auth-context.ts | 16 +- src/lib/authorization-scheme.ts | 95 ++++++++ src/lib/provider-client-headers.ts | 1 + src/lib/redact.ts | 4 +- src/server/audio-client.ts | 3 +- src/server/audio-dictation.ts | 3 +- src/server/audio-upstream.ts | 3 +- src/server/auth-cors.ts | 11 +- src/server/gui-session.ts | 3 +- src/server/index/serve-options.ts | 5 + src/server/management-auth.ts | 3 +- src/server/responses/core-auth.ts | 12 +- src/server/ws-bridge.ts | 5 + structure/data-planes/inbound-compat.md | 13 + tests/fixtures/test-layout-expected.json | 1 + tests/lib/authorization-scheme.test.ts | 85 +++++++ .../responses/agent-assertion-routes.test.ts | 222 ++++++++++++++++++ tests/server/agent-assertion-auth.test.ts | 125 ++++++++++ 22 files changed, 619 insertions(+), 20 deletions(-) create mode 100644 scripts/test-layout/layout-agent-assertion.json create mode 100644 src/lib/authorization-scheme.ts create mode 100644 tests/lib/authorization-scheme.test.ts create mode 100644 tests/responses/agent-assertion-routes.test.ts create mode 100644 tests/server/agent-assertion-auth.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 6dd84dcce22..bfcfdb64e39 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1012,6 +1012,22 @@ has reached Codex the turn is never sent again. If every attempt is declined, Co the same error it would have received without the retries. Native compact requests move to another Pool account on a server error in the same way. +### Runtime agent instances (Codex 0.164+) + +Codex 0.164 can sign each request with a runtime agent assertion when +`features.runtime_agent_instances` is enabled (it is off by default). opencodex supports one of +its two modes: + +- `mode = "dual_header"` keeps the normal `Authorization: Bearer` credential and adds an + `x-openai-agent-assertion` header. opencodex treats the request exactly as before. The assertion + is forwarded only when the turn goes to ChatGPT Direct with your own credential. It is removed + when a Pool account, a stored main credential, a routed provider, or a sidecar serves the request, + because it belongs to the credential Codex sent. +- `mode = "assertion_only"` replaces the bearer with `Authorization: AgentAssertion `. + opencodex refuses these requests with HTTP 400 and error code `agent_assertion_unsupported`. + It never substitutes a Pool or provider credential for the assertion, and it never forwards or + logs the assertion. Use `dual_header`, or turn the feature off, when Codex runs through opencodex. + ### Codex autostart shim Run `ocx codex-shim install` to install the optional launcher wrapper. It runs diff --git a/scripts/test-layout/layout-agent-assertion.json b/scripts/test-layout/layout-agent-assertion.json new file mode 100644 index 00000000000..d3a0675e38e --- /dev/null +++ b/scripts/test-layout/layout-agent-assertion.json @@ -0,0 +1,5 @@ +{ + "agent-assertion-auth.test.ts": "server", + "agent-assertion-routes.test.ts": "responses", + "authorization-scheme.test.ts": "lib" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d3ec63cefb5..9aa38730a89 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json"], + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json"], "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", diff --git a/src/adapters/openai-responses/transport.ts b/src/adapters/openai-responses/transport.ts index 4250e167b0e..424d9f021f6 100644 --- a/src/adapters/openai-responses/transport.ts +++ b/src/adapters/openai-responses/transport.ts @@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../../types"; import { CODEX_RESPONSES_LITE_HEADER } from "../../codex/forward-transport-headers"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers-destination"; import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers"; +import { AGENT_ASSERTION_HEADER } from "../../lib/authorization-scheme"; import { openaiResponsesUrl } from "../openai-responses-url"; // Caller credentials are forwarded only to the canonical Codex destination, never by capability. @@ -69,6 +70,11 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P if (override && mayForwardCallerCredentials) { headers.authorization = `Bearer ${override.accessToken}`; headers["chatgpt-account-id"] = override.chatgptAccountId; + } else if (mayForwardCallerCredentials) { + // dual_header runtime assertion: first-party Direct only, beside the caller's own Bearer. + // A pool override above never carries it, and non-canonical transports never see it. + const assertion = incoming.headers.get(AGENT_ASSERTION_HEADER); + if (assertion) headers[AGENT_ASSERTION_HEADER] = assertion; } } else { url = provider.responsesPath === undefined diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index f804912e339..5afdd2230fd 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -98,6 +98,7 @@ import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUser import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity"; +import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme"; /** * A request-owned bearer cannot inspect the physical main credential for its plan, but cached @@ -701,7 +702,7 @@ async function authorizeReserveCredential( function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } { return { - accessToken: headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "", + accessToken: authorizationCredential(headers) ?? "", chatgptAccountId: headers.get("chatgpt-account-id") ?? "", }; } @@ -716,7 +717,7 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined { const explicit = headers.get("chatgpt-account-id"); if (explicit) return explicit; - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); return bearer ? extractAccountId(undefined, bearer) : undefined; } @@ -807,7 +808,7 @@ function assertMainAccountPolicy( /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */ function callerMatchesObservedMain(headers: Headers): boolean { - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (!bearer) return false; const effectiveAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); @@ -831,7 +832,7 @@ function sameCredentialMaterial(a: string, b: string): boolean { * cooldown — the safe direction. */ function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean { - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (!bearer) return true; const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); if (callerAccountId === undefined) return true; @@ -1767,7 +1768,7 @@ export class CodexMainSubstitutionUnavailableError extends Error { /** Dispatch identity comes only from an owned observation of the selected credential. */ function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined { - const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(selected); if (!bearer) return undefined; const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration()); @@ -1792,6 +1793,8 @@ export function materializeCodexUpstreamAuth( ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Headers { + // assertion_only must never be answered with a pool, stored-main, or other substituted Bearer. + if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError(); if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; const selected = new Headers(); for (const name of FORWARD_HEADERS) { @@ -1814,7 +1817,7 @@ export function materializeCodexUpstreamAuth( } if (ctx.kind === "main" && options.substituteMainCredential !== true && !selected.has("chatgpt-account-id")) { - const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(selected); const accountId = bearer ? extractAccountId(undefined, bearer) : undefined; if (accountId) selected.set("chatgpt-account-id", accountId); } @@ -1892,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync( ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Promise { + if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError(); if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) { return materializeReserveUpstreamAuth(headers, ctx, options); diff --git a/src/lib/authorization-scheme.ts b/src/lib/authorization-scheme.ts new file mode 100644 index 00000000000..eab24026622 --- /dev/null +++ b/src/lib/authorization-scheme.ts @@ -0,0 +1,95 @@ +/** + * One parser for the inbound `Authorization` scheme. + * + * Bearer reads in the proxy used `replace(/^Bearer\s+/i, "")`, which returns any other value + * unchanged. Codex 0.164 runtime agent instances (`features.runtime_agent_instances`) can send + * `Authorization: AgentAssertion `, and that replace handed the whole value on as an opaque + * bearer. An agent assertion is never a bearer: it is not an opencodex admission secret, it is not + * a ChatGPT credential, and it must never be forwarded as `Bearer`. + * + * The legacy read is preserved for every other value, including a scheme-less token, so this file + * changes nothing except the AgentAssertion case. + */ + +/** The companion header Codex sends in `dual_header` mode next to an ordinary Bearer. */ +export const AGENT_ASSERTION_HEADER = "x-openai-agent-assertion"; + +/** Stable error code for `Authorization: AgentAssertion`, which opencodex does not route. */ +export const AGENT_ASSERTION_UNSUPPORTED_CODE = "agent_assertion_unsupported"; + +export const AGENT_ASSERTION_UNSUPPORTED_MESSAGE = "Codex runtime agent assertion_only authentication " + + "(Authorization: AgentAssertion) is not supported through opencodex pooling or routing. " + + "Use dual_header mode, or turn off features.runtime_agent_instances."; + +export type AuthorizationScheme = + | { kind: "none" } + | { kind: "bearer"; token: string } + | { kind: "agent-assertion" } + | { kind: "other"; credential: string }; + +const BEARER_PREFIX = /^Bearer\s+/i; +// RFC 9110 auth schemes are case-insensitive; the scheme ends at whitespace or end of value. +const AGENT_ASSERTION_SCHEME = /^\s*AgentAssertion(?:\s|$)/i; + +/** Classify an Authorization value. The assertion's own bytes are deliberately not returned. */ +export function parseAuthorizationScheme(value: string | null | undefined): AuthorizationScheme { + if (value == null) return { kind: "none" }; + if (AGENT_ASSERTION_SCHEME.test(value)) return { kind: "agent-assertion" }; + if (BEARER_PREFIX.test(value)) { + const token = value.replace(BEARER_PREFIX, "").trim(); + return token ? { kind: "bearer", token } : { kind: "none" }; + } + const credential = value.trim(); + return credential ? { kind: "other", credential } : { kind: "none" }; +} + +/** + * The credential the legacy `replace(/^Bearer\s+/i, "").trim()` read produced, except that an + * AgentAssertion value yields nothing. Use this wherever that replace used to be. + */ +export function authorizationCredential(headers: Pick): string | undefined { + const parsed = parseAuthorizationScheme(headers.get("authorization")); + if (parsed.kind === "bearer") return parsed.token; + if (parsed.kind === "other") return parsed.credential; + return undefined; +} + +/** Whether the request authenticates with `Authorization: AgentAssertion` (assertion_only). */ +export function hasAgentAssertionAuthorization(headers: Pick): boolean { + return parseAuthorizationScheme(headers.get("authorization")).kind === "agent-assertion"; +} + +export class AgentAssertionUnsupportedError extends Error { + readonly code = AGENT_ASSERTION_UNSUPPORTED_CODE; + constructor() { + super(AGENT_ASSERTION_UNSUPPORTED_MESSAGE); + this.name = "AgentAssertionUnsupportedError"; + } +} + +/** The typed refusal. It never echoes the request's Authorization or assertion header. */ +export function agentAssertionUnsupportedResponse(): Response { + return Response.json({ + error: { + message: AGENT_ASSERTION_UNSUPPORTED_MESSAGE, + type: "invalid_request_error", + code: AGENT_ASSERTION_UNSUPPORTED_CODE, + }, + }, { status: 400, headers: { "cache-control": "no-store" } }); +} + +/** + * Carry the dual_header assertion onto first-party Direct headers, and only when the outbound + * Authorization is the caller's own Bearer, byte for byte. A pool account, a substituted main + * credential, or a stripped Authorization means the assertion would accompany a credential it was + * not sent with, so it is dropped instead. + */ +export function carryAgentAssertionForCallerBearer(target: Headers, caller: Pick): void { + target.delete(AGENT_ASSERTION_HEADER); + const assertion = caller.get(AGENT_ASSERTION_HEADER); + if (!assertion) return; + const callerAuth = parseAuthorizationScheme(caller.get("authorization")); + const outboundAuth = parseAuthorizationScheme(target.get("authorization")); + if (callerAuth.kind !== "bearer" || outboundAuth.kind !== "bearer" || callerAuth.token !== outboundAuth.token) return; + target.set(AGENT_ASSERTION_HEADER, assertion); +} diff --git a/src/lib/provider-client-headers.ts b/src/lib/provider-client-headers.ts index 600173860ec..37208a29598 100644 --- a/src/lib/provider-client-headers.ts +++ b/src/lib/provider-client-headers.ts @@ -21,6 +21,7 @@ const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([ "api-key", "x-goog-api-key", "x-oai-attestation", + "x-openai-agent-assertion", ]); const ALLOWED_FORWARDED_CLIENT_HEADERS = new Set([ diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 76d80641b9b..fcde66a7361 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]"; * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than * re-deriving a narrower local list. */ -export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; +export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|x-openai-agent-assertion|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; /** * Colon-labelled credential headers echoed back inside an error body @@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook // Every letter position also accepts \u0001, the placeholder the fold emits for // an unresolved HTML named reference: `authorⅈzation` is the label with one // character we cannot name, and that is still the label. -const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; +const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-openai-agent-assertion|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW .replace(/(? { let authCtx: CodexAuthContext | undefined; + // Ingress already refuses assertion_only; a later internal hop must not reach a pool or + // routed credential with it either, because that would be a silent credential swap. + if (hasAgentAssertionAuthorization(req.headers)) return { ok: false, response: agentAssertionUnsupportedResponse() }; try { let authInputHeaders = codexRouteCredentialDomainHeaders( req, @@ -332,7 +336,7 @@ export async function resolveResponsesCodexAuth( } else { // Even adapters that ignore caller auth must not retain a proxy secret for // a later internal hop or a future transport change. - const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const bearer = authorizationCredential(headers); if (bearer && isProxyAdmissionSecret(bearer, config)) { headers.delete("authorization"); headers.delete("chatgpt-account-id"); @@ -343,6 +347,11 @@ export async function resolveResponsesCodexAuth( if (options.compactionRecoveryKind === "compaction-v1") { copyResponsesClientMetadata(headers, authInputHeaders, route.provider); } + // dual_header: the assertion is first-party only and travels only beside the caller's own + // unchanged Bearer, compared with the ORIGINAL request (injected credentials do not count). + if (isCanonicalOpenAiForwardProvider(route.provider) && authCtx?.kind === "main" && !substituteMainCredential) { + carryAgentAssertionForCallerBearer(headers, req.headers); + } return { ok: true, authCtx, @@ -364,6 +373,7 @@ export async function resolveResponsesCodexAuth( if (err instanceof ForwardAdmissionCredentialError) { return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) }; } + if (err instanceof AgentAssertionUnsupportedError) return { ok: false, response: agentAssertionUnsupportedResponse() }; const response = mapCodexAuthContextErrorToResponse(err, { accountSelector: route.codexAccountNamespace, now: Date.now(), diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 4009a48c1d7..6a10afe729e 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -12,6 +12,7 @@ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite"; import { safeResponseHeaders } from "./safe-response-headers"; import type { AudioSocketTarget } from "./audio-dictation"; import type { CodexWebSocketActivity } from "../codex/websocket-activity"; +import { AGENT_ASSERTION_HEADER } from "../lib/authorization-scheme"; export { safeResponseHeaders } from "./safe-response-headers"; @@ -131,6 +132,10 @@ export function selectForwardHeaders( if (codexOverride) { selected.set("authorization", `Bearer ${codexOverride.accessToken}`); selected.set("chatgpt-account-id", codexOverride.chatgptAccountId); + } else { + // Kept for the WS turn pipeline, which drops it again everywhere except first-party Direct. + const assertion = headers.get(AGENT_ASSERTION_HEADER); + if (assertion) selected.set(AGENT_ASSERTION_HEADER, assertion); } return selected; } diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index eb85a03718a..c658d8881c7 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -561,3 +561,16 @@ Missing identity, a restarted/expired/evicted cache, and another route receive o placeholder; they never recover another conversation's raw text. SSE capture retains at most 256 KiB and 64 calls within the translator budget and releases on terminal/error/cancellation. Unfinished tool streams cannot seed replay. This leaves the native wire and response fields intact. + +## Codex runtime agent assertions + +Authorization scheme parsing lives in `src/lib/authorization-scheme.ts`, and every former +`replace(/^Bearer\s+/i, "")` read uses it. Codex 0.164 runtime agent assertions add a value that is +neither an admission secret nor a caller credential. `Authorization: AgentAssertion` (assertion_only) +is never a bearer, never admitted, and never forwarded: the request fetch handler refuses it with +`agent_assertion_unsupported` before admission or routing, and Responses auth plus +`materializeCodexUpstreamAuth` refuse it again so no pool or substituted credential can stand in for +it. The dual_header `x-openai-agent-assertion` is outside `FORWARD_HEADERS` and blocked from +`forwardClientHeaders`. It is carried only for canonical-forward Direct when the outbound Bearer is +the caller's original one, and is dropped under a pool override. Coverage: +`tests/server/agent-assertion-auth.test.ts` and `tests/responses/agent-assertion-routes.test.ts`. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index da880bb6d44..09497099884 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -217,6 +217,7 @@ "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", "agent-task-recovery-cache.test.ts": "server", "agent-task-recovery-combo.test.ts": "server", "agent-task-recovery-fallback.test.ts": "server", "agent-task-recovery-security.test.ts": "server", + "agent-assertion-auth.test.ts": "server", "agent-assertion-routes.test.ts": "responses", "authorization-scheme.test.ts": "lib", "agent-task-recovery.test.ts": "server", "alias-management-api.test.ts": "server", "alibaba-intl-token-plan.test.ts": "gui", "alibaba-region-backup.test.ts": "providers", "alibaba-region-migration.test.ts": "providers", "alibaba-region-startup.test.ts": "providers", diff --git a/tests/lib/authorization-scheme.test.ts b/tests/lib/authorization-scheme.test.ts new file mode 100644 index 00000000000..237362f3d04 --- /dev/null +++ b/tests/lib/authorization-scheme.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_ASSERTION_HEADER, + AGENT_ASSERTION_UNSUPPORTED_CODE, + agentAssertionUnsupportedResponse, + authorizationCredential, + carryAgentAssertionForCallerBearer, + hasAgentAssertionAuthorization, + parseAuthorizationScheme, +} from "../../src/lib/authorization-scheme"; +import { redactHeaders, redactSecretString, REDACTED_SECRET } from "../../src/lib/redact"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; + +// Codex 0.164 runtime agent assertions are JWT-shaped; the bytes only have to be recognisable. +const ASSERTION = fakeChatGptJwt({ agent: "runtime-agent-assertion-fixture" }); + +describe("Authorization scheme parsing", () => { + test("AgentAssertion is its own scheme in any case and never yields a credential", () => { + for (const value of [`AgentAssertion ${ASSERTION}`, `agentassertion ${ASSERTION}`, `AGENTASSERTION\t${ASSERTION}`, "AgentAssertion"]) { + expect(parseAuthorizationScheme(value)).toEqual({ kind: "agent-assertion" }); + const headers = new Headers({ authorization: value }); + expect(authorizationCredential(headers)).toBeUndefined(); + expect(hasAgentAssertionAuthorization(headers)).toBe(true); + } + }); + + test("Bearer and scheme-less values keep the legacy read", () => { + expect(authorizationCredential(new Headers({ authorization: "Bearer abc.def" }))).toBe("abc.def"); + expect(authorizationCredential(new Headers({ authorization: "bearer spaced " }))).toBe("spaced"); + expect(authorizationCredential(new Headers({ authorization: "ocx_data_bare" }))).toBe("ocx_data_bare"); + expect(authorizationCredential(new Headers())).toBeUndefined(); + // A longer scheme that merely starts with the name is not an agent assertion. + expect(parseAuthorizationScheme("AgentAssertionX token")).toEqual({ kind: "other", credential: "AgentAssertionX token" }); + expect(hasAgentAssertionAuthorization(new Headers({ authorization: `Bearer ${ASSERTION}` }))).toBe(false); + }); + + test("the dual_header assertion is carried only beside the caller's own unchanged Bearer", () => { + const caller = new Headers({ authorization: "Bearer caller-token", [AGENT_ASSERTION_HEADER]: ASSERTION }); + const same = new Headers({ authorization: "Bearer caller-token" }); + carryAgentAssertionForCallerBearer(same, caller); + expect(same.get(AGENT_ASSERTION_HEADER)).toBe(ASSERTION); + + const pooled = new Headers({ authorization: "Bearer pool-token", [AGENT_ASSERTION_HEADER]: ASSERTION }); + carryAgentAssertionForCallerBearer(pooled, caller); + expect(pooled.has(AGENT_ASSERTION_HEADER)).toBe(false); + + const stripped = new Headers(); + carryAgentAssertionForCallerBearer(stripped, caller); + expect(stripped.has(AGENT_ASSERTION_HEADER)).toBe(false); + + const assertionOnly = new Headers({ authorization: `AgentAssertion ${ASSERTION}`, [AGENT_ASSERTION_HEADER]: ASSERTION }); + const target = new Headers({ authorization: `AgentAssertion ${ASSERTION}` }); + carryAgentAssertionForCallerBearer(target, assertionOnly); + expect(target.has(AGENT_ASSERTION_HEADER)).toBe(false); + }); + + test("the typed refusal is stable and does not echo the assertion", async () => { + const response = agentAssertionUnsupportedResponse(); + expect(response.status).toBe(400); + const text = await response.text(); + expect(JSON.parse(text).error.code).toBe(AGENT_ASSERTION_UNSUPPORTED_CODE); + expect(text).not.toContain(ASSERTION); + }); +}); + +describe("agent assertion redaction", () => { + test("header records redact both the AgentAssertion Authorization and the dual_header header", () => { + const redacted = redactHeaders(new Headers({ + authorization: `AgentAssertion ${ASSERTION}`, + [AGENT_ASSERTION_HEADER]: ASSERTION, + })); + expect(redacted.authorization).toBe(REDACTED_SECRET); + expect(redacted[AGENT_ASSERTION_HEADER]).toBe(REDACTED_SECRET); + }); + + test("diagnostic strings never keep the assertion bytes", () => { + for (const raw of [ + `upstream echoed authorization: AgentAssertion ${ASSERTION}`, + `upstream echoed ${AGENT_ASSERTION_HEADER}: ${ASSERTION}`, + JSON.stringify({ [AGENT_ASSERTION_HEADER]: ASSERTION }), + ]) { + expect(redactSecretString(raw)).not.toContain(ASSERTION); + } + }); +}); diff --git a/tests/responses/agent-assertion-routes.test.ts b/tests/responses/agent-assertion-routes.test.ts new file mode 100644 index 00000000000..83d015227f2 --- /dev/null +++ b/tests/responses/agent-assertion-routes.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { saveConfig } from "../../src/config"; +import { AGENT_ASSERTION_HEADER, AGENT_ASSERTION_UNSUPPORTED_CODE } from "../../src/lib/authorization-scheme"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +// Every case starts a real server; on Windows its config-dir ACL work alone can exceed 5 s. +setDefaultTimeout(SERVER_BUDGET_MS); + +/** + * Codex 0.164 runtime agent instances (openai/codex#55402, #55636) end to end. dual_header keeps + * working with its Bearer and forwards x-openai-agent-assertion only on first-party Direct. + * assertion_only (Authorization: AgentAssertion) is refused with a typed error before any pool, + * routed, or admission decision, so no other credential is ever sent in its place. + */ +const inspectNativeCodexOwnership = ownedServiceHomeInspection("agent assertion routes test"); +const ACCOUNT = "agent-assertion-route-account"; +const CALLER = fakeChatGptJwt({ exp: 4_000_000_000, "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ASSERTION = fakeChatGptJwt({ agent: "runtime-agent-assertion-fixture", "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ADMISSION_KEY = "ocx_data_agent_assertion_route_fixture"; + +type Seen = { path: string; authorization: string | null; assertion: string | null }; + +function completed(id: string): Response { + return Response.json({ + id, object: "response", status: "completed", model: "gpt-5.5", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 1, output_tokens: 1 }, + }); +} + +function filesUnder(root: string): string[] { + const out: string[] = []; + for (const name of readdirSync(root)) { + const path = join(root, name); + if (statSync(path).isDirectory()) out.push(...filesUnder(path)); + else out.push(path); + } + return out; +} + +describe("Codex runtime agent assertion routes", () => { + const originalFetch = globalThis.fetch; + let testDir = ""; + let previousHome: string | undefined; + let isolatedCodexHome: IsolatedCodexHome | null = null; + let seen: Seen[] = []; + let upstream: ReturnType | undefined; + let server: ReturnType | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-agent-assertion-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-agent-assertion-")); + process.env.OPENCODEX_HOME = testDir; + seen = []; + upstream = Bun.serve({ + port: 0, + fetch(req) { + seen.push({ + path: new URL(req.url).pathname, + authorization: req.headers.get("authorization"), + assertion: req.headers.get(AGENT_ASSERTION_HEADER), + }); + return completed("resp_agent_assertion"); + }, + }); + const target = upstream.url; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.startsWith("/backend-api/codex")) { + return originalFetch(new URL(`/chatgpt${url.pathname.slice("/backend-api/codex".length)}`, target), init); + } + if (url.hostname === "gateway.example.test") return originalFetch(new URL(`/gateway${url.pathname}`, target), init); + return originalFetch(input, init); + }) as typeof fetch; + }); + + afterEach(async () => { + if (server) await server.stop(true); + server = undefined; + upstream?.stop(true); + globalThis.fetch = originalFetch; + process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) await removeTreeWithRetry(testDir); + }); + + function start(config: Partial): URL { + saveConfig({ port: 0, ...config } as OcxConfig); + server = startServer(0, { inspectNativeCodexOwnership }); + return new URL(server.url); + } + + function post(base: URL, headers: Record, model = "gpt-5.5"): Promise { + return fetch(new URL("/v1/responses", base), { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ model, stream: false, store: false, input: "hello" }), + }); + } + + async function expectRefused(response: Response): Promise { + expect(response.status).toBe(400); + const text = await response.text(); + expect(JSON.parse(text).error.code).toBe(AGENT_ASSERTION_UNSUPPORTED_CODE); + expect(text).not.toContain(ASSERTION); + } + + function expectNoAssertionPersisted(): void { + for (const path of filesUnder(testDir)) expect(readFileSync(path, "latin1")).not.toContain(ASSERTION); + } + + const direct = { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + codexAccountMode: "direct", upstreamWebsocket: false, + } } as unknown as OcxConfig["providers"]; + const pooled = { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + codexAccountMode: "pool", upstreamWebsocket: false, + } } as unknown as OcxConfig["providers"]; + + function savePoolAccount(): Partial { + saveCodexAccountCredential("pool-account", { + accessToken: "pool-account-token", refreshToken: "pool-account-refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "pool-account-uuid", + }); + return { + activeCodexAccountId: "pool-account", + codexAccounts: [{ id: "pool-account", email: "pool@example.test", chatgptAccountId: "pool-account-uuid", isMain: false }], + } as Partial; + } + + test("dual_header on Direct forwards the caller Bearer with its assertion", async () => { + const base = start({ defaultProvider: "openai", providers: direct }); + const response = await post(base, { + authorization: `Bearer ${CALLER}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); + expect(response.status).toBe(200); + await response.text(); + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: `Bearer ${CALLER}`, assertion: ASSERTION }]); + expectNoAssertionPersisted(); + }); + + test("dual_header on a pooled account sends the pool Bearer and strips the assertion", async () => { + const base = start({ defaultProvider: "openai", providers: pooled, ...savePoolAccount() }); + const response = await post(base, { + authorization: `Bearer ${CALLER}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); + expect(response.status).toBe(200); + await response.text(); + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: "Bearer pool-account-token", assertion: null }]); + expectNoAssertionPersisted(); + }); + + test("dual_header with an admission Bearer substitutes stored main and drops the assertion", async () => { + const stored = fakeChatGptJwt({ exp: 4_000_000_000, "https://api.openai.com/auth": { chatgpt_account_id: "stored-main-account" } }); + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored-main-account" } })); + const base = start({ + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: direct, + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], + } as unknown as Partial); + await waitForNativeMainStartupGate(); + const response = await post(base, { authorization: `Bearer ${ADMISSION_KEY}`, [AGENT_ASSERTION_HEADER]: ASSERTION }); + expect(response.status).toBe(200); + await response.text(); + // The assertion was sent beside the admission key, so it never rides with the stored credential. + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: `Bearer ${stored}`, assertion: null }]); + expectNoAssertionPersisted(); + }); + + test("assertion_only on a pooled account is refused without spending the pool credential", async () => { + const base = start({ defaultProvider: "openai", providers: pooled, ...savePoolAccount() }); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}`, "chatgpt-account-id": ACCOUNT })); + expect(seen).toEqual([]); + expectNoAssertionPersisted(); + }); + + test("assertion_only on Direct and on a routed provider is refused before any upstream call", async () => { + const base = start({ + defaultProvider: "openai", + providers: { + ...direct, + gateway: { + adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", + apiKey: "sk-routed-provider-key", models: ["gateway-model"], allowPrivateNetwork: true, + }, + } as unknown as OcxConfig["providers"], + }); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}` })); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}` }, "gateway/gateway-model")); + expect(seen).toEqual([]); + expectNoAssertionPersisted(); + }); + + test("a remote bind refuses assertion_only with the typed error whether or not the caller is admitted", async () => { + const base = start({ + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: pooled, + ...savePoolAccount(), + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], + } as unknown as Partial); + await expectRefused(await post(base, { "x-opencodex-api-key": ADMISSION_KEY, authorization: `AgentAssertion ${ASSERTION}` })); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ADMISSION_KEY}` })); + expect(seen).toEqual([]); + }); +}); diff --git a/tests/server/agent-assertion-auth.test.ts b/tests/server/agent-assertion-auth.test.ts new file mode 100644 index 00000000000..b97427a190f --- /dev/null +++ b/tests/server/agent-assertion-auth.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { buildResponsesTransport, FORWARD_HEADERS } from "../../src/adapters/openai-responses/transport"; +import { + headersForCodexAuthContext, + materializeCodexUpstreamAuth, + materializeCodexUpstreamAuthAsync, + type CodexAuthContext, +} from "../../src/codex/auth-context"; +import { providerForwardClientHeadersConfigError } from "../../src/lib/provider-client-headers"; +import { AGENT_ASSERTION_HEADER, AgentAssertionUnsupportedError } from "../../src/lib/authorization-scheme"; +import { + hasForwardableCodexBearer, + resolveApiAuth, + resolveContextPrincipal, + resolveResponsesApiAuth, + validateForwardAdmissionCredential, +} from "../../src/server/auth-cors"; +import { selectForwardHeaders } from "../../src/server/ws-bridge"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; + +/** + * Codex 0.164 runtime agent instances (openai/codex#55402, #55636) authenticate in two modes: + * dual_header sends an ordinary Bearer plus x-openai-agent-assertion, and assertion_only sends + * Authorization: AgentAssertion . The legacy replace(/^Bearer\s+/i) read passed the second + * one through as an opaque bearer. + */ +const ACCOUNT = "agent-assertion-account"; +const ASSERTION = fakeChatGptJwt({ "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ADMISSION_KEY = "ocx_data_agent_assertion_fixture"; +const remote = { + hostname: "0.0.0.0", + providers: {}, + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], +} as unknown as OcxConfig; +const canonical: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", +} as OcxProviderConfig; + +function assertionOnly(extra: Record = {}): Headers { + return new Headers({ authorization: `AgentAssertion ${ASSERTION}`, "chatgpt-account-id": ACCOUNT, ...extra }); +} + +function dualHeader(bearer = "caller-direct-token"): Headers { + return new Headers({ + authorization: `Bearer ${bearer}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); +} + +describe("assertion_only admission", () => { + test("an AgentAssertion is never admitted, even when its bytes equal a configured proxy key", () => { + for (const value of [`AgentAssertion ${ASSERTION}`, `AgentAssertion ${ADMISSION_KEY}`]) { + const req = new Request("http://proxy.test/v1/responses", { headers: { authorization: value } }); + expect(resolveApiAuth(req, remote)).toBeNull(); + expect(resolveResponsesApiAuth(req, remote)).toBeNull(); + expect(resolveContextPrincipal(req, remote, { kind: "loopback", source: "loopback" })).toBeUndefined(); + } + }); + + test("an AgentAssertion is not a forwardable caller Codex bearer", () => { + // The legacy read saw "AgentAssertion " plus an account header as a caller credential. + expect(hasForwardableCodexBearer(assertionOnly(), remote)).toBe(false); + expect(hasForwardableCodexBearer(dualHeader(), remote)).toBe(true); + expect(() => validateForwardAdmissionCredential(assertionOnly(), remote)).not.toThrow(); + expect(() => validateForwardAdmissionCredential(new Headers({ authorization: `Bearer ${ADMISSION_KEY}` }), remote)) + .toThrow(); + }); +}); + +describe("assertion_only credential materialization", () => { + const pool = { kind: "pool", accountId: "pool-account", accessToken: "pool-token", chatgptAccountId: "pool-uuid" } as unknown as CodexAuthContext; + const main = { kind: "main", accountId: null } as CodexAuthContext; + + test("pool, stored-main and Direct materialization refuse instead of substituting a Bearer", async () => { + expect(() => materializeCodexUpstreamAuth(assertionOnly(), pool)).toThrow(AgentAssertionUnsupportedError); + expect(() => materializeCodexUpstreamAuth(assertionOnly(), main)).toThrow(AgentAssertionUnsupportedError); + expect(() => materializeCodexUpstreamAuth(assertionOnly(), main, { substituteMainCredential: true })) + .toThrow(AgentAssertionUnsupportedError); + await expect(materializeCodexUpstreamAuthAsync(assertionOnly(), main, { substituteMainCredential: true })) + .rejects.toBeInstanceOf(AgentAssertionUnsupportedError); + }); +}); + +describe("dual_header assertion forwarding", () => { + test("first-party Direct passthrough forwards the caller's Bearer and the assertion", () => { + const { headers } = buildResponsesTransport(canonical, { headers: dualHeader() }); + expect(headers.authorization).toBe("Bearer caller-direct-token"); + expect(headers[AGENT_ASSERTION_HEADER]).toBe(ASSERTION); + }); + + test("a pooled account override strips the assertion", () => { + const pooled = { ...canonical, _codexAccountOverride: { accessToken: "pool-token", chatgptAccountId: "pool-uuid" } }; + const { headers } = buildResponsesTransport(pooled, { headers: dualHeader() }); + expect(headers.authorization).toBe("Bearer pool-token"); + expect(headers[AGENT_ASSERTION_HEADER]).toBeUndefined(); + }); + + test("routed transports never forward the assertion, even when configured to", () => { + const routed: OcxProviderConfig[] = [ + { adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "forward" } as OcxProviderConfig, + { adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", apiKey: "sk-routed" } as OcxProviderConfig, + { ...canonical, baseUrl: "https://gateway.example.test/v1", forwardClientHeaders: [AGENT_ASSERTION_HEADER] } as OcxProviderConfig, + ]; + for (const provider of routed) { + const { headers } = buildResponsesTransport(provider, { headers: dualHeader() }); + expect(Object.keys(headers).map(name => name.toLowerCase())).not.toContain(AGENT_ASSERTION_HEADER); + } + expect(providerForwardClientHeadersConfigError([AGENT_ASSERTION_HEADER])).toContain("credential"); + }); + + test("Codex materialization and sidecar allowlists drop the assertion", () => { + expect(FORWARD_HEADERS).not.toContain(AGENT_ASSERTION_HEADER); + const main = { kind: "main", accountId: null } as CodexAuthContext; + expect(materializeCodexUpstreamAuth(dualHeader(), main).has(AGENT_ASSERTION_HEADER)).toBe(false); + // openai-sidecar.ts selects sidecar credentials through this wrapper. + expect(headersForCodexAuthContext(dualHeader(), main).has(AGENT_ASSERTION_HEADER)).toBe(false); + }); + + test("the WebSocket handshake keeps the assertion for Direct and drops it for a pool override", () => { + expect(selectForwardHeaders(dualHeader()).get(AGENT_ASSERTION_HEADER)).toBe(ASSERTION); + const pooled = selectForwardHeaders(dualHeader(), { accessToken: "pool-token", chatgptAccountId: "pool-uuid" }); + expect(pooled.get("authorization")).toBe("Bearer pool-token"); + expect(pooled.has(AGENT_ASSERTION_HEADER)).toBe(false); + }); +}); From 561347cca9cb885ec479009593dee20747dae3c2 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 22:15:38 +0900 Subject: [PATCH 07/25] fix(responses): handle Codex 0.164 request controls (#7473) * fix(responses): handle Codex 0.164 request controls Codex 0.164 adds a positional configuration_update input item, a prompt_cache_key Omit/Custom policy, and a voice-session-id realtime header. - Strip configuration_update for Responses gateways OpenAI does not operate (passthrough and native compact replay); OpenAI-operated destinations keep it verbatim and in order. - Drop it explicitly in the translated parser and skip it in the agent-task tail walkers so a trailing update cannot hide the task. - Stop routed catalog rows inheriting supports_reasoning_effort_updates from GPT-6 templates, so Codex keeps effort on the request parameter. - Relay voice-session-id on live call-create and sideband join. - Pin prompt_cache_key SessionDerived/Custom/Omit across native, translated, cache-diagnostic, lineage and log-identity paths. * test: keep layout.json under the file-size threshold --- .../docs/ko/reference/proxy-formats.md | 3 +- .../content/docs/reference/proxy-formats.md | 3 +- scripts/test-layout/layout.json | 2 +- src/adapters/openai-responses/passthrough.ts | 3 +- .../openai-responses/request-strips.ts | 32 ++ src/codex/catalog/parsing.ts | 4 + src/responses/parser.ts | 7 + src/server/live.ts | 5 + src/server/responses/agent-task-recovery.ts | 2 +- src/server/responses/compact.ts | 4 +- src/server/responses/encrypted-payload.ts | 6 +- structure/transports/responses.md | 2 +- ...x-model-entitlements-program-shape.test.ts | 15 + tests/fixtures/test-layout-expected.json | 1 + .../codex-0164-request-compat.test.ts | 311 ++++++++++++++++++ tests/server/server-live.test.ts | 4 + 16 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 tests/responses/codex-0164-request-compat.test.ts diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 03ae6b806ca..32963d7f7c3 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -299,7 +299,8 @@ call creation과 sideband join은 같은 OpenAI 계정으로 이루어져야 하 계정 선택을 그 쌍에 묶어 두므로(프로세스 로컬) 프록시에 도착한 join은 통화를 만든 계정을 그대로 쓰고, Direct 모드는 두 요청 모두 호출자의 현재 bearer를 전달합니다. 릴레이되는 클라이언트 헤더는 정확히 `openai-alpha`, `x-session-id`, `session-id`, `thread-id`, `originator`, `x-oai-attestation`, -`x-codex-turn-metadata`(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, 각 헤더는 +`x-codex-turn-metadata`, `voice-session-id`(실시간 `x-session-id`와 별개인 클라이언트 음성 세션 id) +(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, 각 헤더는 호출자가 보낸 경우에만 전달되고 프록시가 만들어 내지 않습니다. `Authorization`과 ChatGPT 계정 id는 ChatGPT 경로에서 프록시가 소유합니다(Pool은 저장된 계정으로 교체, Direct는 검증된 호출자 bearer를 전달). API 키 프로바이더는 자체 bearer를 씁니다. Codex가 join을 프록시로 보내는 것은 diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index de02c401ceb..534c9aa049b 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -741,7 +741,8 @@ upstream (`404`). Both legs carry Codex's `session-id` and `thread-id` headers; account choice is bound to that pair (process-local), so a join that reaches the proxy reuses the account that created the call, while Direct mode forwards the caller's current bearer on both legs. The relayed client headers are exactly `openai-alpha`, `x-session-id`, `session-id`, `thread-id`, -`originator`, `x-oai-attestation`, and `x-codex-turn-metadata` +`originator`, `x-oai-attestation`, `x-codex-turn-metadata`, and `voice-session-id` (the client voice +session id, separate from the realtime `x-session-id`) (`LIVE_CLIENT_PROTOCOL_HEADERS` in `src/server/live.ts`); each is relayed only when the caller sent it, and none is invented. `Authorization` and the ChatGPT account id are proxy-owned on ChatGPT-backed routes (Pool replaces diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9aa38730a89..d3977b7b624 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1566,7 +1566,7 @@ "responses-hosted-tool-min-spread.test.ts": "responses", "responses-hide-raw-reasoning.test.ts": "responses", "responses-image-gen-repair.test.ts": "responses", - "responses-inbound-store-default.test.ts": "responses", + "responses-inbound-store-default.test.ts": "responses", "codex-0164-request-compat.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", "responses-json-events.test.ts": "responses", "responses-legacy-dotted-tool-name-repair.test.ts": "responses", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 050686dcc3e..57c7ccf0e6b 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -40,7 +40,7 @@ import { emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; +import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -335,6 +335,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isOpenAiOperatedResponsesDestination(provider)) { if (parsed._compactionRequest !== true && isPlainObject(outBody)) outBody = frameCompactionHistory(outBody); outBody = stripCanonicalOnlyTopLevelFields(outBody); + outBody = stripCanonicalOnlyInputItems(outBody); } outBody = promoteClientLoadedTools(outBody); } diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index b74b92ad7bd..25587bb6c5b 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -187,6 +187,38 @@ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown { return next; } +/** + * OpenAI-private input item TYPES, the item-level sibling of the two tables above. + * + * Each one is a positional control the OpenAI Responses backend interprets where it sits in + * history, never a turn of the conversation, so dropping it leaves every message, call and output + * in place. Codex gates sending one on its own `is_openai` provider check, which loopback + * injection always satisfies, so a gateway this proxy does not operate can still receive one and + * reject the whole body as an unknown item type. + * + * Same rule as the top-level table: only types a client is OBSERVED to send. + */ +const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet = new Set([ + // Codex 0.164 `ResponseItem::ConfigurationUpdate` ({type, reasoning: {effort}}): a durable + // reasoning-effort change recorded at its position in history. codex-rs sends it only for a + // catalog row with `supports_reasoning_effort_updates`, and keeps it in saved history after a + // model switch. Mapping the effort onto another provider is out of scope (#6895). + "configuration_update", +]); + +/** + * Remove OpenAI-private input items. The caller decides the boundary, exactly as for + * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep + * the items verbatim and in order. Returns the input unchanged when none is present. + */ +export function stripCanonicalOnlyInputItems(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + const isPrivate = (item: unknown): boolean => isPlainObject(item) + && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); + if (!body.input.some(isPrivate)) return body; + return { ...body, input: body.input.filter(item => !isPrivate(item)) }; +} + /** * Sampling fields a model on the provider's `noStopModels` / `noPenaltyModels` list rejects on * every wire. Claude inbound translates `stop_sequences` into a Responses `stop` diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index f5d0a860f8e..bdab81c61e3 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -809,6 +809,10 @@ export function normalizeRoutedCatalogEntry( delete entry.multi_agent_version; delete entry.multi_agent_reasoning_effort; delete entry.use_responses_lite; + // Codex 0.164 sends positional `configuration_update` effort items only for rows declaring this + // and freezes the request-level effort for the context window. A routed provider never + // negotiated that contract, so a row cloned from a GPT-6 template must not inherit it. + delete entry.supports_reasoning_effort_updates; delete entry.supports_websockets; // Routed rows cloned from native templates must not inherit OpenAI-only experimental context // delivery. Codex reads the flag as "this model accepts experimental context history" and drives diff --git a/src/responses/parser.ts b/src/responses/parser.ts index e18ee9f1cbe..10bd953eb5e 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -329,6 +329,13 @@ export function parseRequest( continue; } + if (effectiveType === "configuration_update") { + // Codex 0.164 positional reasoning-effort control. It is not a turn, so a translated route + // drops it without touching pending reasoning; the native passthrough keeps it verbatim in + // _rawBody. Mapping its effort onto the routed request is deliberately not done (#6895). + continue; + } + if (isCompactionItemType(effectiveType)) { // A stored summary from a previous compaction. Decode our ocx1/ocx2 envelopes into plain text so // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note. diff --git a/src/server/live.ts b/src/server/live.ts index a4e48348341..41d14925168 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -88,6 +88,10 @@ export const LIVE_SIDEBAND_API_ROOT = "https://api.openai.com/v1"; * upstream the caller was addressing, so there is nothing to scope it away from. That is not * true of the images sidecar, which strips it deliberately and keeps doing so. * + * `voice-session-id` is the client-generated voice session id Codex 0.164 forwards on WebSocket, + * WebRTC and existing-call transports (openai/codex#53784). It is a separate identity from the + * realtime session id carried in `x-session-id`, so neither may stand in for the other. + * * Every name here is relayed only when the caller sent it. Nothing on this list is invented, * which is what keeps a caller that omits one byte-identical upstream. Auth headers * (`authorization`, `chatgpt-account-id`) stay proxy-owned and are never taken from this list. @@ -100,6 +104,7 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [ "originator", "x-oai-attestation", "x-codex-turn-metadata", + "voice-session-id", ] as const; /** diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index d170e9436f5..159ed7abd03 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -104,7 +104,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { const type = input[itemIndex] && typeof input[itemIndex] === "object" ? (input[itemIndex] as { type?: unknown }).type : undefined; - if (type !== "compaction_trigger" && type !== "additional_tools") break; + if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break; itemIndex -= 1; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 32263104a72..169ce00a0a6 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -135,7 +135,7 @@ import type { DataPlaneAdmission } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; -import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips"; +import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips"; import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning"; import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough"; import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination"; @@ -1155,7 +1155,7 @@ export async function handleResponsesCompact( replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody; } return isOpenAiOperatedResponsesDestination(sendProvider) - ? replayBody : stripCanonicalOnlyTopLevelFields(replayBody); + ? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody)); }; const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( compactUrl, diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 2ff158e4732..1788c0733fa 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -265,13 +265,13 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { // codex-rs appends one agent_message (any of the four codex-rs message types) at the // current input tail. Historical // agent messages may be adjacent in full-history bodies; they must not poison the - // later task. compaction_trigger/additional_tools are trailing metadata rather than - // a newer user turn. + // later task. compaction_trigger/additional_tools/configuration_update are trailing + // metadata rather than a newer user turn; Codex may record an effort update after the task. let index = input.length - 1; while (index >= 0) { const item = input[index]; const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined; - if (type !== "compaction_trigger" && type !== "additional_tools") break; + if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break; index -= 1; } const item = input[index]; diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 805c208809d..c003eac87b0 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -197,7 +197,7 @@ This table is not an unknown-parameter sanitizer, and the distinction is the poi client is observed to send, so an unrecognized top-level key reaches the wire untouched rather than being deleted on the theory that the destination would have rejected it. `codex_output_schema` is deliberately absent for that reason: in codex-rs it is the `name` of the JSON-schema `text.format` -object, not a top-level key, so listing it would remove a field this client never sends. +object, not a top-level key, so listing it would remove a field this client never sends. Private input item TYPES are a third table, `CANONICAL_ONLY_INPUT_ITEM_TYPES` (`stripCanonicalOnlyInputItems`, same destination boundary in the passthrough and native compact gateway replay). It holds Codex 0.164 `configuration_update` (`{type, reasoning: {effort}}`), a positional effort control: OpenAI-operated destinations receive it verbatim and in order, other gateways get the conversation without it, the translated parser drops it without clearing pending reasoning, and the agent-task tail walkers skip it like `compaction_trigger`. Codex sends it only for rows with `supports_reasoning_effort_updates`, which `normalizeRoutedCatalogEntry` removes from routed rows; its effort is never mapped onto a routed request (#6895). Coverage: `tests/responses/codex-0164-request-compat.test.ts`. After that namespace boundary has produced public function tools, the Grok CLI Responses transport applies the same root-schema policy as its Chat transport. A root `oneOf`/`anyOf` is flattened only diff --git a/tests/codex-integration/codex-model-entitlements-program-shape.test.ts b/tests/codex-integration/codex-model-entitlements-program-shape.test.ts index a91f82f67f0..f5a04c7d9fb 100644 --- a/tests/codex-integration/codex-model-entitlements-program-shape.test.ts +++ b/tests/codex-integration/codex-model-entitlements-program-shape.test.ts @@ -41,6 +41,21 @@ describe("Codex roster access program metadata", () => { }); expect(codexForward).toHaveProperty("available_access_programs", { cyber: ["standard"] }); }); + test("a routed row never inherits configuration_update support from a GPT-6 template", () => { + // Codex 0.164 sends positional `configuration_update` effort items only for rows declaring + // this flag; a routed provider must keep effort on the ordinary request parameter. + const template: RawEntry = { slug: "gpt-6-sol", supports_reasoning_effort_updates: true, use_responses_lite: true }; + const routed = deriveEntry(template, "other/model", "Other provider", 5, { provider: "other", id: "model" }); + expect(routed).not.toHaveProperty("supports_reasoning_effort_updates"); + expect(routed).not.toHaveProperty("use_responses_lite"); + expect(template.supports_reasoning_effort_updates).toBe(true); + // The native row and the exact Codex-forward alias keep the upstream contract. + expect(deriveEntry(template, "gpt-6-sol", "Native", 5)).toHaveProperty("supports_reasoning_effort_updates", true); + const codexForward = deriveEntry(template, "openai/gpt-6-sol", "Codex forward", 5, { + provider: "openai", id: "gpt-6-sol", codexForwardNativeCapabilityAlias: true, + }); + expect(codexForward).toHaveProperty("supports_reasoning_effort_updates", true); + }); test("keeps access programs from each authenticated roster separate", async () => { const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { credentials: [credential(MAIN_CODEX_ACCOUNT_ID), credential("secondary")], diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 09497099884..005f2051f32 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1103,6 +1103,7 @@ "responses-hosted-tool-declaration.test.ts": "responses", "responses-hosted-tool-min-spread.test.ts": "responses", "responses-hide-raw-reasoning.test.ts": "responses", "responses-image-gen-repair.test.ts": "responses", "responses-inbound-store-default.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", + "codex-0164-request-compat.test.ts": "responses", "responses-json-events.test.ts": "responses", "responses-legacy-dotted-tool-name-repair.test.ts": "responses", "responses-muse-tool-name-alias.test.ts": "responses", "responses-muse-tool-choice.test.ts": "responses", "responses-native-main-refresh.test.ts": "responses", "responses-opaque-blob-recovery.test.ts": "responses", "responses-cold-opaque-recovery.test.ts": "responses", diff --git a/tests/responses/codex-0164-request-compat.test.ts b/tests/responses/codex-0164-request-compat.test.ts new file mode 100644 index 00000000000..760e5e23325 --- /dev/null +++ b/tests/responses/codex-0164-request-compat.test.ts @@ -0,0 +1,311 @@ +/** + * Codex 0.164 request compatibility (openai/codex 816fa702..af91f6ef2c). + * + * prompt_cache_key policy (openai/codex#55587): a host keeps the session-derived key, sends a + * Custom key, or Omits the field. Session and thread headers do not change with the policy, and + * sub-agents inherit it. The key is a cache hint only: an omitted or custom key must reach the + * wire exactly as sent, and must not move conversation identity, lineage, or cache diagnostics. + * + * configuration_update (ResponseItem::ConfigurationUpdate): a positional reasoning-effort control. + * OpenAI-operated destinations receive it verbatim and in order; other Responses gateways and + * translated routes drop it without failing the request or turning it into a message. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { codexConversationIdentity } from "../../src/codex/lineage"; +import { codexPoolAffinityKey } from "../../src/codex/auth-context"; +import { parseRequest } from "../../src/responses/parser"; +import { handleResponses, hasUnreadableEncryptedAgentTask } from "../../src/server/responses"; +import { recoverEncryptedAgentTaskWithResult, resetAgentTaskRecoveryState } from "../../src/server/responses/agent-task-recovery"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { appendFinalCacheDiagnostic, cacheDiagnosticPath } from "../../src/usage/cache-diagnostic"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { encryptedInput, routedConfig } from "../helpers/agent-task-recovery"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const CANONICAL = "https://chatgpt.com/backend-api/codex"; +const SESSION = "sess-0164-root"; +const ROOT_THREAD = "thread-0164-root"; +const CHILD_THREAD = "thread-0164-child"; +const CONFIG_UPDATE = { type: "configuration_update", reasoning: { effort: "high" } }; +const IDENTITY_HEADERS = ["session-id", "session_id", "thread-id", "x-codex-parent-thread-id"]; + +let isolated: IsolatedCodexHome; +let home: string; +let previousHome: string | undefined; +let previousCacheDebug: string | undefined; +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + isolated = installIsolatedCodexHome("ocx-0164-codex-"); + previousHome = process.env.OPENCODEX_HOME; + previousCacheDebug = process.env.OPENCODEX_CACHE_DEBUG; + home = mkdtempSync(join(tmpdir(), "ocx-0164-home-")); + process.env.OPENCODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + isolated.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCacheDebug === undefined) delete process.env.OPENCODEX_CACHE_DEBUG; + else process.env.OPENCODEX_CACHE_DEBUG = previousCacheDebug; + removeTreeWithRetry(home); +}); + +interface Wire { url: string; headers: Headers; body: Record } + +const RESPONSES_SSE = "data: " + JSON.stringify({ type: "response.completed", response: { + id: "resp_0164", object: "response", status: "completed", output: [], + usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5, input_tokens_details: { cached_tokens: 0 } }, +} }) + "\n\ndata: [DONE]\n\n"; +const CHAT_SSE = [ + { id: "c", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] }, + { id: "c", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 } }, +].map(chunk => "data: " + JSON.stringify(chunk) + "\n\n").join("") + "data: [DONE]\n\n"; + +function captureWire(): Wire[] { + const seen: Wire[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + seen.push({ url, headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + return new Response(url.includes("/chat/completions") ? CHAT_SSE : RESPONSES_SSE, { + status: 200, headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + return seen; +} + +function config(provider: Partial): OcxConfig { + return { + defaultProvider: "p", + openaiProviderTierVersion: 2, + providers: { p: { adapter: "openai-responses", models: ["gpt-5.6-luna"], ...provider } }, + } as unknown as OcxConfig; +} + +const canonical = (): OcxConfig => config({ authMode: "forward", codexAccountMode: "direct", baseUrl: CANONICAL }); + +function user(text: string): Record { + return { type: "message", role: "user", content: [{ type: "input_text", text }] }; +} + +async function drive( + cfg: OcxConfig, + model: string, + input: unknown[], + options: { headers?: Record; promptCacheKey?: string } = {}, +): Promise<{ status: number; logCtx: RequestLogContext }> { + const token = fakeChatGptJwt({ chatgpt_account_id: "acct-0164", exp: Math.floor(Date.now() / 1000) + 86_400 }); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer " + token, + "chatgpt-account-id": "acct-0164", + ...options.headers, + }, + body: JSON.stringify({ + model, input, stream: true, store: false, + ...(options.promptCacheKey === undefined ? {} : { prompt_cache_key: options.promptCacheKey }), + }), + }), cfg, logCtx); + await response.text(); + return { status: response.status, logCtx }; +} + +function identityOf(wire: Wire): Record { + return Object.fromEntries(IDENTITY_HEADERS.map(name => [name, wire.headers.get(name)])); +} + +const rootHeaders = { "session-id": SESSION, "thread-id": ROOT_THREAD }; +const childHeaders = { "session-id": SESSION, "thread-id": CHILD_THREAD, "x-codex-parent-thread-id": ROOT_THREAD }; + +describe("prompt_cache_key policy (openai/codex#55587)", () => { + const policies = [ + { name: "SessionDerived", key: SESSION }, + { name: "Custom", key: "host-chosen-cache-key" }, + { name: "Omit", key: undefined }, + ] as const; + + test("native passthrough sends each policy verbatim and keeps session/thread identity", async () => { + const seen = captureWire(); + const conversationIds: Array = []; + for (const policy of policies) { + const { status, logCtx } = await drive(canonical(), "p/gpt-5.6-luna", [user("ping")], { + headers: rootHeaders, promptCacheKey: policy.key, + }); + expect(status).toBe(200); + conversationIds.push(logCtx.conversationId); + } + expect(seen).toHaveLength(3); + for (const [index, policy] of policies.entries()) { + const wire = seen[index]!; + expect(wire.url).toBe(CANONICAL + "/responses"); + expect(Object.hasOwn(wire.body, "prompt_cache_key")).toBe(policy.key !== undefined); + expect(wire.body.prompt_cache_key).toBe(policy.key); + // The policy controls the body only; the identity headers are the same on every request. + expect(identityOf(wire)).toEqual(identityOf(seen[0]!)); + } + expect(identityOf(seen[0]!)).toMatchObject({ "session-id": SESSION, "thread-id": ROOT_THREAD }); + // A custom or missing key is not a conversation change in Logs. + expect(conversationIds[0]).toBeString(); + expect(new Set(conversationIds).size).toBe(1); + }, 20_000); + + test("a sub-agent that inherits Omit keeps its headers, lineage cohort, and log identity", async () => { + const seen = captureWire(); + const withKey = await drive(canonical(), "p/gpt-5.6-luna", [user("child task")], { + headers: childHeaders, promptCacheKey: SESSION, + }); + const omitted = await drive(canonical(), "p/gpt-5.6-luna", [user("child task")], { headers: childHeaders }); + expect([withKey.status, omitted.status]).toEqual([200, 200]); + expect(seen).toHaveLength(2); + expect(seen[1]!.body).not.toHaveProperty("prompt_cache_key"); + expect(identityOf(seen[1]!)).toEqual(identityOf(seen[0]!)); + expect(identityOf(seen[1]!)).toMatchObject({ + "session-id": SESSION, "thread-id": CHILD_THREAD, "x-codex-parent-thread-id": ROOT_THREAD, + }); + expect(omitted.logCtx.conversationId).toBe(withKey.logCtx.conversationId); + + // Lineage and pool affinity read the headers the child actually sent; with the key omitted the + // child still joins its root's cohort instead of splitting off. + const root = new Headers(rootHeaders); + const child = new Headers(childHeaders); + expect(codexPoolAffinityKey(child)).toBe(codexPoolAffinityKey(root)); + expect(codexConversationIdentity(child)?.conversationKey) + .toBe(codexConversationIdentity(root)?.conversationKey); + }, 20_000); + + test("translated Chat route forwards Custom verbatim and omits the field under Omit", async () => { + const seen = captureWire(); + const chat = config({ + adapter: "openai-chat", authMode: "key", apiKey: "chat-key", baseUrl: "https://chat.example/v1", + promptCacheKey: true, models: ["m"], + } as Partial); + for (const key of ["host-chosen-cache-key", undefined]) { + const { status } = await drive(chat, "p/m", [user("ping")], { headers: rootHeaders, promptCacheKey: key }); + expect(status).toBe(200); + } + expect(seen.map(wire => wire.url)).toEqual(Array(2).fill("https://chat.example/v1/chat/completions")); + expect(seen[0]!.body.prompt_cache_key).toBe("host-chosen-cache-key"); + expect(seen[1]!.body).not.toHaveProperty("prompt_cache_key"); + }, 20_000); + + test("cache diagnostics record an omitted key as absent, never as a mismatch", async () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + captureWire(); + for (const [index, policy] of policies.entries()) { + const { logCtx } = await drive(canonical(), "p/gpt-5.6-luna", [user("ping")], { + headers: rootHeaders, promptCacheKey: policy.key, + }); + expect(logCtx.cacheDiagnosticDraft).toBeDefined(); + appendFinalCacheDiagnostic({ + requestId: "req-0164-" + index, protocol: "responses", provider: "p", model: "gpt-5.6-luna", + cacheProvenance: "observed", draft: logCtx.cacheDiagnosticDraft, + }); + } + const records = readFileSync(cacheDiagnosticPath(), "utf8").trim().split("\n") + .map(line => JSON.parse(line) as { promptCacheKey: Record; session: Record }); + expect(records).toHaveLength(3); + const [derived, custom, omitted] = records; + for (const record of [derived!, custom!]) { + expect(record.promptCacheKey).toMatchObject({ + inbound: { present: true, source: "caller" }, outbound: { present: true, source: "caller" }, equal: true, + }); + } + expect(omitted!.promptCacheKey).toEqual({ inbound: { present: false }, outbound: { present: false } }); + // The session header is the identity signal, and it is the same under every policy. + expect(new Set(records.map(record => JSON.stringify(record.session))).size).toBe(1); + }, 20_000); +}); + +describe("configuration_update input item", () => { + const input = () => [user("one"), structuredClone(CONFIG_UPDATE), user("two")]; + const provenance = { content_item_metadata: [{ source: "user" }] }; + + test("OpenAI-operated destinations receive it verbatim and in order", async () => { + const seen = captureWire(); + for (const cfg of [canonical(), config({ authMode: "key", apiKey: "sk-test", baseUrl: "https://api.openai.com/v1" })]) { + const { status } = await drive(cfg, "p/gpt-5.6-luna", input(), { headers: rootHeaders }); + expect(status).toBe(200); + } + expect(seen).toHaveLength(2); + for (const wire of seen) { + const items = wire.body.input as Array>; + const at = items.findIndex(item => item.type === "configuration_update"); + expect(items[at]).toEqual(CONFIG_UPDATE); + expect(items[at - 1]).toMatchObject({ role: "user", content: [{ text: "one" }] }); + expect(items[at + 1]).toMatchObject({ role: "user", content: [{ text: "two" }] }); + } + }, 20_000); + + test("a third-party Responses gateway gets the conversation without it or RLite provenance", async () => { + const seen = captureWire(); + const items = input(); + items[0] = { ...items[0], internal_chat_message_metadata_passthrough: provenance }; + const { status } = await drive( + config({ authMode: "key", apiKey: "gw-key", baseUrl: "https://gateway.example/v1" }), "p/gw-model", items, + { headers: rootHeaders }, + ); + expect(status).toBe(200); + const sent = seen[0]!.body.input as Array>; + expect(sent.some(item => item.type === "configuration_update")).toBe(false); + expect(sent.some(item => Object.hasOwn(item, "internal_chat_message_metadata_passthrough"))).toBe(false); + expect(sent.filter(item => item.role === "user").map(item => (item.content as Array<{ text: string }>)[0]!.text)) + .toEqual(["one", "two"]); + }, 20_000); + + test("a translated route drops it without failing or inventing a message", async () => { + const seen = captureWire(); + const { status } = await drive(config({ + adapter: "openai-chat", authMode: "key", apiKey: "chat-key", baseUrl: "https://chat.example/v1", models: ["m"], + } as Partial), "p/m", input(), { headers: rootHeaders }); + expect(status).toBe(200); + const messages = seen[0]!.body.messages as Array<{ role: string; content: unknown }>; + expect(JSON.stringify(messages)).not.toContain("configuration_update"); + expect(messages.filter(message => message.role === "user").map(message => JSON.stringify(message.content))) + .toEqual([expect.stringContaining("one"), expect.stringContaining("two")]); + }, 20_000); + + test("the parser keeps pending reasoning attached across it", () => { + const parsed = parseRequest({ + model: "p/m", + input: [ + user("q"), + { type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] }, + structuredClone(CONFIG_UPDATE), + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + }); + expect(parsed.context.messages.map(message => message.role)).toEqual(["user", "assistant"]); + const assistant = parsed.context.messages[1] as { content: Array<{ type: string }> }; + expect(assistant.content.map(part => part.type)).toEqual(["thinking", "text"]); + }); + + test("a trailing update does not hide the agent task before it", async () => { + resetAgentTaskRecoveryState(); + let fetches = 0; + globalThis.fetch = (async () => { fetches += 1; throw new Error("must-not-fetch"); }) as typeof fetch; + const tailed = [...encryptedInput(), structuredClone(CONFIG_UPDATE)]; + expect(hasUnreadableEncryptedAgentTask(tailed)).toBe(true); + // Without credentials the envelope is found and then refused; a missed envelope would read as + // unsupported instead. + expect(await recoverEncryptedAgentTaskWithResult(new Request("http://localhost/v1/responses"), tailed, {}, routedConfig())) + .toEqual({ recovered: false, reason: "admission_denied" }); + expect(fetches).toBe(0); + resetAgentTaskRecoveryState(); + }); +}); diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index c2a7eead5f9..0e08f0a5b80 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -287,6 +287,8 @@ test("call-create forwards Frameless protocol headers and keeps auth pool-owned" "thread-id": "thread_def", originator: "codex_app", "x-oai-attestation": "attest-blob", + // openai/codex#53784: client voice session id, distinct from the realtime x-session-id. + "voice-session-id": "voice_456", // Must NOT be forwarded: account-derived compliance header. "x-openai-fedramp": "true", }, @@ -302,6 +304,7 @@ test("call-create forwards Frameless protocol headers and keeps auth pool-owned" expect(headers.get("thread-id")).toBe("thread_def"); expect(headers.get("originator")).toBe("codex_app"); expect(headers.get("x-oai-attestation")).toBe("attest-blob"); + expect(headers.get("voice-session-id")).toBe("voice_456"); expect(headers.get("x-openai-fedramp")).toBeNull(); // Auth stays proxy-resolved, not blindly copied from the whitelist path. expect(headers.get("authorization")).toBe(`Bearer ${DIRECT_CHATGPT_TOKEN}`); @@ -338,6 +341,7 @@ test("call-create without protocol headers does not invent them upstream", async expect(headers.get("thread-id")).toBeNull(); expect(headers.get("originator")).toBeNull(); expect(headers.get("x-oai-attestation")).toBeNull(); + expect(headers.get("voice-session-id")).toBeNull(); } finally { await server.stop(true); await upstream.stop(true); From b50cc8050f25fe4b31f81d7d3aceeba4f430fcaf Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 22:16:12 +0900 Subject: [PATCH 08/25] feat(management): passive GET /api/quota-pool (ocx.quota-pool/1 producer) (#7476) * feat(management): passive GET /api/quota-pool producing ocx.quota-pool/1 Assemble GOAT's ocx.quota-pool/1 contract from caches the Home already holds (Codex pool, held provider reports, per-account OAuth quota, Claude CLI admission), always inside runPassiveRead: no probe, token refresh, upstream call or write. accountRef is an install-keyed HMAC; homeId and the key live in quota-pool-identity.json, minted at server start. Readable over the Remote Link management relay and the Hub client relay. * fix(quota-pool): exclusive no-follow identity creation, passive reauth state, CORS Publish quota-pool-identity.json by hard-linking a private temp file (exclusive create fallback), so concurrent first starts adopt one identity and a symlink or malformed file is refused, never overwritten. Derive Codex reauth from needsReauth only, since a passive read does not check credentials. Pass req/config so the response carries CORS headers. Clear the relay test's seeded Codex quota so it cannot leak into later files. --- .../content/docs/reference/management-api.md | 1 + scripts/test-layout/layout.json | 2 +- src/adapters/claude-cli/usage-admission.ts | 14 + src/codex/quota-history.ts | 7 + src/codex/quota.ts | 10 + src/link/management-routes.ts | 1 + src/quota-pool/assemble.ts | 326 ++++++++++++++++++ src/quota-pool/contract.ts | 58 ++++ src/quota-pool/identity.ts | 141 ++++++++ src/server/background-lifecycle.ts | 4 + src/server/management-api.ts | 8 + src/server/management/quota-pool-routes.ts | 24 ++ src/server/management/route-registry.ts | 1 + structure/INDEX.md | 3 + structure/gui-and-management-api.md | 2 +- structure/manifest.json | 10 + structure/quota-pool.md | 79 +++++ structure/remote-link.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/helpers/quota-pool-schema.ts | 68 ++++ tests/server/quota-pool-relay.test.ts | 248 +++++++++++++ tests/usage/quota-pool.test.ts | 245 +++++++++++++ 22 files changed, 1252 insertions(+), 4 deletions(-) create mode 100644 src/quota-pool/assemble.ts create mode 100644 src/quota-pool/contract.ts create mode 100644 src/quota-pool/identity.ts create mode 100644 src/server/management/quota-pool-routes.ts create mode 100644 structure/quota-pool.md create mode 100644 tests/helpers/quota-pool-schema.ts create mode 100644 tests/server/quota-pool-relay.test.ts create mode 100644 tests/usage/quota-pool.test.ts diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index fd71559980a..1ef9de2d0a5 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -668,6 +668,7 @@ setting interacts with `claudeCode.enabled` across upgrades and downgrades. | `DELETE /api/providers?name=...` | Delete a provider, reassigning the default when possible | 404 unknown provider; 409 `last_provider`; 409 `provider_has_dependent_combos` | | `POST /api/providers/test?name=...` | Perform a bounded live provider connectivity/model-discovery probe | 404 unknown provider; failures are normally returned as `ok: false` evidence | | `GET /api/provider-quotas` | Read provider quota reports; `refresh=1` forces refresh | — | +| `GET /api/quota-pool` | Read the passive `ocx.quota-pool/1` account quota pool (per-account and pooled windows, opaque account references) from cached evidence only; never probes or refreshes, and ignores `refresh`. Also readable from a Remote Link Child or Hub client at `/api/machine/hub-relay/api/quota-pool` | — | | `GET /api/quota-resets` | List recently detected quota-window resets and whether detection is enabled; `limit=` caps the count | 400 invalid `limit` | | `GET, PUT /api/provider-context-caps` | Read or update global, all-provider, or one-provider context caps | 400 invalid request; 404 unknown provider | | `GET /api/provider-presets` | Return GUI provider presets derived from the runtime registry | — | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d3977b7b624..c0b8337735b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -144,7 +144,7 @@ "account-import.test.ts": "server", "account-import-project-fallback.test.ts": "server", "account-pool-management-api.test.ts": "server", - "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", + "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", "quota-pool-relay.test.ts": "server", "quota-pool.test.ts": "usage", "link-listener-lifecycle.test.ts": "server", "link-listener-mutation.test.ts": "server", "link-join-route.test.ts": "server", "core-link-boundary.test.ts": "lab", diff --git a/src/adapters/claude-cli/usage-admission.ts b/src/adapters/claude-cli/usage-admission.ts index 548e03723e9..00446ee8438 100644 --- a/src/adapters/claude-cli/usage-admission.ts +++ b/src/adapters/claude-cli/usage-admission.ts @@ -181,6 +181,20 @@ async function checkAdmission(model: string, deps: AdmissionDeps, now: number): } let preflightOverride: ((model: string) => Promise) | undefined; + +/** The last usage the admission recorded, as `/api/quota-pool` reports it. */ +export type ClaudeUsageObservation = Readonly; + +/** + * The newest recorded usage snapshot, from memory or the admission file. Read-only: it resolves + * no sign-in (no Keychain read), runs no probe and writes nothing. `identity` is the admission's + * own SHA-256 account key, never the account uuid itself. + */ +export function peekClaudeUsageObservation(deps: Pick = {}): ClaudeUsageObservation | null { + const path = statePath(deps); + const rows = [load(path), memory.get(path)].filter((s): s is Snapshot => s !== undefined); + return rows.sort((a, b) => b.checkedAt - a.checkedAt)[0] ?? null; +} /** Test seam for the pre-dispatch check in the Responses executor; call with no argument to restore. */ export function setClaudeUsagePreflightForTests(fn?: (model: string) => Promise): void { preflightOverride = fn; } /** The pre-dispatch check the Responses executor runs before a claude-cli turn. */ diff --git a/src/codex/quota-history.ts b/src/codex/quota-history.ts index a792f89597c..099a7562fe2 100644 --- a/src/codex/quota-history.ts +++ b/src/codex/quota-history.ts @@ -92,6 +92,13 @@ export class CodexQuotaHistory { return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped }; } + /** The newest sample's origin, without pruning or clearing: a passive read changes nothing. */ + peekLatestSource(accountId: string, identity: string | undefined): QuotaHistorySample["source"] | undefined { + const bucket = this.accounts.get(accountId); + if (!identity || !bucket || bucket.identity !== identity) return undefined; + return bucket.samples.at(-1)?.source; + } + clear(accountId?: string): number { if (accountId === undefined) { const count = this.accounts.size; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 334c8515801..0088c4968d6 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -1099,3 +1099,13 @@ export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_ return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample), truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } }; } + +/** + * Where the newest stored reading for a pool account came from (a WHAM usage body or upstream + * response headers), or undefined when no current history exists. Read-only: unlike + * `getAccountQuotaHistory` it never prunes or clears a mismatched bucket. + */ +export function latestCodexQuotaObservationSource(accountId: string): "wham" | "response-header" | undefined { + hydrateAccountQuotasFromDisk(); + return quotaHistory.peekLatestSource(accountId, poolQuotaHistoryIdentity(accountId)); +} diff --git a/src/link/management-routes.ts b/src/link/management-routes.ts index 3ac88deaedd..5549c193fec 100644 --- a/src/link/management-routes.ts +++ b/src/link/management-routes.ts @@ -29,6 +29,7 @@ export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [ "/api/sidecar-settings", "/api/v2", "/api/provider-quotas", + "/api/quota-pool", "/api/codex-auth/active", "/api/codex-auth/accounts", "/api/oauth/accounts", diff --git a/src/quota-pool/assemble.ts b/src/quota-pool/assemble.ts new file mode 100644 index 00000000000..419a2ea63eb --- /dev/null +++ b/src/quota-pool/assemble.ts @@ -0,0 +1,326 @@ +/** + * Assemble `ocx.quota-pool/1` (`GET /api/quota-pool`) from what this Home already holds. + * + * Strictly passive. Every source below is a cache another path filled; this module starts no + * probe, token refresh, quota refresh or upstream call and writes nothing. The whole assembly runs + * inside `runPassiveRead`, so even the shared readers it calls (the Codex account list, the OAuth + * store) take their passive branch: no WHAM probe, no native-main claim, no file hardening or + * malformed-store backup. Sources: + * + * - Codex pool accounts: the passive `listCodexAuthAccountsSnapshot` (stored `StoredAccountQuota` + * per account plus the cached main-account reading); + * - provider-level reports: `heldProviderQuotaReports`, the reports the Home last published + * (for the Codex pool provider this is OpenCodex's own pool aggregate, reported as `pooled`); + * - per-account OAuth quota (Anthropic instances, Kiro, Antigravity, xAI, Cursor, Kimi, Command + * Code, Devin, DSH, Meta): the last-good per-account cache; + * - Claude CLI: the usage-admission snapshot (5-hour, weekly and family windows, plus a parsed + * CLI limit refusal). + * + * An unknown window is omitted, never emitted as 0%. A window whose reset has passed is unknown + * again and is omitted too. Optional fields are left out instead of set to null. + */ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { ProviderQuota, ProviderQuotaWindow } from "../providers/quota-types"; +import type { StoredAccountQuota } from "../codex/quota-types"; +import { isTerminalShortWindow, resetAtToMs } from "../codex/quota-types"; +import { runPassiveRead } from "../lib/passive-read"; +import { packageVersion } from "../lib/package-version"; +import { listCodexAuthAccountsSnapshot } from "../codex/auth-api"; +import { latestCodexQuotaObservationSource } from "../codex/quota"; +import { ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination"; +import { hasCodexPoolProvider, heldProviderQuotaReports, type ProviderQuotaReport } from "../providers/quota/report-cache"; +import { + accountCacheKey, + accountQuotaCache, + getCachedProviderAccountQuota, + hasPassiveAccountQuota, + supportsPerAccountQuota, +} from "../providers/quota/account-cache"; +import { parseQuotaFailureCode } from "../providers/quota-types"; +import { getAccountSet } from "../oauth/store"; +import { peekClaudeUsageObservation } from "../adapters/claude-cli/usage-admission"; +import { + QUOTA_POOL_SCHEMA, + type QuotaPool, + type QuotaPoolAccount, + type QuotaPoolAccountState, + type QuotaPoolProvider, + type QuotaPoolRole, + type QuotaPoolSource, + type QuotaPoolWindow, +} from "./contract"; +import { quotaPoolAccountRef, readQuotaPoolIdentity, type QuotaPoolIdentity } from "./identity"; + +const VERSION = packageVersion("0.0.0"); +const SHORT_BURST_MAX_SECONDS = 6 * 3600; +const SHORT_DAILY_MAX_SECONDS = 36 * 3600; + +export interface QuotaPoolDeps { + now?: () => number; + /** Test seam for the Claude CLI admission file. */ + claudeUsageStatePath?: string; +} + +export type QuotaPoolResult = { ok: true; pool: QuotaPool } | { ok: false; error: "quota_pool_identity_unavailable" }; + +const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + +function iso(ms: number): string | undefined { + if (!finite(ms) || ms <= 0) return undefined; + const date = new Date(ms); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; +} + +/** A used percent inside the contract's 0..100, or undefined when there is no reading. */ +function percent(value: unknown): number | undefined { + if (!finite(value) || value < 0) return undefined; + return Math.min(100, value); +} + +type WindowDraft = Omit & { resetMs?: number }; + +/** + * Finish a window, or drop it when it carries nothing (no percent, no counters) or its reset has + * already passed: an elapsed window is unknown now, not empty. + */ +function finish(draft: WindowDraft, source: QuotaPoolSource, observedMs: number, now: number): QuotaPoolWindow | null { + const observedAt = iso(observedMs); + if (!observedAt) return null; + const { resetMs, ...rest } = draft; + if (resetMs !== undefined && resetMs <= now) return null; + if (rest.usedPercent === undefined && rest.used === undefined && rest.limit === undefined && rest.exhausted === undefined) return null; + const resetsAt = resetMs !== undefined ? iso(resetMs) : undefined; + const exhausted = rest.exhausted ?? (rest.usedPercent !== undefined ? rest.usedPercent >= 100 : undefined); + const window: QuotaPoolWindow = { ...rest, source, observedAt }; + if (resetsAt) window.resetsAt = resetsAt; + if (exhausted !== undefined) window.exhausted = exhausted; + return window; +} + +function resetMs(value: unknown): number | undefined { + return finite(value) && value > 0 ? resetAtToMs(value) : undefined; +} + +function shortKind(seconds: number | undefined): Pick { + if (seconds === undefined || seconds <= SHORT_BURST_MAX_SECONDS) return { id: "5h", kind: "burst_5h", label: "5-hour" }; + if (seconds <= SHORT_DAILY_MAX_SECONDS) return { id: "daily", kind: "daily", label: "Daily" }; + return { id: "short", kind: "custom", label: "Short window" }; +} + +/** Codex `StoredAccountQuota`, mapped as the header and WHAM parsers stored it. */ +export function codexQuotaWindows(quota: StoredAccountQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] { + const out: QuotaPoolWindow[] = []; + const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); }; + const shortPercent = percent(quota.shortPercent); + const shortSeconds = finite(quota.shortWindowSeconds) && quota.shortWindowSeconds > 0 ? quota.shortWindowSeconds : undefined; + if (shortPercent !== undefined) { + push(finish({ + ...shortKind(shortSeconds), + usedPercent: shortPercent, + ...(shortSeconds !== undefined ? { windowSeconds: shortSeconds } : {}), + resetMs: resetMs(quota.shortResetAt), + exhausted: isTerminalShortWindow(quota, now), + }, source, quota.shortObservedAt ?? quota.updatedAt, now)); + } + const weekly = percent(quota.weeklyPercent); + if (weekly !== undefined) { + push(finish({ id: "weekly", kind: "weekly", label: "Weekly", usedPercent: weekly, resetMs: resetMs(quota.weeklyResetAt) }, source, quota.updatedAt, now)); + } + const monthly = percent(quota.monthlyPercent); + if (monthly !== undefined) { + push(finish({ id: "monthly", kind: "monthly", label: "Monthly", usedPercent: monthly, resetMs: resetMs(quota.monthlyResetAt) }, source, quota.updatedAt, now)); + } + for (const custom of quota.customWindows ?? []) { + const used = percent(custom?.percent); + if (typeof custom?.label !== "string" || !custom.label.trim() || used === undefined) continue; + push(finish({ id: `custom:${custom.label}`, kind: "custom", label: custom.label, usedPercent: used, resetMs: resetMs(custom.resetAt) }, source, quota.updatedAt, now)); + } + return out; +} + +function customWindow(window: ProviderQuotaWindow, source: QuotaPoolSource, updatedAt: number, now: number): QuotaPoolWindow | null { + if (typeof window?.label !== "string" || !window.label.trim()) return null; + // An exact-model row (Antigravity diagnostics, CCA fallback) restates the pool its family + // window already reports and carries no proven scope; reporting it unscoped would let it gate + // every model. + if (window.modelId !== undefined && window.scope !== "model") return null; + const scoped = window.scope === "model"; + const passive = finite(window.passiveObservedAt); + return finish({ + id: scoped ? `family:${window.label.toLowerCase()}` : `custom:${window.label}`, + kind: "custom", + label: window.label, + ...(percent(window.percent) !== undefined ? { usedPercent: percent(window.percent) } : {}), + ...(scoped ? { scopeModel: window.label } : {}), + ...(window.rejected === true ? { exhausted: true } : {}), + resetMs: resetMs(window.resetAt), + }, passive ? "header" : source, passive ? window.passiveObservedAt! : updatedAt, now); +} + +/** A `ProviderQuota` (provider report or per-account cache row). */ +export function providerQuotaWindows(quota: ProviderQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] { + const out: QuotaPoolWindow[] = []; + const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); }; + const at = quota.updatedAt; + const fixed = [ + ["5h", "burst_5h", "5-hour", quota.fiveHourPercent, quota.fiveHourResetAt], + ["weekly", "weekly", "Weekly", quota.weeklyPercent, quota.weeklyResetAt], + ["monthly", "monthly", "Monthly", quota.monthlyPercent, quota.monthlyResetAt], + ] as const; + for (const [id, kind, label, value, reset] of fixed) { + const used = percent(value); + if (used === undefined) continue; + push(finish({ id, kind, label, usedPercent: used, resetMs: resetMs(reset) }, source, at, now)); + } + const kiroUsed = finite(quota.kiroCreditsUsed) && quota.kiroCreditsUsed >= 0 ? quota.kiroCreditsUsed : undefined; + const kiroLimit = finite(quota.kiroCreditsLimit) && quota.kiroCreditsLimit > 0 ? quota.kiroCreditsLimit : undefined; + if (kiroUsed !== undefined || kiroLimit !== undefined) { + push(finish({ + id: "credits:kiro", kind: "credits", label: "Kiro credits", unit: "credits", + ...(kiroUsed !== undefined ? { used: kiroUsed } : {}), + ...(kiroLimit !== undefined ? { limit: kiroLimit } : {}), + ...(kiroUsed !== undefined && kiroLimit !== undefined ? { usedPercent: percent((kiroUsed / kiroLimit) * 100) } : {}), + }, source, at, now)); + } + const usd = quota.creditsUsd; + if (usd && finite(usd.used) && usd.used >= 0) { + const unlimited = usd.unlimited === true; + push(finish({ + id: "credits:usd", kind: "credits", label: "USD credits", unit: "usd", used: usd.used, + ...(!unlimited && finite(usd.limit) && usd.limit > 0 ? { limit: usd.limit } : {}), + ...(!unlimited && percent(usd.percent) !== undefined ? { usedPercent: percent(usd.percent) } : {}), + ...(unlimited ? { exhausted: false } : finite(usd.remaining) ? { exhausted: usd.remaining <= 0 } : {}), + }, source, at, now)); + } + for (const custom of quota.customWindows ?? []) push(customWindow(custom, source, at, now)); + return out; +} + +function accountState(input: { reauth: boolean; paused: boolean; windows: readonly QuotaPoolWindow[] }): QuotaPoolAccountState { + if (input.reauth) return "reauth"; + if (input.paused) return "paused"; + // A model-scoped window never gates the account as a whole. + if (input.windows.some(window => window.exhausted === true && window.scopeModel === undefined)) return "exhausted"; + return input.windows.length === 0 ? "unknown" : "available"; +} + +function nextResetAt(windows: readonly QuotaPoolWindow[]): string | undefined { + const resets = windows.filter(window => window.scopeModel === undefined && window.resetsAt).map(window => window.resetsAt!).sort(); + return resets[0]; +} + +function reportSource(report: ProviderQuotaReport): QuotaPoolSource { + if (report.aggregation) return "estimated"; + return report.observed === true ? "header" : "measured"; +} + +function codexAccounts(identity: QuotaPoolIdentity, accounts: Awaited>["accounts"], now: number): QuotaPoolAccount[] { + const provider = OPENAI_CODEX_PROVIDER_ID; + return accounts.map(dto => { + const source: QuotaPoolSource = latestCodexQuotaObservationSource(dto.id) === "response-header" ? "header" : "measured"; + const windows = dto.quota ? codexQuotaWindows(dto.quota as StoredAccountQuota, source, now) : []; + const account: QuotaPoolAccount = { + provider, + accountRef: quotaPoolAccountRef(identity, provider, dto.id), + // needsReauth only: a passive read leaves hasCredential false when it did not check, and an + // account without a credential already carries needsReauth. + state: accountState({ reauth: dto.needsReauth === true, paused: dto.paused, windows }), + windows, + }; + if (typeof dto.logLabel === "string" && ACCOUNT_LOG_LABEL_RE.test(dto.logLabel)) account.label = dto.logLabel; + if (typeof dto.plan === "string" && dto.plan.trim()) account.plan = dto.plan.trim(); + return account; + }); +} + +function oauthAccounts(identity: QuotaPoolIdentity, provider: string, now: number): QuotaPoolAccount[] { + const set = getAccountSet(provider); + if (!set) return []; + return set.accounts.map(row => { + const quota = getCachedProviderAccountQuota(provider, row.id); + const entry = accountQuotaCache.get(accountCacheKey(provider, row.id)); + const windows = quota ? providerQuotaWindows(quota, "measured", now) : []; + const account: QuotaPoolAccount = { + provider, + accountRef: quotaPoolAccountRef(identity, provider, row.id), + label: oauthAccountLogLabel(row.id, provider), + state: accountState({ reauth: row.needsReauth === true, paused: row.paused === true, windows }), + windows, + }; + const failure = entry?.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? parseQuotaFailureCode(entry.quotaFailure) : undefined; + if (failure) account.failure = failure; + return account; + }); +} + +function claudeCliAccounts(identity: QuotaPoolIdentity, provider: string, deps: QuotaPoolDeps, now: number): QuotaPoolAccount[] { + const observation = peekClaudeUsageObservation(deps.claudeUsageStatePath ? { statePath: deps.claudeUsageStatePath } : {}); + if (!observation) return []; + const windows = observation.quota ? providerQuotaWindows(observation.quota, "measured", now) : []; + if (finite(observation.refusalUntil) && observation.refusalUntil > now) { + const refusal = finish({ id: "refusal", kind: "custom", label: "Claude CLI limit", exhausted: true, resetMs: observation.refusalUntil }, + "estimated", observation.checkedAt, now); + if (refusal) windows.push(refusal); + } + return [{ + provider, + accountRef: quotaPoolAccountRef(identity, provider, `claude-cli:${observation.identity}`), + state: accountState({ reauth: false, paused: false, windows }), + windows, + }]; +} + +function role(config: OcxConfig): QuotaPoolRole { + return config.runtimeRole === "hub" || config.runtimeRole === "client" ? config.runtimeRole : "standalone"; +} + +function enabled(provider: OcxProviderConfig | undefined): provider is OcxProviderConfig { + return !!provider && provider.disabled !== true; +} + +async function assemble(config: OcxConfig, identity: QuotaPoolIdentity, deps: QuotaPoolDeps): Promise { + const now = (deps.now ?? Date.now)(); + const names = Object.keys(config.providers).filter(name => enabled(config.providers[name])).sort(); + const reports = new Map(heldProviderQuotaReports(names).reports.map(report => [report.provider, report])); + const codexPool = hasCodexPoolProvider(config); + const codex = codexPool ? (await listCodexAuthAccountsSnapshot(config)).accounts : []; + const providers: QuotaPoolProvider[] = []; + for (const name of names) { + const provider = config.providers[name]!; + let accounts: QuotaPoolAccount[] = []; + let tracked = false; + if (codexPool && name === OPENAI_CODEX_PROVIDER_ID) { + tracked = true; + accounts = codexAccounts(identity, codex, now); + } else if (provider.adapter === "claude-cli") { + tracked = true; + accounts = claudeCliAccounts(identity, name, deps, now); + } else if (supportsPerAccountQuota(name) || hasPassiveAccountQuota(name)) { + tracked = true; + accounts = oauthAccounts(identity, name, now); + } + const report = reports.get(name); + const pooledWindows = report ? providerQuotaWindows(report.quota, reportSource(report), now) : []; + if (!tracked && !report) continue; + const pooled: QuotaPoolProvider["pooled"] = { windows: pooledWindows }; + const next = nextResetAt(pooledWindows); + if (next) pooled.nextResetAt = next; + providers.push({ provider: name, accounts, pooled }); + } + return { + schema: QUOTA_POOL_SCHEMA, + producer: { version: VERSION, role: role(config), homeId: identity.homeId }, + generatedAt: iso(now) ?? new Date().toISOString(), + providers, + }; +} + +/** The quota pool, or an identity error. Always a passive read, whoever asks. */ +export function readQuotaPool(config: OcxConfig, deps: QuotaPoolDeps = {}): Promise { + return runPassiveRead(async () => { + const identity = readQuotaPoolIdentity(); + if (!identity) return { ok: false, error: "quota_pool_identity_unavailable" } as const; + return { ok: true, pool: await assemble(config, identity, deps) } as const; + }); +} diff --git a/src/quota-pool/contract.ts b/src/quota-pool/contract.ts new file mode 100644 index 00000000000..88da277d0d2 --- /dev/null +++ b/src/quota-pool/contract.ts @@ -0,0 +1,58 @@ +/** + * Wire shape of `GET /api/quota-pool`: the GOAT contract `ocx.quota-pool/1`. + * + * GOAT owns the decoder (`packages/contracts/src/quotaPool.ts` in goat-si/goat); OpenCodex is + * the producer. Every optional field is OMITTED when OpenCodex does not know it. A missing + * window means unknown, never 0%, and no field is ever `null`. + */ + +export const QUOTA_POOL_SCHEMA = "ocx.quota-pool/1" as const; + +export type QuotaPoolWindowKind = "burst_5h" | "daily" | "weekly" | "monthly" | "credits" | "custom"; +/** measured: a usage probe body; header: response rate-limit headers; estimated: derived by OpenCodex. */ +export type QuotaPoolSource = "measured" | "header" | "estimated"; +export type QuotaPoolUnit = "requests" | "tokens" | "credits" | "usd"; +export type QuotaPoolAccountState = "available" | "exhausted" | "paused" | "reauth" | "unknown"; +export type QuotaPoolRole = "standalone" | "hub" | "client"; + +export interface QuotaPoolWindow { + id: string; + kind: QuotaPoolWindowKind; + label: string; + usedPercent?: number; + used?: number; + limit?: number; + unit?: QuotaPoolUnit; + resetsAt?: string; + windowSeconds?: number; + scopeModel?: string; + source: QuotaPoolSource; + observedAt: string; + exhausted?: boolean; +} + +export interface QuotaPoolAccount { + provider: string; + driver?: string; + /** Opaque, stable for this install, never an email or a raw account id (see identity.ts). */ + accountRef: string; + label?: string; + plan?: string; + state: QuotaPoolAccountState; + windows: QuotaPoolWindow[]; + /** A closed `QuotaFailureCode`; never upstream text. */ + failure?: string; +} + +export interface QuotaPoolProvider { + provider: string; + accounts: QuotaPoolAccount[]; + pooled: { windows: QuotaPoolWindow[]; headroom?: number; nextResetAt?: string }; +} + +export interface QuotaPool { + schema: typeof QUOTA_POOL_SCHEMA; + producer: { version: string; role: QuotaPoolRole; homeId: string }; + generatedAt: string; + providers: QuotaPoolProvider[]; +} diff --git a/src/quota-pool/identity.ts b/src/quota-pool/identity.ts new file mode 100644 index 00000000000..32b397cf2a5 --- /dev/null +++ b/src/quota-pool/identity.ts @@ -0,0 +1,141 @@ +/** + * The install identity behind `ocx.quota-pool/1`: the Home's `homeId` and the secret that keys + * every `accountRef`. + * + * Both live in `/quota-pool-identity.json` (owner-only). The server creates the + * file once, in the background after it starts listening (`ensureQuotaPoolIdentity`); the + * `/api/quota-pool` read only reads it, so the read path never writes. A read that finds no + * valid file reports the identity as unavailable instead of minting one. + * + * `accountRef` is `acct_` + base64url(HMAC-SHA256(secret, "ocx.quota-pool/1" NUL provider NUL + * accountId)) truncated to 128 bits. It is stable across restarts because the secret is + * persisted, distinct per provider, and not reversible: without the per-install secret a + * recipient cannot confirm a guessed email or account id against it, and it never contains + * either. Deleting the file rotates `homeId` and every `accountRef` together. + */ +import { createHmac, randomBytes, randomUUID } from "node:crypto"; +import { closeSync, constants as fsConstants, linkSync, lstatSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths"; + +export const QUOTA_POOL_IDENTITY_FILE = "quota-pool-identity.json"; + +export interface QuotaPoolIdentity { + homeId: string; + secret: string; +} + +const HOME_ID = /^home_[0-9a-f]{32}$/; +const SECRET = /^[0-9a-f]{64}$/; +let memo: { path: string; identity: QuotaPoolIdentity } | null = null; + +export function quotaPoolIdentityPath(configDir = getConfigDir()): string { + return join(configDir, QUOTA_POOL_IDENTITY_FILE); +} + +function parseIdentity(text: string): QuotaPoolIdentity | null { + try { + const raw = JSON.parse(text) as Record; + if (raw?.version !== 1 || typeof raw.homeId !== "string" || typeof raw.secret !== "string") return null; + if (!HOME_ID.test(raw.homeId) || !SECRET.test(raw.secret)) return null; + return { homeId: raw.homeId, secret: raw.secret }; + } catch { + return null; + } +} + +/** Read-only: the persisted identity, or null when it is missing or malformed. Never writes. */ +export function readQuotaPoolIdentity(configDir = getConfigDir()): QuotaPoolIdentity | null { + const path = quotaPoolIdentityPath(configDir); + if (memo?.path === path) return memo.identity; + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null; + } catch { + return null; + } + let identity: QuotaPoolIdentity | null; + try { identity = parseIdentity(readFileSync(path, "utf8")); } catch { return null; } + if (identity) memo = { path, identity }; + return identity; +} + +const NOFOLLOW = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; + +/** Write a whole new owner-only file; fails if anything (a symlink included) already exists. */ +function createExclusive(path: string, content: string): void { + const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NOFOLLOW, 0o600); + try { writeSync(fd, content); } finally { closeSync(fd); } +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code; +} + +/** + * Create the identity once. Called off the request path (server startup); idempotent. + * + * Publication is exclusive and never follows a link: the secret is written to a private temp + * file and hard-linked into place, which fails if anything already sits at the path. Two servers + * starting on one fresh home therefore cannot each keep a different identity: the loser re-reads + * and adopts the winner's. A symlink or a malformed file at the path is refused and left alone + * (the read keeps reporting the identity as unavailable until an operator removes it); it is never + * overwritten. + */ +export function ensureQuotaPoolIdentity(configDir = getConfigDir()): QuotaPoolIdentity { + const existing = readQuotaPoolIdentity(configDir); + if (existing) return existing; + const path = quotaPoolIdentityPath(configDir); + let present = true; + try { lstatSync(path); } catch (error) { if (errorCode(error) !== "ENOENT") throw error; present = false; } + if (present) throw new Error("quota-pool identity file is not a valid regular file; remove it to rotate the identity"); + const identity: QuotaPoolIdentity = { + homeId: `home_${randomUUID().replaceAll("-", "")}`, + secret: randomBytes(32).toString("hex"), + }; + const content = `${JSON.stringify({ version: 1, ...identity })}\n`; + const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let published = false; + try { + createExclusive(temp, content); + try { + linkSync(temp, path); + published = true; + } catch (error) { + const code = errorCode(error); + if (code === "EEXIST") { + const winner = readQuotaPoolIdentity(configDir); + if (winner) return winner; + throw new Error("quota-pool identity was created concurrently but is not readable yet"); + } + // No hard links on this filesystem: exclusive create in place is still never a replacement. + if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "ENOSYS") throw error; + } + } finally { + try { unlinkSync(temp); } catch { /* best effort */ } + } + if (!published) { + try { + createExclusive(path, content); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + const winner = readQuotaPoolIdentity(configDir); + if (winner) return winner; + throw error; + } + } + memo = { path, identity }; + return identity; +} + +/** The opaque account reference: keyed by the install secret, never the raw id or an email. */ +export function quotaPoolAccountRef(identity: QuotaPoolIdentity, provider: string, accountId: string): string { + const digest = createHmac("sha256", Buffer.from(identity.secret, "hex")) + .update(`ocx.quota-pool/1\0${provider}\0${accountId}`) + .digest(); + return `acct_${digest.subarray(0, 16).toString("base64url")}`; +} + +export function resetQuotaPoolIdentityMemoForTests(): void { + memo = null; +} diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 8a81af070fa..8f51e2c9fcf 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -32,6 +32,7 @@ import { startMemoryWatchdog, type MemoryWatchdog, } from "./memory-watchdog"; +import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -204,6 +205,9 @@ export function acquireServerBackgroundLifecycle( if (cleanupInProgress) { throw new Error("server background lifecycle cleanup is still in progress"); } + // The quota-pool identity (homeId and the accountRef key) is minted here, per server and off + // the request path, so GET /api/quota-pool only ever reads it. + try { ensureQuotaPoolIdentity(); } catch { /* the read reports quota_pool_identity_unavailable */ } const owner: LeaseOwner = { token: Symbol("server-background-lifecycle"), diff --git a/src/server/management-api.ts b/src/server/management-api.ts index f6076920a48..5f23fccc242 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -161,6 +161,13 @@ async function handleLowQuotaRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-pool", false)) return null; + const { handleQuotaPoolRoutes } = await import("./management/quota-pool-routes"); + return handleQuotaPoolRoutes(ctx); +} + /** * Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is * mounted for every dashboard request, so a static import would put the workflow-budget ledger @@ -351,6 +358,7 @@ export async function handleManagementAPI( ?? (await handleRequestHistoryRoutes(ctx)) ?? (await handleQuotaResetRoutesOnDemand(ctx)) ?? (await handleLowQuotaRoutesOnDemand(ctx)) + ?? (await handleQuotaPoolRoutesOnDemand(ctx)) ?? (await handleWorkflowBudgetRoutesOnDemand(ctx)) ?? (await handleCodexAgentRoleRoutesOnDemand(ctx)) ?? (await handleProtocolRoutesOnDemand(ctx)) diff --git a/src/server/management/quota-pool-routes.ts b/src/server/management/quota-pool-routes.ts new file mode 100644 index 00000000000..8904a4604fb --- /dev/null +++ b/src/server/management/quota-pool-routes.ts @@ -0,0 +1,24 @@ +/** + * `GET /api/quota-pool`: the `ocx.quota-pool/1` read GOAT consumes (see src/quota-pool/). + * + * Same management authorization as the neighbouring reads; a Remote Link Child reaches it through + * the read-only link relay (`LINK_MANAGEMENT_READ_PATHS`) and a Hub client through its hub relay. + * The read is always passive: there is no refresh form, so nothing here can make the Home probe. + */ +import type { ManagementContext } from "./context"; +import { jsonResponse } from "../auth-cors"; + +export async function handleQuotaPoolRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + // Any query (including a stray refresh=1) is ignored: the read has no active form. + if (url.pathname === "/api/quota-pool" && req.method === "GET") { + const { readQuotaPool } = await import("../../quota-pool/assemble"); + const result = await readQuotaPool(config); + const response = result.ok + ? jsonResponse(result.pool, 200, req, config) + : jsonResponse({ error: result.error }, 503, req, config); + response.headers.set("cache-control", "no-store"); + return response; + } + return null; +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 168d7c7ea6c..46fcd8b658d 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -370,6 +370,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: false }, { method: "GET", path: "/api/provider-presets", module: "server/management/provider-routes", mutates: false }, { method: "GET", path: "/api/provider-quotas", module: "server/management/provider-routes", mutates: false }, + { method: "GET", path: "/api/quota-pool", module: "server/management/quota-pool-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The ocx.quota-pool/1 read is a machine contract GOAT consumes over management and the link relay; the CLI shows the same caches through ocx provider quota and ocx account.", owner: "ocx-1 quota pool", ownerDoc: "structure/quota-pool.md" } }, { method: "GET", path: "/api/provider-request-pacing", module: "server/management/provider-key-pacing-routes", mutates: false }, { method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false }, { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, diff --git a/structure/INDEX.md b/structure/INDEX.md index cf769362195..38e94aa3dfc 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -109,6 +109,7 @@ The dashboard, the management API, and third-party client config ownership. | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and startup safety. | | [`provider-completion-diagnostic.md`](provider-completion-diagnostic.md) | Explicit single-model completion test: consent, applicability gate, send-time authority fingerprint, bounded streaming and metadata comparison. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | +| [`quota-pool.md`](quota-pool.md) | The passive GET /api/quota-pool read GOAT consumes (ocx.quota-pool/1): sources, window mapping, absent-not-zero, and the install identity behind homeId and accountRef. | | [`pricing.md`](pricing.md) | User price policies, prompt-length rate selection, persistence and estimate cache invalidation. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | | [`clients/codex-desktop.md`](clients/codex-desktop.md) | Windows package activation, restart context, and protected compatibility authority persistence. | @@ -249,6 +250,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/providers/quota/anthropic-account-quota.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/providers/quota/dsh-account.ts` | [`providers/dsh-account.md`](providers/dsh-account.md) | | `src/providers/request-pacing.ts` | [`providers/anthropic-key-billing.md`](providers/anthropic-key-billing.md) | +| `src/quota-pool/` | [`quota-pool.md`](quota-pool.md) | | `src/quota/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | | `src/reasoning-effort.ts` | [`runtime.md`](runtime.md) | | `src/remote-control/` | [`remote-workspace.md`](remote-workspace.md) | @@ -266,6 +268,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | +| `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | | `src/server/responses/account-auto-pause-recovery.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/server/responses/advisor-plan-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/advisor-slot.ts` | [`advisor.md`](advisor.md) | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 47615cad546..e2daf09969d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -290,7 +290,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`, and `GET/PUT /api/auto-review-settings`. The auto-review settings route validates the global reviewer selector, persists its enabled/model fields with rollback before publication, then converges the catalog and returns its disposition; the Models panel keeps the server echo and displays a pending-sync notice whenever no commit is reported. Sidecar PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. An effective `vision.enabled` change persists before one bounded catalog-only convergence updates the managed catalog and Codex model cache; the PUT response returns `catalogRefreshPending` for incomplete work. Identical effective values and unrelated partial writes do not converge, and no result claims client reload. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-6-luna` and `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. PUT refuses a qualified target that only the router's default-provider fallback accepts. Provider disable and delete report the target they leave behind as `dependentShadowIntercept` (`shadowInterceptProviderDependency` in `src/server/management/shadow-call-validation.ts`), and at request time an unresolvable target returns `409 intercept_target_unavailable` before any send (`src/server/responses/shadow-target-availability.ts`); it never falls back to the native source model or the default provider. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | -| Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `POST /api/providers/test-completion` (explicit single-model completion diagnostic, [contract](provider-completion-diagnostic.md)), `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | +| Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `POST /api/providers/test-completion` (explicit single-model completion diagnostic, [contract](provider-completion-diagnostic.md)), `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. `GET /api/quota-pool` (`src/server/management/quota-pool-routes.ts`, loaded on demand) is the always-passive `ocx.quota-pool/1` read GOAT consumes; see [quota pool](quota-pool.md). | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`, `PUT /api/model-settings`. Visibility writes trigger catalog sync through the owning server path. `PUT /api/model-settings` edits one routed row's capability axes in place — context window, declared input modalities, reasoning ladder and its default — through the provider-level per-model maps the runtime already reads, so the row keeps its discovery provenance instead of being replaced by a custom model. Every field is optional and `null` clears the declaration back to the registry/catalog answer; display name is deliberately absent because `PUT /api/providers/{provider}/model-display-names` already owns it. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `gateway` default for new installs, `first-party` opt-in, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-gateway-and-first-party). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. It leads with Default model and Quick task model, which set the `opus` and `haiku` family defaults through `gui/src/pages/claude-desktop-roles.ts` (same move rules as the family lanes, which sit under a folded Advanced disclosure); the profile wire shape is unchanged. `gui/src/pages/ClaudeCode.tsx` is one page with a single Save bar; its Save body (`gui/src/pages/claude-code-save.ts`) omits `enabled`, which only the immediate connection switch writes. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. `gui/src/pages/claude-code-tier-defaults.tsx` edits `tierModels` through the Save bar's dirty/poll/revert contract. `GET /api/claude-code/model-defaults` previews local user defaults; `POST` accepts exactly a fingerprint bound to the full settings document and saved tiers (`src/claude/model-default-settings.ts`). Changed settings/tiers return 409 and existing empty/corrupt documents are refused. Apply stages private bytes, captures the actual shared file into `model-default-backups/apply-*/settings.json` under the Claude config directory, validates that capture, then publishes with an exclusive hard link. A competing destination is never replaced, even on rollback; there is a brief missing-file interval, and unsupported cross-volume/link operations have no overwrite fallback. Failed restoration returns `settings_recovery_required` with the private recovery path shown as an alert. Selected aliases receive paired `_MODEL_NAME` labels via `claudeTierDisplayName` in `src/claude/context-windows.ts`. Preview includes label additions/updates and uses an empty `after` string for removal. An existing label is managed only when it exactly matches the generated name for its current alias (an identical manually entered label is indistinguishable); custom, empty and non-string labels are preserved. Unset tiers retain both their settings model IDs and display labels; a matching generated label is removed only when an explicitly selected tier is replaced with a non-alias; no client restart, connection credential change or Desktop family edit occurs. Covered by `tests/claude-integration/claude-model-default-labels.test.ts`, `tests/claude-integration/claude-intercept-settings.test.ts`, `tests/server/model-settings-management-api.test.ts`, `gui/tests/claude-code-save.test.ts` and `gui/tests/claude-code-background-helper.test.tsx`. | diff --git a/structure/manifest.json b/structure/manifest.json index aa34393fbf4..a41c7c38ee5 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -714,6 +714,16 @@ "src/usage/" ] }, + { + "path": "quota-pool.md", + "tier": 5, + "title": "Quota Pool Producer", + "scope": "The passive GET /api/quota-pool read GOAT consumes (ocx.quota-pool/1): sources, window mapping, absent-not-zero, and the install identity behind homeId and accountRef.", + "documents": [ + "src/quota-pool/", + "src/server/management/quota-pool-routes.ts" + ] + }, { "path": "pricing.md", "tier": 5, diff --git a/structure/quota-pool.md b/structure/quota-pool.md new file mode 100644 index 00000000000..3f39547e1f1 --- /dev/null +++ b/structure/quota-pool.md @@ -0,0 +1,79 @@ +# Quota Pool Producer + +`GET /api/quota-pool` serves the GOAT contract `ocx.quota-pool/1`: one provider-neutral view of +every account quota this Home already holds. GOAT owns the decoder +(`packages/contracts/src/quotaPool.ts` in goat-si/goat); OpenCodex is the producer, and +`tests/helpers/quota-pool-schema.ts` mirrors the contract strictly (strict objects, no `null`). + +## Surfaces + +- Home: `GET /api/quota-pool` on the management API, under the same management authorization as + the neighbouring reads (`src/server/management/quota-pool-routes.ts`, loaded on demand by + `src/server/management-api.ts`). Only GET is routed. Any query, including `refresh=1`, is ignored: + the read has no active form. +- Remote Link Child: `/api/quota-pool` is in `LINK_MANAGEMENT_READ_PATHS`, so the Child's dashboard + reaches it at `/api/machine/hub-relay/api/quota-pool`; the Home serves it through + `/v1/link-management` with the link key. The shared refresh-key rule still refuses a link read + that carries `refresh`, `reload`, `force` or `quota`. +- Hub client: the hub relay forwards `/api/machine/hub-relay/api/*` to the hub's management API + with the caller's management credential, so the same path reaches the hub's read. + +## Passive by construction + +`readQuotaPool` (`src/quota-pool/assemble.ts`) runs the whole assembly inside `runPassiveRead`, for +every caller, and reads only caches other paths filled: + +- Codex pool accounts: the passive `listCodexAuthAccountsSnapshot` (stored `StoredAccountQuota` per + account and the cached main-account reading), with no WHAM probe, token refresh or native-main + claim. Each account's window `source` is `header` when its newest quota-history sample came from + response headers and `measured` otherwise (`latestCodexQuotaObservationSource`, a read that never + prunes or clears history). +- Provider reports: `heldProviderQuotaReports`, the reports the Home last published. They become + the provider's `pooled` windows; an aggregated Codex pool report is `estimated`, an in-band + observed report is `header`, any other report is `measured`. +- Per-account OAuth quota (Anthropic instances, Kiro, Antigravity, xAI, Cursor, Kimi, Command Code, + Devin, DSH, Meta): the last-good per-account cache. A current `QuotaFailureCode` becomes + `failure`. +- Claude CLI: `peekClaudeUsageObservation` in `src/adapters/claude-cli/usage-admission.ts`, which + reads the admission snapshot from memory or its file without resolving the sign-in. A parsed CLI + limit refusal is a `custom` window `refusal` with `exhausted: true` and source `estimated`. + +`tests/server/quota-pool-relay.test.ts` reads the route on the Home, over the link relay and over +the hub relay against a pool account with an expired token, and fails on any fetch, process spawn or +file write inside the read and on any change to the Home's files. + +## Window mapping + +Kinds come from the fields the existing parsers stored (`parseUpstreamQuotaHeaders`, +`parseUsageQuota`): the Codex short window is `burst_5h` up to 6 hours (`daily` up to 36 hours, +`custom` beyond) and carries `windowSeconds` only when upstream stated it; provider `fiveHour*` is +`burst_5h`; weekly and monthly map by name; Kiro plan credits and USD credits are `credits` windows +with `unit`. A window an upstream proved to be model-scoped (`scope: "model"`) carries +`scopeModel` and never makes the whole account `exhausted`; an exact-model diagnostic row without +that proof is dropped, because unscoped it would gate every model. `rejected` evidence becomes +`exhausted: true`. + +Absent is unknown: a window with no reading is omitted, never reported as 0%, and a window whose +reset has passed is omitted too. Optional fields are left out instead of set to `null`. Account +`state` is `reauth`, then `paused`, then `exhausted` (any unscoped exhausted window), then +`unknown` (no windows), else `available`. `pooled.nextResetAt` is the earliest unscoped reset among +the pooled windows. + +## Identity + +`src/quota-pool/identity.ts` keeps `/quota-pool-identity.json` (owner-only): +`homeId` (`home_` + 32 hex, the Home's stable instance id, not a hostname) and a 256-bit secret. +The server creates it in `acquireServerBackgroundLifecycle` before it serves; the read only reads it +and answers 503 `quota_pool_identity_unavailable` instead of creating it. Creation is exclusive and +never follows a link: the secret is written to a private temp file and hard-linked into place (an +in-place exclusive create where hard links are unavailable), so two servers starting on one fresh +home adopt the same identity, and a symlink or malformed file at the path is refused and left +untouched rather than overwritten. + +`accountRef` is `acct_` + base64url of the first 128 bits of HMAC-SHA256(secret, +`ocx.quota-pool/1` NUL provider NUL accountId). It is stable across restarts, distinct per provider, +and never contains an email, a raw account id or a token; without the secret a recipient cannot +confirm a guessed identity against it. Deleting the file rotates `homeId` and every reference +together. `label` is the account's existing log label (`main`, `p`, `o`), never an +email or alias. `producer.role` is `runtimeRole` (`standalone` when unset) and `producer.version` +is the package version. `driver` and `pooled.headroom` are not produced yet. diff --git a/structure/remote-link.md b/structure/remote-link.md index ed0509bee6e..2c823a52ba5 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -112,7 +112,7 @@ Client-side status supplies initiation identity from `src/client/link-status.ts` A link Child relays every model request to its Home, so everything the Home's proxy applies per request (effort caps, the sub-agent roster and fallback, the injection prompt, sidecar and compaction routing, the catalog) already follows the Home. Two things did not: the Codex settings the injector writes into the Child's own `config.toml`, and every management read. Both now cross the link, and nothing else does. -`src/link/management-routes.ts` is the shared, default-deny table. A Home management path is readable over the link only when it is listed in `LINK_MANAGEMENT_READ_PATHS` (the reads the dashboard polls except the model list, the header quota strip, and the account, usage, catalog and preset reads the CLI makes; combos and routing profiles stay out until their projections are reviewed for decision-endpoint configuration), the method is GET or HEAD, and the query carries no `refresh`, `reload`, `force` or `quota` key, which would make the Home refresh credentials or probe upstream quota. `/api/models`, `/api/model-presets`, `/api/subagent-models` and `/api/subagent-model-fallback` are absent because they run live provider discovery and can persist an initial model selection; the Child's Codex picker already shows the Home's catalog, and the preset preview only decides a write a Child cannot make. `/api/injection-model` stays readable because the Child's dashboard and `ocx effort` show the Home's delegation model and effort from it, Every link read is a passive read (`runPassiveRead` in `src/lib/passive-read.ts`, entered by `src/server/index/link-management.ts`): it shows what the Home already knows and does not make the Home act on providers, accounts, processes or files. Inside it `fetchAllModels` and the management model rows return no routed models, so `/api/injection-model` and `/api/sidecar-settings` list native options plus the persisted picks; `fetchProviderQuotaReports` returns the reports the Home holds, without a probe, token refresh, account snapshot or publication into routing evidence; the Codex account list uses the cached main-account info and pool quota, without a token refresh, WHAM probe or native-main claim; the startup-health diagnostic, the Codex runtime version probes and the bundled-catalog refresh (which runs the Codex binary and persists the runtime) are not started, and a native-main fence raised for unknown service ownership is reported as it stands instead of re-inspected (a successful re-inspection activates the native-main lifecycle and its recovery); `hardenConfigDir`, `hardenExistingSecret` and `backupInvalidConfig` do nothing, so reading a credential store neither tightens its permissions nor backs up a malformed copy; and memory-budget enforcement triggered by a read (the usage caches publish and enforce) demotes only stores registered as `passiveEvictable` in `src/lib/app-owned-memory-stores.ts`, whose eviction only releases memory (today the two usage caches), because demoting other stores can spill a continuation to disk or rewrite a replay snapshot; the rest of the budget is enforced by the Home's own next enforcement. The Home's own dashboard and traffic keep those caches current. `tests/server/link-management-relay.test.ts` reads every allowlisted path, GET and HEAD, against a Home with a pending provider, a quota-probed provider, a canonical OpenAI provider, a pool account with an expired token, a malformed OAuth store and a native-main ownership fence. Inside a passive read it records calls to `fetch`; `child_process` `execFile`, `execFileSync`, `spawn`, `spawnSync`, `exec` and `execSync`; `Bun.spawn`, `Bun.spawnSync` and `Bun.write`; the `node:fs` writers `writeFileSync`, `appendFileSync`, `copyFileSync`, `renameSync`, `chmodSync`, `mkdirSync`, `rmSync`, `unlinkSync`, `writeSync`, `ftruncateSync`, `truncateSync`, `linkSync`, `symlinkSync`, `utimesSync` and `openSync` for writing; and the matching `node:fs/promises` writers, and asserts there are none. Writes through other APIs (callback `fs`, file handles, Bun file writers, SQLite) are caught only as file changes: the test compares the size, mtime and mode of every file under the Home's config and Codex homes before and after the reads, along with the config file bytes and the live config, and checks that the ownership fence was not re-probed. A separate case reads `/api/usage` against a ledger row, a persisted Antigravity replay session, an undeclared cache and a continuation store far over the memory budget, waits past the replay snapshot debounce, and checks that enforcement ran without demoting any of them, while the Home's own enforcement demotes all three. The same reads made by the Home then back up the store, probe the quota and the account, re-probe ownership and decide the pending provider, which proves the fixture is live. Work a read schedules for later is still attributed to the read when it runs; the test waits past the responses snapshot debounce (2 s) and checks again after the Home reads, but it cannot observe work delayed beyond that. `/api/logs` and `/api/diagnostics/project-config` are deliberately absent: the first carries request detail, the second describes files on the Home. The Home's hub-link listener admits `/v1/link-management/` and `/v1/link-management/codex-settings` (`linkManagementRouteAllowed`) in addition to the data-plane table; the Child's own data-plane ingress keeps the data-plane table, so a local caller on the Child cannot reach these reads without the Child's dashboard session. +`src/link/management-routes.ts` is the shared, default-deny table. A Home management path is readable over the link only when it is listed in `LINK_MANAGEMENT_READ_PATHS` (the reads the dashboard polls except the model list, the header quota strip, the passive `/api/quota-pool` read GOAT consumes ([quota pool](quota-pool.md)), and the account, usage, catalog and preset reads the CLI makes; combos and routing profiles stay out until their projections are reviewed for decision-endpoint configuration), the method is GET or HEAD, and the query carries no `refresh`, `reload`, `force` or `quota` key, which would make the Home refresh credentials or probe upstream quota. `/api/models`, `/api/model-presets`, `/api/subagent-models` and `/api/subagent-model-fallback` are absent because they run live provider discovery and can persist an initial model selection; the Child's Codex picker already shows the Home's catalog, and the preset preview only decides a write a Child cannot make. `/api/injection-model` stays readable because the Child's dashboard and `ocx effort` show the Home's delegation model and effort from it, Every link read is a passive read (`runPassiveRead` in `src/lib/passive-read.ts`, entered by `src/server/index/link-management.ts`): it shows what the Home already knows and does not make the Home act on providers, accounts, processes or files. Inside it `fetchAllModels` and the management model rows return no routed models, so `/api/injection-model` and `/api/sidecar-settings` list native options plus the persisted picks; `fetchProviderQuotaReports` returns the reports the Home holds, without a probe, token refresh, account snapshot or publication into routing evidence; the Codex account list uses the cached main-account info and pool quota, without a token refresh, WHAM probe or native-main claim; the startup-health diagnostic, the Codex runtime version probes and the bundled-catalog refresh (which runs the Codex binary and persists the runtime) are not started, and a native-main fence raised for unknown service ownership is reported as it stands instead of re-inspected (a successful re-inspection activates the native-main lifecycle and its recovery); `hardenConfigDir`, `hardenExistingSecret` and `backupInvalidConfig` do nothing, so reading a credential store neither tightens its permissions nor backs up a malformed copy; and memory-budget enforcement triggered by a read (the usage caches publish and enforce) demotes only stores registered as `passiveEvictable` in `src/lib/app-owned-memory-stores.ts`, whose eviction only releases memory (today the two usage caches), because demoting other stores can spill a continuation to disk or rewrite a replay snapshot; the rest of the budget is enforced by the Home's own next enforcement. The Home's own dashboard and traffic keep those caches current. `tests/server/link-management-relay.test.ts` reads every allowlisted path, GET and HEAD, against a Home with a pending provider, a quota-probed provider, a canonical OpenAI provider, a pool account with an expired token, a malformed OAuth store and a native-main ownership fence. Inside a passive read it records calls to `fetch`; `child_process` `execFile`, `execFileSync`, `spawn`, `spawnSync`, `exec` and `execSync`; `Bun.spawn`, `Bun.spawnSync` and `Bun.write`; the `node:fs` writers `writeFileSync`, `appendFileSync`, `copyFileSync`, `renameSync`, `chmodSync`, `mkdirSync`, `rmSync`, `unlinkSync`, `writeSync`, `ftruncateSync`, `truncateSync`, `linkSync`, `symlinkSync`, `utimesSync` and `openSync` for writing; and the matching `node:fs/promises` writers, and asserts there are none. Writes through other APIs (callback `fs`, file handles, Bun file writers, SQLite) are caught only as file changes: the test compares the size, mtime and mode of every file under the Home's config and Codex homes before and after the reads, along with the config file bytes and the live config, and checks that the ownership fence was not re-probed. A separate case reads `/api/usage` against a ledger row, a persisted Antigravity replay session, an undeclared cache and a continuation store far over the memory budget, waits past the replay snapshot debounce, and checks that enforcement ran without demoting any of them, while the Home's own enforcement demotes all three. The same reads made by the Home then back up the store, probe the quota and the account, re-probe ownership and decide the pending provider, which proves the fixture is live. Work a read schedules for later is still attributed to the read when it runs; the test waits past the responses snapshot debounce (2 s) and checks again after the Home reads, but it cannot observe work delayed beyond that. `/api/logs` and `/api/diagnostics/project-config` are deliberately absent: the first carries request detail, the second describes files on the Home. The Home's hub-link listener admits `/v1/link-management/` and `/v1/link-management/codex-settings` (`linkManagementRouteAllowed`) in addition to the data-plane table; the Child's own data-plane ingress keeps the data-plane table, so a local caller on the Child cannot reach these reads without the Child's dashboard session. On the Home, `src/server/index/link-management.ts` runs after the link-key admission in `src/server/index/serve-options.ts`. It builds a fresh loopback request with no caller header, cookie, Origin or credential and no principal, so no consent- or capability-gated branch is reachable, calls the management handler, and returns only content headers with `Cache-Control: no-store`. The relayed projections are the dashboard's own: provider and key-pool rows report presence and masked identities, never a credential value. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 005f2051f32..eec307a924a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -201,7 +201,7 @@ "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-import-project-fallback.test.ts": "server", "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", - "link-management-relay.test.ts": "server", + "link-management-relay.test.ts": "server", "quota-pool-relay.test.ts": "server", "quota-pool.test.ts": "usage", "link-listener-lifecycle.test.ts": "server", "link-listener-mutation.test.ts": "server", "link-join-route.test.ts": "server", "core-link-boundary.test.ts": "lab", "acl-error-classification.test.ts": "lib", diff --git a/tests/helpers/quota-pool-schema.ts b/tests/helpers/quota-pool-schema.ts new file mode 100644 index 00000000000..6390154d47c --- /dev/null +++ b/tests/helpers/quota-pool-schema.ts @@ -0,0 +1,68 @@ +/** + * A local mirror of GOAT's `ocx.quota-pool/1` decoder (goat-si/goat + * packages/contracts/src/quotaPool.ts, branch feat/quota-pool-contract, PR #690). + * + * Stricter than the consumer on purpose: objects are strict, so an unexpected key or a `null` + * where the contract says "optional" fails here even though GOAT's forward-compatible arrays + * would silently drop the element. + */ +import { z } from "zod"; + +const nonBlank = z.string().refine(value => value.trim().length > 0 && value === value.trim(), "trimmed non-empty"); +const isoDateTime = z.string().refine(value => Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value, "ISO date-time"); + +export const QuotaWindowSchema = z.strictObject({ + id: nonBlank, + kind: z.enum(["burst_5h", "daily", "weekly", "monthly", "credits", "custom"]), + label: nonBlank, + usedPercent: z.number().min(0).max(100).optional(), + used: z.number().optional(), + limit: z.number().optional(), + unit: z.enum(["requests", "tokens", "credits", "usd"]).optional(), + resetsAt: isoDateTime.optional(), + windowSeconds: z.number().min(0).optional(), + scopeModel: nonBlank.optional(), + source: z.enum(["measured", "header", "estimated"]), + observedAt: isoDateTime, + exhausted: z.boolean().optional(), +}); + +export const QuotaAccountSchema = z.strictObject({ + provider: nonBlank, + driver: nonBlank.optional(), + accountRef: z.string().min(1), + label: nonBlank.optional(), + plan: nonBlank.optional(), + state: z.enum(["available", "exhausted", "paused", "reauth", "unknown"]), + windows: z.array(QuotaWindowSchema), + failure: nonBlank.optional(), +}); + +export const QuotaPoolSchema = z.strictObject({ + schema: z.literal("ocx.quota-pool/1"), + producer: z.strictObject({ + version: nonBlank, + role: z.enum(["standalone", "hub", "client"]), + homeId: nonBlank, + }), + generatedAt: isoDateTime, + providers: z.array(z.strictObject({ + provider: nonBlank, + accounts: z.array(QuotaAccountSchema), + pooled: z.strictObject({ + windows: z.array(QuotaWindowSchema), + headroom: z.number().optional(), + nextResetAt: isoDateTime.optional(), + }), + })), +}); + +/** Every `null` anywhere in a decoded body: the contract omits unknown fields instead. */ +export function nullPaths(value: unknown, path = "$"): string[] { + if (value === null) return [path]; + if (Array.isArray(value)) return value.flatMap((item, index) => nullPaths(item, `${path}[${index}]`)); + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, item]) => nullPaths(item, `${path}.${key}`)); + } + return []; +} diff --git a/tests/server/quota-pool-relay.test.ts b/tests/server/quota-pool-relay.test.ts new file mode 100644 index 00000000000..c3a3cd82174 --- /dev/null +++ b/tests/server/quota-pool-relay.test.ts @@ -0,0 +1,248 @@ +/** + * `GET /api/quota-pool` over every path GOAT reads it (goat-si/goat #684 §3.3): + * the Home's management API, a Remote Link Child's relay (`/v1/link-management` on the Home's + * link listener) and a Hub client's relay (`/api/machine/hub-relay/api/quota-pool`). + * + * Passive-read guard in the style of link-management-relay.test.ts: inside the read, any fetch, + * process spawn or file write is a finding, the Home's durable state must not change, and a pool + * account with an expired token must not be refreshed (a refresh would have to fetch). + */ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import * as fsPromises from "node:fs/promises"; +import { mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { isPassiveRead } from "../../src/lib/passive-read"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { applyAccountQuotaFromUpstreamHeaders, clearAccountQuota } from "../../src/codex/quota"; +import { linkStorePath } from "../../src/link/paths"; +import { writeLinkStore } from "../../src/link/store"; +import { linkManagementReadAllowed } from "../../src/link/management-routes"; +import { relayHubManagementRequest } from "../../src/client/hub-relay"; +import { readQuotaPoolIdentity } from "../../src/quota-pool/identity"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { nullPaths, QuotaPoolSchema } from "../helpers/quota-pool-schema"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const LINKED_KEY = "quota-pool-linked-admission"; +const ADMIN_KEY = "quota-pool-admin-admission"; +const POOL_EMAIL = "qp-pool-expired@example.test"; +const previous = { + home: process.env.OPENCODEX_HOME, + apiToken: process.env.OPENCODEX_API_AUTH_TOKEN, + adminToken: process.env.OPENCODEX_ADMIN_AUTH_TOKEN, +}; +let testHome = ""; +let codexHome: IsolatedCodexHome | null = null; +let server: { stop(closeActiveConnections?: boolean): Promise; url: URL } | null = null; +let effects: string[] = []; +let restore: Array<() => void> = []; + +/** Record every fetch, spawn and file write made inside a passive read; answer off-machine fetches locally. */ +function watchEffects(): void { + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (isPassiveRead()) effects.push(`fetch ${url}`); + if (/^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])[:/]/.test(url)) return originalFetch(input, init); + return Promise.resolve(Response.json({ error: "fixture upstream" }, { status: 503 })); + }) as typeof fetch; + restore.push(() => { globalThis.fetch = originalFetch; }); + const wrap = (target: object, names: string[], label: string, mutates: (args: unknown[]) => boolean = () => true) => { + for (const name of names) { + const original = (target as Record unknown>)[name]!; + const spy = spyOn(target as Record unknown>, name).mockImplementation(function (this: unknown, ...args: unknown[]) { + if (isPassiveRead() && mutates(args)) effects.push(`${label}.${name} ${String(args[0])}`); + return original.apply(this, args); + }); + restore.push(() => spy.mockRestore()); + } + }; + wrap(childProcess, ["execFile", "execFileSync", "spawn", "spawnSync", "exec", "execSync"], "child_process"); + wrap(Bun, ["spawn", "spawnSync", "write"], "Bun"); + wrap(fs, ["writeFileSync", "appendFileSync", "copyFileSync", "renameSync", "chmodSync", "mkdirSync", "rmSync", "unlinkSync", + "writeSync", "ftruncateSync", "truncateSync", "linkSync", "symlinkSync", "utimesSync"], "fs"); + wrap(fsPromises, ["writeFile", "appendFile", "copyFile", "rename", "chmod", "mkdir", "rm", "unlink", "truncate"], "fs/promises"); + const forWrite = (args: unknown[]) => { + const flags = args[1]; + if (flags === undefined) return false; + if (typeof flags === "number") { + const { O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND } = fs.constants; + return (flags & (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC | O_APPEND)) !== 0; + } + return !["r", "rs", "sr"].includes(String(flags)); + }; + wrap(fs, ["openSync"], "fs", forWrite); + wrap(fsPromises, ["open"], "fs/promises", forWrite); +} + +function durableState(): Record { + const state: Record = {}; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { walk(path); continue; } + try { const stat = fs.statSync(path); state[path] = `${stat.size}:${stat.mtimeMs}:${stat.mode}`; } catch { state[path] = "vanished"; } + } + }; + walk(testHome); + if (codexHome) walk(codexHome.path); + return state; +} + +function config(): OcxConfig { + return { + port: 0, + hostname: "0.0.0.0", + runtimeRole: "hub", + defaultProvider: "mock", + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:9/v1", allowPrivateNetwork: true, apiKey: "fixture-key", models: ["m"] }, + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + // Expired token: reading its quota actively would refresh it first. + codexAccounts: [{ id: "qp-pool-expired", email: POOL_EMAIL, plan: "plus", isMain: false }], + apiKeys: [{ id: "linked-key", name: "linked-key", key: LINKED_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], + clientIntegrations: { codex: false }, + webSearchSidecar: { enabled: false }, + } as OcxConfig; +} + +async function linkBase(): Promise { + const port = JSON.parse(await Bun.file(linkStorePath()).text()).listenerPort as number; + return `http://127.0.0.1:${port}`; +} + +beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), "ocx-quota-pool-relay-")); + process.env.OPENCODEX_HOME = testHome; + process.env.OPENCODEX_API_AUTH_TOKEN = "quota-pool-env-admission"; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = ADMIN_KEY; + effects = []; + watchEffects(); + codexHome = installIsolatedCodexHome("ocx-quota-pool-codex-"); + writeFileSync(join(codexHome.path, "opencodex-catalog.json"), JSON.stringify({ models: [{ slug: "mock/m" }] })); + saveCodexAccountCredential("qp-pool-expired", { + accessToken: "access-qp-pool-expired", refreshToken: "refresh-qp-pool-expired", + expiresAt: Date.now() - 60_000, chatgptAccountId: "acct-qp-pool-expired", + }); + writeLinkStore(linkStorePath(), { + version: 1, + listenerPort: null, + links: [{ + id: "lnk_00000000000000aa", alias: "quota-pool-test", direction: "client-initiated", + hostKeyFingerprint: "SHA256:abcdefghijklmnop", tunnelPort: 2222, apiKeyId: "linked-key", createdAt: "2026-10-21T00:00:00.000Z", + }], + }); + const { saveConfig } = await import("../../src/config"); + saveConfig(config()); + const { startServer } = await import("../../src/server"); + server = startServer(0); + const reset = Math.floor(Date.now() / 1000) + 3600; + applyAccountQuotaFromUpstreamHeaders("qp-pool-expired", new Headers({ + "x-codex-primary-used-percent": "64", "x-codex-primary-window-minutes": "300", "x-codex-primary-reset-at": String(reset), + "x-codex-secondary-used-percent": "18", "x-codex-secondary-reset-at": String(reset + 4 * 86400), + })); + // The seeding above persists the quota cache on a 250 ms debounce; let it land before any + // test takes its durable-state baseline, so only the read is measured. + await Bun.sleep(600); +}); + +/** Refused by the management auth layer before the handler runs. */ +async function expectRefused(response: Response) { + expect(response.status).toBe(401); + expect(await response.text()).not.toContain("ocx.quota-pool"); +} + +afterEach(async () => { + if (server) await server.stop(true); + server = null; + // The seeded reading lives in process memory; leaving it would make a later file's pool account + // look freshly measured. + clearAccountQuota(); + await Bun.sleep(300); // let the clear's debounced persist land before the home is removed + for (const undo of restore.splice(0).reverse()) undo(); + codexHome?.restore(); + codexHome = null; + for (const [key, value] of [["OPENCODEX_HOME", previous.home], ["OPENCODEX_API_AUTH_TOKEN", previous.apiToken], + ["OPENCODEX_ADMIN_AUTH_TOKEN", previous.adminToken]] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (testHome) removeTreeWithRetry(testHome); + testHome = ""; +}); + +async function expectPool(response: Response, role = "hub") { + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const text = await response.text(); + for (const secret of [POOL_EMAIL, "qp-pool-expired", "acct-qp-pool-expired", "access-qp-pool-expired", LINKED_KEY, ADMIN_KEY]) { + expect(text).not.toContain(secret); + } + const body = JSON.parse(text); + expect(nullPaths(body)).toEqual([]); + const pool = QuotaPoolSchema.parse(body); + expect(pool.producer).toMatchObject({ role, homeId: readQuotaPoolIdentity()!.homeId }); + const openai = pool.providers.find(provider => provider.provider === "openai"); + // The passive read checks no credential, so no Codex row may be reported as needing reauth. + expect(openai?.accounts.map(row => row.state).filter(state => state === "reauth")).toEqual([]); + const account = openai?.accounts.find(row => row.windows.length > 0); + expect(account?.windows.map(window => [window.kind, window.usedPercent])).toEqual([["burst_5h", 64], ["weekly", 18]]); + return pool; +} + +describe("GET /api/quota-pool", () => { + test("the server mints the identity at startup, so the read only reads it", () => { + expect(readQuotaPoolIdentity()?.homeId).toMatch(/^home_[0-9a-f]{32}$/); + }); + + test("Home management read: authorized like its neighbours, passive, and refresh is ignored", async () => { + await expectRefused(await fetch(`${server!.url.origin}/api/quota-pool`)); + const before = durableState(); + effects = []; + for (const query of ["", "?refresh=1"]) { + await expectPool(await fetch(`${server!.url.origin}/api/quota-pool${query}`, { headers: { "x-opencodex-api-key": ADMIN_KEY } })); + } + await Bun.sleep(250); + expect(effects).toEqual([]); + expect(durableState()).toEqual(before); + }); + + test("Remote Link Child relay: link key only, read-only, passive", async () => { + expect(linkManagementReadAllowed("GET", "/api/quota-pool")).toBe(true); + expect(linkManagementReadAllowed("POST", "/api/quota-pool")).toBe(false); + const base = await linkBase(); + expect((await fetch(`${base}/v1/link-management/api/quota-pool`)).status).toBe(401); + const before = durableState(); + effects = []; + const first = await expectPool(await fetch(`${base}/v1/link-management/api/quota-pool`, { headers: { authorization: `Bearer ${LINKED_KEY}` } })); + const second = await expectPool(await fetch(`${base}/v1/link-management/api/quota-pool`, { headers: { authorization: `Bearer ${LINKED_KEY}` } })); + expect(second.providers.flatMap(p => p.accounts.map(a => a.accountRef))).toEqual(first.providers.flatMap(p => p.accounts.map(a => a.accountRef))); + for (const method of ["POST", "PUT", "DELETE"]) { + const refused = await fetch(`${base}/v1/link-management/api/quota-pool`, { method, headers: { authorization: `Bearer ${LINKED_KEY}` }, body: "{}" }); + expect({ method, status: refused.status }).toEqual({ method, status: 404 }); + } + await Bun.sleep(250); + expect(effects).toEqual([]); + expect(durableState()).toEqual(before); + }); + + test("Hub client relay: /api/machine/hub-relay/api/quota-pool reaches the hub's read", async () => { + const relay = (headers: Record) => relayHubManagementRequest( + new Request("http://127.0.0.1:1/api/machine/hub-relay/api/quota-pool?refresh=1", { headers }), + "/api/quota-pool?refresh=1", + { managementUrl: server!.url.origin, browserOrigin: "http://127.0.0.1:1" }, + ); + await expectRefused(await relay({})); + const before = durableState(); + effects = []; + await expectPool(await relay({ "x-opencodex-api-key": ADMIN_KEY })); + await Bun.sleep(250); + expect(effects).toEqual([]); + expect(durableState()).toEqual(before); + }); +}); diff --git a/tests/usage/quota-pool.test.ts b/tests/usage/quota-pool.test.ts new file mode 100644 index 00000000000..7d1f4333691 --- /dev/null +++ b/tests/usage/quota-pool.test.ts @@ -0,0 +1,245 @@ +/** + * Producer side of GOAT's `ocx.quota-pool/1` (`GET /api/quota-pool`): the assembled shape against + * a local mirror of the contract, absent-not-zero, the window-kind mapping through the existing + * Codex header parser, and the opaque, stable `accountRef`. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { lstatSync, mkdtempSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { codexQuotaWindows, providerQuotaWindows, readQuotaPool } from "../../src/quota-pool/assemble"; +import { ensureQuotaPoolIdentity, quotaPoolIdentityPath, readQuotaPoolIdentity, resetQuotaPoolIdentityMemoForTests } from "../../src/quota-pool/identity"; +import { parseUpstreamQuotaHeaders } from "../../src/codex/quota"; +import { getAccountSet, saveCredential } from "../../src/oauth/store"; +import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../../src/providers/quota"; +import { setProviderQuotaReportCache } from "../../src/providers/quota/report-cache"; +import { nullPaths, QuotaPoolSchema } from "../helpers/quota-pool-schema"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const NOW = Date.parse("2026-10-21T12:00:00.000Z"); +const HOUR = 3_600_000; +const ACCOUNT_EMAIL = "pool-person@example.test"; +let home = ""; +let priorHome: string | undefined; +let admissionPath = ""; +let xaiIds: string[] = []; + +function config(): OcxConfig { + return { + providers: { + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }, + "claude-cli": { adapter: "claude-cli", baseUrl: "http://127.0.0.1:9" }, + openrouter: { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", apiKey: "fixture-openrouter-key" }, + quiet: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:9/v1" }, + }, + } as OcxConfig; +} + +beforeEach(async () => { + priorHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-quota-pool-")); + process.env.OPENCODEX_HOME = home; + resetQuotaPoolIdentityMemoForTests(); + for (const slot of ["a", "b"]) { + await saveCredential("xai", { + access: `synthetic-access-${slot}`, refresh: `synthetic-refresh-${slot}`, expires: Date.now() + HOUR, + accountId: `xai-raw-account-${slot}`, email: ACCOUNT_EMAIL, + } as never); + } + xaiIds = getAccountSet("xai")!.accounts.map(row => row.id); + // Account a: a weekly reading only. Account b: nothing observed yet. + setCachedProviderAccountQuotaForTests("xai", xaiIds[0]!, { weeklyPercent: 42, weeklyResetAt: NOW + 48 * HOUR, updatedAt: NOW - 60_000 }); + admissionPath = join(home, "claude-usage-admission.json"); + writeFileSync(admissionPath, JSON.stringify({ + identity: "c".repeat(64), checkedAt: NOW - 30_000, + quota: { + fiveHourPercent: 100, fiveHourResetAt: NOW + HOUR, weeklyPercent: 61, weeklyResetAt: NOW + 72 * HOUR, updatedAt: NOW - 30_000, + customWindows: [{ label: "Opus", percent: 100, resetAt: NOW + 72 * HOUR, scope: "model" }], + }, + })); + setProviderQuotaReportCache({ key: "fixture", ts: NOW, response: { generatedAt: NOW, reports: [{ + provider: "openrouter", label: "OpenRouter", source: "fixture", updatedAt: NOW - 120_000, + quota: { creditsUsd: { used: 7.5, limit: 10, remaining: 2.5, percent: 75 }, updatedAt: NOW - 120_000 }, + }] } }); +}); + +afterEach(() => { + setProviderQuotaReportCache(null); + clearAccountQuotaCache("xai"); + resetQuotaPoolIdentityMemoForTests(); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + removeTreeWithRetry(home); +}); + +const read = () => readQuotaPool(config(), { now: () => NOW, claudeUsageStatePath: admissionPath }); + +describe("ocx.quota-pool/1 producer", () => { + test("reports the identity as unavailable instead of minting one on the read path", async () => { + expect(await read()).toEqual({ ok: false, error: "quota_pool_identity_unavailable" }); + expect(() => readFileSync(quotaPoolIdentityPath())).toThrow(); + }); + + test("assembles the contract shape from held caches", async () => { + const identity = ensureQuotaPoolIdentity(); + const result = await read(); + if (!result.ok) throw new Error(result.error); + const pool = result.pool; + expect(QuotaPoolSchema.parse(pool)).toEqual(pool); + expect(nullPaths(pool)).toEqual([]); + expect(pool.producer).toEqual({ version: expect.any(String), role: "standalone", homeId: identity.homeId }); + expect(identity.homeId).toMatch(/^home_[0-9a-f]{32}$/); + + // Shape snapshot with the opaque references and clocks normalized. + const shape = JSON.parse(JSON.stringify(pool, (key, value) => key === "accountRef" ? "" : value)); + expect(shape).toEqual({ + schema: "ocx.quota-pool/1", + producer: { version: expect.any(String), role: "standalone", homeId: identity.homeId }, + generatedAt: "2026-10-21T12:00:00.000Z", + providers: [ + { + provider: "claude-cli", + accounts: [{ + provider: "claude-cli", accountRef: "", state: "exhausted", + windows: [ + { id: "5h", kind: "burst_5h", label: "5-hour", usedPercent: 100, resetsAt: "2026-10-21T13:00:00.000Z", + source: "measured", observedAt: "2026-10-21T11:59:30.000Z", exhausted: true }, + { id: "weekly", kind: "weekly", label: "Weekly", usedPercent: 61, resetsAt: "2026-10-24T12:00:00.000Z", + source: "measured", observedAt: "2026-10-21T11:59:30.000Z", exhausted: false }, + { id: "family:opus", kind: "custom", label: "Opus", usedPercent: 100, scopeModel: "Opus", + resetsAt: "2026-10-24T12:00:00.000Z", source: "measured", observedAt: "2026-10-21T11:59:30.000Z", exhausted: true }, + ], + }], + pooled: { windows: [] }, + }, + { + provider: "openrouter", + accounts: [], + pooled: { windows: [{ + id: "credits:usd", kind: "credits", label: "USD credits", unit: "usd", used: 7.5, limit: 10, usedPercent: 75, + source: "measured", observedAt: "2026-10-21T11:58:00.000Z", exhausted: false, + }] }, + }, + { + provider: "xai", + accounts: [ + { provider: "xai", accountRef: "", label: expect.stringMatching(/^o[0-9a-f]{6}$/), state: "available", + windows: [{ id: "weekly", kind: "weekly", label: "Weekly", usedPercent: 42, resetsAt: "2026-10-23T12:00:00.000Z", + source: "measured", observedAt: "2026-10-21T11:59:00.000Z", exhausted: false }] }, + // Nothing observed: unknown with no windows, never a 0% bar. + { provider: "xai", accountRef: "", label: expect.stringMatching(/^o[0-9a-f]{6}$/), state: "unknown", windows: [] }, + ], + pooled: { windows: [] }, + }, + ], + }); + }); + + test("accountRef is opaque, stable across restarts and distinct per provider and account", async () => { + ensureQuotaPoolIdentity(); + const first = await read(); + resetQuotaPoolIdentityMemoForTests(); // a restart re-reads the persisted secret + const second = await read(); + if (!first.ok || !second.ok) throw new Error("identity unavailable"); + const refs = (pool: typeof first.pool) => pool.providers.flatMap(provider => provider.accounts.map(account => account.accountRef)); + expect(refs(second.pool)).toEqual(refs(first.pool)); + const all = refs(first.pool); + expect(new Set(all).size).toBe(all.length); + for (const ref of all) expect(ref).toMatch(/^acct_[A-Za-z0-9_-]{22}$/); + const body = JSON.stringify(first.pool); + for (const secret of [ACCOUNT_EMAIL, "xai-raw-account-a", "synthetic-access-a", "c".repeat(64), ...xaiIds]) { + expect(body).not.toContain(secret); + } + // Removing the file rotates the install secret, and with it every reference. + unlinkSync(quotaPoolIdentityPath()); + resetQuotaPoolIdentityMemoForTests(); + ensureQuotaPoolIdentity(); + const rotated = await read(); + if (!rotated.ok) throw new Error(rotated.error); + expect(refs(rotated.pool).some(ref => all.includes(ref))).toBe(false); + }); +}); + +describe("quota-pool identity creation", () => { + test("a symlink or malformed file at the path is refused and left untouched", () => { + const decoy = join(home, "decoy.txt"); + writeFileSync(decoy, "unrelated"); + symlinkSync(decoy, quotaPoolIdentityPath()); + expect(() => ensureQuotaPoolIdentity()).toThrow(); + expect(readFileSync(decoy, "utf8")).toBe("unrelated"); + expect(lstatSync(quotaPoolIdentityPath()).isSymbolicLink()).toBe(true); + unlinkSync(quotaPoolIdentityPath()); + writeFileSync(quotaPoolIdentityPath(), "{}"); + expect(() => ensureQuotaPoolIdentity()).toThrow(); + expect(readFileSync(quotaPoolIdentityPath(), "utf8")).toBe("{}"); + expect(readQuotaPoolIdentity()).toBeNull(); + }); + + test("servers racing on a fresh home agree on one identity", async () => { + const script = `const { ensureQuotaPoolIdentity } = await import(${JSON.stringify(join(import.meta.dir, "../../src/quota-pool/identity.ts"))}); +await Bun.sleep(Math.max(0, Number(process.env.QP_START_AT) - Date.now())); +console.log(ensureQuotaPoolIdentity().homeId);`; + for (let round = 0; round < 3; round++) { + const dir = mkdtempSync(join(home, "race-")); + const startAt = String(Date.now() + 400); + const runs = Array.from({ length: 4 }, () => Bun.spawn([process.execPath, "-e", script], { + env: { ...process.env, OPENCODEX_HOME: dir, QP_START_AT: startAt }, stdout: "pipe", stderr: "pipe", + })); + const ids = await Promise.all(runs.map(async run => (await new Response(run.stdout).text()).trim())); + resetQuotaPoolIdentityMemoForTests(); + const persisted = readQuotaPoolIdentity(dir)!.homeId; + expect(ids).toEqual([persisted, persisted, persisted, persisted]); + } + }); +}); + +describe("window mapping", () => { + test("a declared short primary maps to burst_5h and the secondary to weekly, via the header parser", () => { + const parsed = parseUpstreamQuotaHeaders(new Headers({ + "x-codex-primary-used-percent": "37", "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": String(Math.floor((NOW + HOUR) / 1000)), + "x-codex-secondary-used-percent": "12", "x-codex-secondary-reset-at": String(Math.floor((NOW + 96 * HOUR) / 1000)), + }))!; + const windows = codexQuotaWindows({ ...parsed, updatedAt: NOW }, "header", NOW); + expect(windows.map(window => [window.id, window.kind, window.usedPercent, window.windowSeconds, window.source])).toEqual([ + ["5h", "burst_5h", 37, 18_000, "header"], + ["weekly", "weekly", 12, undefined, "header"], + ]); + expect(windows[0]!.resetsAt).toBe("2026-10-21T13:00:00.000Z"); + }); + + test("a weekly-only reading yields no burst window at all", () => { + const parsed = parseUpstreamQuotaHeaders(new Headers({ "x-codex-primary-used-percent": "5" }))!; + const windows = codexQuotaWindows({ ...parsed, updatedAt: NOW }, "measured", NOW); + expect(windows.map(window => window.kind)).toEqual(["weekly"]); + }); + + test("absent, negative and elapsed readings are omitted, never zero-filled", () => { + expect(providerQuotaWindows({ updatedAt: NOW }, "measured", NOW)).toEqual([]); + expect(codexQuotaWindows({ updatedAt: NOW }, "measured", NOW)).toEqual([]); + expect(providerQuotaWindows({ weeklyPercent: -1, updatedAt: NOW }, "measured", NOW)).toEqual([]); + // The reset passed, so the reading no longer describes the current window. + expect(providerQuotaWindows({ fiveHourPercent: 90, fiveHourResetAt: NOW - 1, updatedAt: NOW - HOUR }, "measured", NOW)).toEqual([]); + const kiro = providerQuotaWindows({ kiroCreditsUsed: 10, updatedAt: NOW }, "measured", NOW); + expect(kiro).toEqual([{ id: "credits:kiro", kind: "credits", label: "Kiro credits", unit: "credits", used: 10, + source: "measured", observedAt: "2026-10-21T12:00:00.000Z" }]); + }); + + test("model scope and refusal verdicts are preserved; unscoped exact-model rows are dropped", () => { + const windows = providerQuotaWindows({ + updatedAt: NOW, + customWindows: [ + { label: "Gemini Pro", percent: 20 }, + { label: "gemini-4-argon", percent: 20, modelId: "gemini-4-argon" }, + { label: "Sonnet", percent: 3, scope: "model", passiveObservedAt: NOW - 5_000 }, + { label: "Burst", percent: 40, rejected: true }, + ], + }, "measured", NOW); + expect(windows.map(window => [window.id, window.scopeModel, window.exhausted, window.source])).toEqual([ + ["custom:Gemini Pro", undefined, false, "measured"], + ["family:sonnet", "Sonnet", false, "header"], + ["custom:Burst", undefined, true, "measured"], + ]); + }); +}); From 2945ebb364b90ad2e0f3fe71e69a375045aec39d Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 22:16:53 +0900 Subject: [PATCH 09/25] feat(readyz): advertise peer features from one registry (#7477) GET /readyz now fills the existing optional `features` field so a Hub, Child or GOAT decides compatibility by feature instead of parsing versions. src/remote/features.ts is the single registry and each entry reads its owning source: - quota-pool/1 when the management route registry declares GET /api/quota-pool (off until OCX-1 declares the route) - link-management-relay when the link management read table is non-empty - served-by-header from the emitter descriptor in src/remote/served-by.ts (off until OCX-2 sends x-opencodex-served-by) - relay-auth/, currently relay-auth/2 The ids are fixed strings, so the unauthenticated body gains no account or configuration data. Existing /readyz fields are unchanged. --- .../_plan/261021_readyz_features/000_plan.md | 50 +++++++ .../docs/fr/reference/cli/lifecycle.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 10 +- .../docs/ru/reference/cli/lifecycle.md | 2 +- .../docs/tr/reference/cli/lifecycle.md | 2 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- scripts/test-layout/layout.json | 2 +- src/remote/features.ts | 64 +++++++++ src/remote/protocol.ts | 2 + src/remote/served-by.ts | 14 ++ structure/remote-link.md | 1 + structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/server/proxy-liveness.test.ts | 4 +- tests/server/readyz-features.test.ts | 124 ++++++++++++++++++ tests/server/server-live.test.ts | 2 +- 19 files changed, 277 insertions(+), 14 deletions(-) create mode 100644 devlog/_plan/261021_readyz_features/000_plan.md create mode 100644 src/remote/features.ts create mode 100644 src/remote/served-by.ts create mode 100644 tests/server/readyz-features.test.ts diff --git a/devlog/_plan/261021_readyz_features/000_plan.md b/devlog/_plan/261021_readyz_features/000_plan.md new file mode 100644 index 00000000000..745de5e35aa --- /dev/null +++ b/devlog/_plan/261021_readyz_features/000_plan.md @@ -0,0 +1,50 @@ +# OCX-3: `/readyz` advertises peer features + +Producer side of the GOAT hub design (goat-si/goat#684, ADR-0021, `261021_goatclaw_hub/000_design.md` §5 and +brief OCX-3). Peers decide compatibility from `features` instead of parsing versions. + +## Current state + +- `GET /readyz` (`src/server/index/serve-options.ts`) spreads `readyProtocolMetadata(config, req)` from + `src/remote/protocol.ts` into its body. `RemoteReadyMetadata.features?: string[]` already exists and + `parseRemoteReadyMetadata` validates it (at most 64 unique non-empty strings, each at most 80 chars, no control + characters), but no producer fills it. +- Relay auth version: `LINK_RELAY_AUTH_VERSION = "2"` in `src/link/relay-auth.ts`. +- Management routes are declared in `MANAGEMENT_ROUTES` (`src/server/management/route-registry.ts`), pure data + reconciled against dispatch source by `tests/server/management-route-registry.test.ts`. +- The Remote Link management relay read table is `LINK_MANAGEMENT_READ_PATHS` (`src/link/management-routes.ts`). +- No `x-opencodex-served-by` header is emitted yet (OCX-2). + +## Change + +1. New `src/remote/features.ts`: one ordered registry `PEER_FEATURES`, each entry an id plus a predicate over a + `PeerFeatureSources` snapshot read from the owning modules. `servedPeerFeatures(sources?)` returns the ids + whose predicate holds, in registry order. + - `quota-pool/1`: `MANAGEMENT_ROUTES` declares `GET /api/quota-pool` (OCX-1 turns it on by declaring the route, + which the registry reconciliation test already forces). + - `link-management-relay`: `LINK_MANAGEMENT_READ_PATHS` is non-empty. + - `served-by-header`: `SERVED_BY_HEADER_EMITTED`, the emitter's descriptor in a new + `src/remote/served-by.ts` (`false` until OCX-2, which flips it in the change that emits the header). + A live test on an isolated server keeps it honest: `x-opencodex-served-by` is present on the + `/readyz`, `/healthz` and unknown-`/v1/*` responses exactly when `/readyz` advertises the feature. + - `relay-auth/` (currently `relay-auth/2`): always. +2. `readyProtocolMetadata` adds `features: servedPeerFeatures()` (computed once; the sources are build constants). +3. Tests in a new `tests/server/readyz-features.test.ts` (registered in layout + expected fixture): registry + presence/absence follows each source, ids are unique and survive `parseRemoteReadyMetadata`, the live + `/readyz` body carries exactly the registry output and nothing account-shaped. Update the exact-shape + assertions line-neutrally in `tests/server/proxy-liveness.test.ts` (two `readyProtocolMetadata` `toEqual`s) + and `tests/server/server-live.test.ts:1442` (sorted key allowlist gains `features`; allowlist kept). +4. Docs: `structure/runtime.md` (owns `src/remote/`), `structure/remote-link.md` pointer for relay-auth, + `docs-site` lifecycle `ocx ready` field list. + +## Out of scope / open question + +The Child machine listener (`src/client/machine-listener.ts`) answers its own `/readyz` with a different fixed +shape (`protocolVersion: 1`, no `features`). Whether a Child should advertise features, and which (it serves no +`/api/quota-pool` of its own), is a product decision reported back, not chosen here. + +## Verification + +typecheck; focused files (new test, proxy-liveness, management-route-registry, core-link-boundary, +core-lab-boundary, client-machine-listener, server-live); `bun run test:changed`; `bun run structure:check`; +`bun run privacy:scan`; full `bun run test` under `/usr/bin/lockf -t 3600 /tmp/ocx-heavy-test.lock`. 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 918e27f2e30..17508627872 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -141,7 +141,7 @@ Vérifie l’identité du proxy actif. La sortie destinée aux utilisateurs indi ### `ocx ready [--json] [--wait [--timeout ]]` -Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`. `protocol` est la version courante du protocole distant du hub, `minimumClientProtocol` la plus ancienne version cliente compatible et `managementUrl` l’origine canonique de gestion visible par le navigateur. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation. +Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`. `protocol` est la version courante du protocole distant du hub, `minimumClientProtocol` la plus ancienne version cliente compatible et `managementUrl` l’origine canonique de gestion visible par le navigateur. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation. Par défaut, la commande effectue une seule sonde. Avec `--wait`, elle interroge le service jusqu’à ce qu’il soit prêt ou jusqu’à l’expiration du délai, mais s’arrête immédiatement si elle observe l’état terminal `failed`. Le délai par défaut est de 45 secondes. `--timeout ` exige `--wait` et accepte un entier positif compris entre 1 et 300. La sortie JSON de la CLI est `{ready, status, pid, port}`, où `status` vaut `ready`, `pending`, `failed` ou `unreachable`. Les codes de sortie sont 0 si le service est prêt ; 1 s’il n’est pas prêt, reste en attente, échoue, dépasse le délai ou est inaccessible ; et 64 si les arguments sont invalides. 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 df2d6de16f6..923d03a0637 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -137,7 +137,7 @@ ocx status --json 認証不要の `GET /readyz` エンドポイントで同期後の準備状態を確認します。準備完了時は `200`、 `pending` または終端状態の `failed` では `Retry-After: 1` とともに `503` を返します。HTTP の -サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}` です。`protocol` は hub の現在の remote protocol、`minimumClientProtocol` は互換性のある最小 client protocol、`managementUrl` は browser から見える canonical management origin です。`/readyz` がない +サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}` です。`protocol` は hub の現在の remote protocol、`minimumClientProtocol` は互換性のある最小 client protocol、`managementUrl` は browser から見える canonical management origin です。`/readyz` がない 旧プロキシは `unreachable` として fail-closed し、`/healthz` は readiness ではなく別の liveness 確認です。 デフォルトでは 1 回だけ probe します。`--wait` は準備完了または timeout まで polling しますが、 終端 `failed` を確認すると即座に終了します。デフォルト timeout は 45 秒で、`--timeout ` には 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 5c353e5bd80..7ed0a4aaca6 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -209,7 +209,7 @@ ocx status --json 인증이 필요 없는 `GET /readyz` 엔드포인트로 동기화 후 준비 상태를 확인합니다. 준비되면 `200`, `pending` 또는 종단 상태인 `failed`이면 `Retry-After: 1`과 함께 `503`을 반환합니다. HTTP의 정제된 -식별 필드는 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`입니다. `protocol`은 허브의 현재 원격 프로토콜, `minimumClientProtocol`은 호환되는 최소 클라이언트 프로토콜, `managementUrl`은 브라우저에서 보이는 표준 관리 origin입니다. `/readyz`가 없는 이전 프록시는 +식별 필드는 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`입니다. `protocol`은 허브의 현재 원격 프로토콜, `minimumClientProtocol`은 호환되는 최소 클라이언트 프로토콜, `managementUrl`은 브라우저에서 보이는 표준 관리 origin입니다. `/readyz`가 없는 이전 프록시는 `unreachable`로 fail-closed하며, `/healthz`는 준비 상태가 아닌 별도의 liveness 확인입니다. 기본값은 한 번의 probe이며, `--wait`는 준비 또는 timeout까지 polling하지만 종단 `failed`를 확인하면 즉시 종료합니다. 기본 timeout은 45초이며, `--timeout `는 `--wait`와 함께 써야 하고 양의 정수인 1~300초 범위를 받습니다. CLI JSON은 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 9a1d434ad74..c231e7a52f2 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -394,12 +394,18 @@ not traffic missed between polls; injection seq cannot exclude restart/eviction Check post-sync readiness through the unauthenticated `GET /readyz` endpoint. It returns `200` when ready, or `503` with `Retry-After: 1` for `pending` and terminal `failed`. Its sanitized HTTP identity is `{service, version, uptime, pid, port, status}` plus the remote-hub protocol fields -`{protocol, minimumClientProtocol, managementUrl}`. `protocol` is the hub protocol this proxy +`{protocol, minimumClientProtocol, managementUrl, features}`. `protocol` is the hub protocol this proxy speaks and `minimumClientProtocol` the oldest client it still accepts, so a client can refuse an incompatible pairing before sending anything else. `managementUrl` is the origin a client should use for the management plane: the configured `hub.managementPublicOrigin` when `runtimeRole` is `hub`, and otherwise the origin the request itself arrived on. A readiness request with no -HTTP(S) origin is rejected rather than answered with a guess. Old proxies without `/readyz` fail +HTTP(S) origin is rejected rather than answered with a guess. +`features` lists the peer features this build serves, so a peer can check a capability without +comparing versions: `relay-auth/` names the Remote Link relay authentication version, +`link-management-relay` the read-only Remote Link management relay, `quota-pool/1` the +`GET /api/quota-pool` management read, and `served-by-header` the `x-opencodex-served-by` response +header. A feature this build does not serve is absent. The list holds fixed names only, never +account or configuration data. Old proxies without `/readyz` fail closed as `unreachable`; `/healthz` is separate liveness, not readiness. The command performs one probe by default; `--wait` polls until ready or timeout, but exits immediately when it observes the terminal `failed` state. The default timeout is 45 seconds; `--timeout ` requires `--wait` and accepts positive integer seconds from 1–300. 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 74f5088282c..54b903e9c38 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -206,7 +206,7 @@ Identity-check живого прокси. Текстовый вывод сооб Проверяет готовность после синхронизации через не требующий аутентификации `GET /readyz`. При готовности возвращается `200`; для `pending` и терминального `failed` возвращается `503` с -`Retry-After: 1`. Санитизированные поля HTTP-ответа: `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`. `protocol` — текущая версия удалённого протокола hub, `minimumClientProtocol` — минимальная совместимая версия клиента, а `managementUrl` — канонический origin управления для браузера. +`Retry-After: 1`. Санитизированные поля HTTP-ответа: `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`. `protocol` — текущая версия удалённого протокола hub, `minimumClientProtocol` — минимальная совместимая версия клиента, а `managementUrl` — канонический origin управления для браузера. Старые прокси без `/readyz` fail-closed как `unreachable`; `/healthz` — отдельная проверка liveness, а не готовности. По умолчанию команда выполняет одну пробу. `--wait` опрашивает до готовности или тайм-аута, но при терминальном `failed` завершается немедленно. Тайм-аут по умолчанию — 45 секунд; 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 69f053509e4..5f3a6a3c68d 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -206,7 +206,7 @@ takdirde 1 ile çıkar, bu da onu servis probları için uygun hale getirir. Kimliği doğrulanmamış `GET /readyz` uç noktası aracılığıyla senkronizasyon sonrası hazırlığı kontrol edin. Hazır olduğunda `200` veya `pending` ve terminal `failed` için `Retry-After: 1` ile `503` döndürür. Temizlenmiş HTTP kimliği -`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}` şeklindedir. `protocol` hub'ın güncel uzak protokolünü, `minimumClientProtocol` uyumlu en düşük istemci protokolünü ve `managementUrl` tarayıcıya görünen kanonik yönetim origin'ini belirtir. `/readyz` içermeyen +`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}` şeklindedir. `protocol` hub'ın güncel uzak protokolünü, `minimumClientProtocol` uyumlu en düşük istemci protokolünü ve `managementUrl` tarayıcıya görünen kanonik yönetim origin'ini belirtir. `/readyz` içermeyen eski proxy'ler `unreachable` olarak kapalı başarısız olur; `/healthz` hazırlık değil, ayrı bir canlılıktır. Komut varsayılan olarak bir prob gerçekleştirir; `--wait`, hazır olana veya zaman aşımına kadar yoklar, ancak terminal `failed` 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 62b44ac873d..c9df64c93bc 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 @@ -136,7 +136,7 @@ ocx status --json 通过无需认证的 `GET /readyz` 端点检查同步后的就绪状态。就绪时返回 `200`;状态为 `pending` 或 终态 `failed` 时返回 `503`,并带有 `Retry-After: 1`。HTTP 仅返回经脱敏的身份字段 -`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`。`protocol` 是 Hub 当前的远程协议版本,`minimumClientProtocol` 是兼容的最低客户端协议版本,`managementUrl` 是浏览器可见的规范管理 origin。不支持 `/readyz` 的旧代理会按 `unreachable` 失败关闭; +`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`。`protocol` 是 Hub 当前的远程协议版本,`minimumClientProtocol` 是兼容的最低客户端协议版本,`managementUrl` 是浏览器可见的规范管理 origin。不支持 `/readyz` 的旧代理会按 `unreachable` 失败关闭; `/healthz` 是独立的存活检查,不是就绪检查。默认只探测一次;`--wait` 会轮询到就绪或超时,但遇到终态 `failed` 会立即退出。默认超时为 45 秒;`--timeout ` 必须与 `--wait` 一起使用,取值范围为 1–300 秒的正整数。CLI JSON 输出 `{ready, status, pid, port}`,其中 `status` 为 `ready`、`pending`、`failed` 或 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 d65ca711fb1..c8ed06eb607 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 @@ -134,7 +134,7 @@ ocx status --json ### `ocx ready [--json] [--wait [--timeout ]]` -透過免認證的 `GET /readyz` 端點檢查同步後的就緒狀態。就緒時回傳 `200`,或 `pending` 與終端 `failed` 時回傳附帶 `Retry-After: 1` 的 `503`。其淨化的 HTTP 身分為 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`。`protocol` 是 Hub 目前的遠端協定版本,`minimumClientProtocol` 是相容的最低用戶端協定版本,`managementUrl` 是瀏覽器可見的標準管理 origin。沒有 `/readyz` 的舊代理會以 `unreachable` 方式 fail closed;`/healthz` 是分開的存活檢查,而非就緒檢查。此指令預設執行一次探測;`--wait` 輪詢直到就緒或逾時,但在觀察到終端 `failed` 狀態時立即退出。預設逾時為 45 秒;`--timeout ` 需要 `--wait`,接受 1–300 的正整數秒。CLI JSON 輸出 `{ready, status, pid, port}`,其中 `status` 為 `ready`、`pending`、`failed` 或 `unreachable`。離開碼為:就緒 0;未就緒、pending、failed、逾時或 unreachable 1;無效引數 64。 +透過免認證的 `GET /readyz` 端點檢查同步後的就緒狀態。就緒時回傳 `200`,或 `pending` 與終端 `failed` 時回傳附帶 `Retry-After: 1` 的 `503`。其淨化的 HTTP 身分為 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`。`protocol` 是 Hub 目前的遠端協定版本,`minimumClientProtocol` 是相容的最低用戶端協定版本,`managementUrl` 是瀏覽器可見的標準管理 origin。沒有 `/readyz` 的舊代理會以 `unreachable` 方式 fail closed;`/healthz` 是分開的存活檢查,而非就緒檢查。此指令預設執行一次探測;`--wait` 輪詢直到就緒或逾時,但在觀察到終端 `failed` 狀態時立即退出。預設逾時為 45 秒;`--timeout ` 需要 `--wait`,接受 1–300 的正整數秒。CLI JSON 輸出 `{ready, status, pid, port}`,其中 `status` 為 `ready`、`pending`、`failed` 或 `unreachable`。離開碼為:就緒 0;未就緒、pending、failed、逾時或 unreachable 1;無效引數 64。 ### `ocx doctor` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c0b8337735b..48c487f770f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1453,7 +1453,7 @@ "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", - "proxy-liveness.test.ts": "server", + "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", diff --git a/src/remote/features.ts b/src/remote/features.ts new file mode 100644 index 00000000000..ca7120154d2 --- /dev/null +++ b/src/remote/features.ts @@ -0,0 +1,64 @@ +/** + * The one registry of peer-visible features this build serves, advertised in `GET /readyz` + * `features` so a Hub, a Child or GOAT decides compatibility by feature instead of parsing versions. + * + * Every entry reads the module that owns the capability; nothing here restates a fact by hand: + * + * - `quota-pool/1` when the management route registry declares `GET /api/quota-pool`. The registry + * is reconciled against dispatch source (`tests/server/management-route-registry.test.ts`), so the + * feature appears exactly when the route is reachable. + * - `link-management-relay` when the Remote Link management read table is non-empty. + * - `served-by-header` when the served-by emitter says responses carry the header. + * - `relay-auth/` with the connection-bound link relay auth version. + * + * The ids are static strings. They carry no account, credential or configuration data, which is + * what keeps the unauthenticated `/readyz` body safe. `parseRemoteReadyMetadata` accepts at most + * 64 unique ids of at most 80 characters, and `tests/server/readyz-features.test.ts` holds the + * full registry to that. + */ +import { LINK_MANAGEMENT_READ_PATHS } from "../link/management-routes"; +import { LINK_RELAY_AUTH_VERSION } from "../link/relay-auth"; +import { MANAGEMENT_ROUTES, type ManagementRoute } from "../server/management/route-registry"; +import { SERVED_BY_HEADER_EMITTED } from "./served-by"; + +export const QUOTA_POOL_PATH = "/api/quota-pool"; + +/** The owning sources the registry reads. Tests pass their own to drive presence and absence. */ +export interface PeerFeatureSources { + readonly managementRoutes: readonly Pick[]; + readonly linkManagementReadPaths: readonly string[]; + readonly servedByHeaderEmitted: boolean; + readonly relayAuthVersion: string; +} + +/** One feature: its id when the sources say it is served, otherwise null. */ +export type PeerFeature = (sources: PeerFeatureSources) => string | null; + +/** Ordered; `/readyz` lists the served ids in this order. */ +export const PEER_FEATURES: readonly PeerFeature[] = [ + s => s.managementRoutes.some(route => route.method === "GET" && route.path === QUOTA_POOL_PATH) ? "quota-pool/1" : null, + s => s.linkManagementReadPaths.length > 0 ? "link-management-relay" : null, + s => s.servedByHeaderEmitted ? "served-by-header" : null, + s => `relay-auth/${s.relayAuthVersion}`, +]; + +export function currentPeerFeatureSources(): PeerFeatureSources { + return { + managementRoutes: MANAGEMENT_ROUTES, + linkManagementReadPaths: LINK_MANAGEMENT_READ_PATHS, + servedByHeaderEmitted: SERVED_BY_HEADER_EMITTED, + relayAuthVersion: LINK_RELAY_AUTH_VERSION, + }; +} + +export function servedPeerFeatures(sources: PeerFeatureSources = currentPeerFeatureSources()): string[] { + return PEER_FEATURES.map(feature => feature(sources)).filter((id): id is string => id !== null); +} + +let buildFeatures: readonly string[] | undefined; + +/** This build's served features. The sources are build constants, so the list is computed once. */ +export function buildPeerFeatures(): string[] { + buildFeatures ??= Object.freeze(servedPeerFeatures()); + return [...buildFeatures]; +} diff --git a/src/remote/protocol.ts b/src/remote/protocol.ts index dc4cf359af2..fe76f9bff21 100644 --- a/src/remote/protocol.ts +++ b/src/remote/protocol.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types/config"; +import { buildPeerFeatures } from "./features"; export const REMOTE_HUB_PROTOCOL = 1; export const MINIMUM_REMOTE_CLIENT_PROTOCOL = 1; @@ -53,6 +54,7 @@ export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteRe protocol: REMOTE_HUB_PROTOCOL, minimumClientProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL, managementUrl, + features: buildPeerFeatures(), }; } diff --git a/src/remote/served-by.ts b/src/remote/served-by.ts new file mode 100644 index 00000000000..5f30a3ae1b8 --- /dev/null +++ b/src/remote/served-by.ts @@ -0,0 +1,14 @@ +/** + * The served-by response header and whether this build sends it. + * + * Relayed and Hub-served responses are meant to name the Home that served them in + * `x-opencodex-served-by`, so a peer can prove which Home answered without reading logs. This module + * is the emitter's descriptor: `SERVED_BY_HEADER_EMITTED` is true only in a build whose responses + * carry the header, and the change that starts sending it flips the flag in the same commit. + * `/readyz` advertises `served-by-header` from this flag (`src/remote/features.ts`), and + * `tests/server/readyz-features.test.ts` fails if the advertisement and live responses disagree. + */ +export const SERVED_BY_HEADER = "x-opencodex-served-by"; + +/** Whether responses from this build carry {@link SERVED_BY_HEADER}. */ +export const SERVED_BY_HEADER_EMITTED: boolean = false; diff --git a/structure/remote-link.md b/structure/remote-link.md index 2c823a52ba5..fe388d83b96 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -79,6 +79,7 @@ Enrollment cancellation in `src/client/connect.ts` reaches actual network reques ## Connection-bound relay authentication The production relay's complete-send boundary is `src/client/link-relay-transport.ts`. A dedicated HTTP agent opens exactly one TCP socket. Before any admission header or body is sent, a credential-free, five-second challenge proves the stored link/key identity using direction-separated HMACs from `src/link/relay-auth.ts`. The data request uses that same authenticated socket, with a one-use session nonce; a closed socket cannot be silently replaced. A peer lacking this protocol is refused with an upgrade/re-link error, never retried through an unauthenticated send. Home-initiated reverse tunnels use the same check. `LinkRelayDeps.fetchImpl` is a trusted complete-send test seam, not a user setting; production supplies no substitute. Bun's Node HTTP compatibility is confined to this leaf so streamed request caps, backpressure, credential replacement, decompression, response status, caller cancellation and SSE limits remain owned by the existing relay. +A Home advertises `LINK_RELAY_AUTH_VERSION` as `relay-auth/`, and the management relay as `link-management-relay`, in its `/readyz` `features` ([Runtime](runtime.md), `src/remote/features.ts`), so a peer can detect a relay-auth mismatch without comparing release versions. `src/server/index/link-relay-sessions.ts` owns a per-listener reservation book. A valid caller proof reserves its peer socket for five seconds (at most 256 outstanding reservations); the data request must consume that nonce on the same peer address/port while its link and credential remain valid. Current and unexpired pending-rotation fingerprints use the same expiry rule as ordinary API-key admission, and the server signs with the fingerprint that matched. The nonce is not an alternative data-plane credential: dispatch still applies its ordinary key and route policy. Removal, rotation expiry, abort and commit are observed anew at dispatch. Replays and a different socket are refused. diff --git a/structure/runtime.md b/structure/runtime.md index aedb847931a..1824e83b4ff 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -347,7 +347,7 @@ config snapshot and the bounded service-token observation needed for its `_remot it does not import the connect command, inspect catalog readiness, acquire lifecycle locks, or run config/secret ACL hardening. -`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md). For Remote Link, `src/cli/status.ts` asks the identity-checked local Child for `/v1/hub-state` without any credential header; the Child's connection-bound relay owns Home authentication. Hub Homes retain their bounded parsed projection and owner-bound cache. `src/client/hub-client.ts` distinguishes a bounded nested `hub_state_not_a_hub` 404 from legacy route absence; `src/client/hub-state.ts` reports role absence without presenting obsolete cached Hub state. `src/client/link-status.ts` reads matching sidecar/marker evidence for initiation identity; ambiguous data does not claim a Home-owned tunnel. These contracts are covered in `tests/cli/cli-status-hub-state.test.ts` and `tests/clients/link-status-projection.test.ts`. +`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/features.ts` is the one registry of peer features `GET /readyz` lists in `features`, and each entry reads its owning source: `quota-pool/1` when `src/server/management/route-registry.ts` declares `GET /api/quota-pool`, `link-management-relay` when `src/link/management-routes.ts` has read paths, `served-by-header` when the emitter descriptor `src/remote/served-by.ts` says responses carry `x-opencodex-served-by`, and `relay-auth/` from `src/link/relay-auth.ts`. The ids are fixed strings with no account or configuration data; `tests/server/readyz-features.test.ts` checks presence and absence per source, the peer parser, and the served-by advertisement against live responses. The Child machine listener answers its own `/readyz` without `features`. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md). For Remote Link, `src/cli/status.ts` asks the identity-checked local Child for `/v1/hub-state` without any credential header; the Child's connection-bound relay owns Home authentication. Hub Homes retain their bounded parsed projection and owner-bound cache. `src/client/hub-client.ts` distinguishes a bounded nested `hub_state_not_a_hub` 404 from legacy route absence; `src/client/hub-state.ts` reports role absence without presenting obsolete cached Hub state. `src/client/link-status.ts` reads matching sidecar/marker evidence for initiation identity; ambiguous data does not claim a Home-owned tunnel. These contracts are covered in `tests/cli/cli-status-hub-state.test.ts` and `tests/clients/link-status-projection.test.ts`. ### Remote Hub status credential binding diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index eec307a924a..74344230178 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1022,7 +1022,7 @@ "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", "provider-workspace-state.test.ts": "gui", "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", - "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", + "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", "quota-reset-account-key.test.ts": "usage", diff --git a/tests/server/proxy-liveness.test.ts b/tests/server/proxy-liveness.test.ts index 80353747532..5e6f9ae453f 100644 --- a/tests/server/proxy-liveness.test.ts +++ b/tests/server/proxy-liveness.test.ts @@ -25,6 +25,7 @@ import { readyProtocolMetadata, } from "../../src/remote/protocol"; import { getDefaultConfig } from "../../src/config"; +import { buildPeerFeatures } from "../../src/remote/features"; function healthz(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status }); @@ -886,7 +887,7 @@ describe("remote readiness protocol metadata", () => { expect(readyProtocolMetadata( { ...getDefaultConfig(), runtimeRole }, new Request("https://hub.example.test/readyz"), - )).toEqual(metadata); + )).toEqual({ ...metadata, features: buildPeerFeatures() }); } }); @@ -902,6 +903,7 @@ describe("remote readiness protocol metadata", () => { protocol: 1, minimumClientProtocol: 1, managementUrl: "http://hub.example.test:8443", + features: buildPeerFeatures(), }); }); diff --git a/tests/server/readyz-features.test.ts b/tests/server/readyz-features.test.ts new file mode 100644 index 00000000000..4c0cfa6b5d4 --- /dev/null +++ b/tests/server/readyz-features.test.ts @@ -0,0 +1,124 @@ +/** + * OCX-3: `GET /readyz` advertises peer features from one registry (`src/remote/features.ts`). + * Presence and absence follow each owning source, the ids survive the peer-side parser, and the + * unauthenticated body carries static ids only. The served-by case checks the advertisement + * against real responses, so the emitter's flag cannot claim a header that is not sent. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig, saveConfig } from "../../src/config"; +import { LINK_MANAGEMENT_READ_PATHS } from "../../src/link/management-routes"; +import { LINK_RELAY_AUTH_VERSION } from "../../src/link/relay-auth"; +import { + buildPeerFeatures, + currentPeerFeatureSources, + PEER_FEATURES, + QUOTA_POOL_PATH, + servedPeerFeatures, + type PeerFeatureSources, +} from "../../src/remote/features"; +import { parseRemoteReadyMetadata, readyProtocolMetadata } from "../../src/remote/protocol"; +import { SERVED_BY_HEADER, SERVED_BY_HEADER_EMITTED } from "../../src/remote/served-by"; +import { startServer } from "../../src/server"; +import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; +import { createReadinessGate } from "../../src/server/readiness"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const none: PeerFeatureSources = { + managementRoutes: [], + linkManagementReadPaths: [], + servedByHeaderEmitted: false, + relayAuthVersion: "2", +}; +const all: PeerFeatureSources = { + managementRoutes: [{ method: "GET", path: QUOTA_POOL_PATH }], + linkManagementReadPaths: ["/api/settings"], + servedByHeaderEmitted: true, + relayAuthVersion: "2", +}; + +describe("peer feature registry", () => { + test("presence and absence follow each owning source", () => { + expect(servedPeerFeatures(none)).toEqual(["relay-auth/2"]); + expect(servedPeerFeatures(all)).toEqual(["quota-pool/1", "link-management-relay", "served-by-header", "relay-auth/2"]); + expect(servedPeerFeatures({ ...none, managementRoutes: [{ method: "POST", path: QUOTA_POOL_PATH }] })).toEqual(["relay-auth/2"]); + expect(servedPeerFeatures({ ...none, managementRoutes: [{ method: "GET", path: "/api/quota-pool/extra" }] })).toEqual(["relay-auth/2"]); + expect(servedPeerFeatures({ ...none, relayAuthVersion: "3" })).toEqual(["relay-auth/3"]); + }); + + test("this build reads the live sources", () => { + const features = buildPeerFeatures(); + expect(features).toEqual(servedPeerFeatures(currentPeerFeatureSources())); + expect(features.includes("quota-pool/1")) + .toBe(MANAGEMENT_ROUTES.some(route => route.method === "GET" && route.path === QUOTA_POOL_PATH)); + expect(features.includes("link-management-relay")).toBe(LINK_MANAGEMENT_READ_PATHS.length > 0); + expect(features.includes("served-by-header")).toBe(SERVED_BY_HEADER_EMITTED); + expect(features).toContain(`relay-auth/${LINK_RELAY_AUTH_VERSION}`); + }); + + test("every id fits the peer-side parser", () => { + const features = servedPeerFeatures(all); + expect(features).toHaveLength(PEER_FEATURES.length); + const metadata = { protocol: 1, minimumClientProtocol: 1, managementUrl: "https://hub.example.test" }; + expect(parseRemoteReadyMetadata({ ...metadata, features })?.features).toEqual(features); + const live = readyProtocolMetadata(getDefaultConfig(), new Request("http://127.0.0.1/readyz")); + expect(parseRemoteReadyMetadata(live)?.features).toEqual(buildPeerFeatures()); + }); +}); + +describe("GET /readyz features on a live server", () => { + const previousHome = process.env.OPENCODEX_HOME; + const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; + let home = ""; + let codexHome: IsolatedCodexHome | null = null; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-readyz-features-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + codexHome = installIsolatedCodexHome("ocx-readyz-features-codex-"); + saveConfig(getDefaultConfig()); + }); + + afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + codexHome?.restore(); + codexHome = null; + removeTreeWithRetry(home); + }); + + test("the unauthenticated body lists the registry and nothing account-shaped", async () => { + const server = startServer(0, { readinessGate: createReadinessGate() }); + try { + const response = await fetch(new URL("/readyz", server.url)); + expect(response.status).toBe(503); + const body = (await response.json()) as Record; + expect(body.features).toEqual(buildPeerFeatures()); + expect(JSON.stringify(body.features)).not.toMatch(/account|email|token|secret|acct|@/i); + } finally { + await server.stop(true); + } + }); + + test("served-by-header is advertised exactly when responses carry the header", async () => { + const server = startServer(0, { readinessGate: createReadinessGate() }); + try { + const readyz = await fetch(new URL("/readyz", server.url)); + const advertised = ((await readyz.json()) as { features: string[] }).features.includes("served-by-header"); + const responses = [ + readyz, + await fetch(new URL("/healthz", server.url)), + await fetch(new URL("/v1/opencodex-readyz-features-probe", server.url)), + ]; + for (const response of responses) expect(response.headers.has(SERVED_BY_HEADER)).toBe(advertised); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index 0e08f0a5b80..28c68c04a1b 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -1444,7 +1444,7 @@ describe("GET /readyz", () => { expectReadyProtocolMetadata(readyzBody, new URL(base).origin); // Sanitization: the body must never carry sync diagnostics, paths, or warnings. expect(Object.keys(readyzBody).sort()).toEqual([ - "managementUrl", "minimumClientProtocol", "pid", "port", "protocol", "service", "status", "uptime", "version", + "features", "managementUrl", "minimumClientProtocol", "pid", "port", "protocol", "service", "status", "uptime", "version", ]); expect(JSON.stringify(readyzBody)).not.toContain("warning"); expect(JSON.stringify(readyzBody)).not.toContain("path"); From 072e7fe540d7afd068154c57817ea3334d9df1bf Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 22 Oct 2026 01:14:19 +0900 Subject: [PATCH 10/25] test(update): let the test-scoped Windows ACL belt through the host-free helper (#7487) * test(update): let the test-scoped Windows ACL belt through the host-free helper #7468's withoutHostProcesses refused every spawn, including the icacls.exe that config writes run on Windows against the test home, and failed 9 update-job cases on windows 9/9. Pass through exactly %SystemRoot%\System32\icacls.exe when every path argument is inside OPENCODEX_HOME or the temp dir, record it in a separate allowed list, and keep refusing everything else. * test(update): allow only the ACL belt's own icacls argv, with a canonical target Review (#7487): a '..' segment passed the prefix check, and switches such as /save took a relative file operand outside the test roots. Allow exactly the argv shapes src/lib/windows-secret-acl.ts builds, resolve the target with path.win32.resolve before the containment check, and test the refusals. --- scripts/test-layout/layout.json | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/helpers/update-recovery-host-free.ts | 68 ++++++++++++++++++- .../update/update-recovery-host-free.test.ts | 64 +++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 tests/update/update-recovery-host-free.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 48c487f770f..6813278866d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1840,7 +1840,7 @@ "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update", - "update-job.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", + "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 74344230178..e5304118cb2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1249,7 +1249,7 @@ "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update", - "update-job.test.ts": "update", "update-job-supervised-restart.test.ts": "update", + "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", diff --git a/tests/helpers/update-recovery-host-free.ts b/tests/helpers/update-recovery-host-free.ts index 9bc9aa7ea4d..112a123592b 100644 --- a/tests/helpers/update-recovery-host-free.ts +++ b/tests/helpers/update-recovery-host-free.ts @@ -1,4 +1,6 @@ import { expect } from "bun:test"; +import { tmpdir } from "node:os"; +import { win32 } from "node:path"; /** * Service-recovery scenarios answer every host probe themselves (#7417). On macOS the default @@ -22,14 +24,32 @@ export const HOST_FREE_RECOVERY_IO = { * best-effort scheduler-wrapper cleanup (`killWindowsSchedulerWrappers`) launches PowerShell, * scoped to this test home's own wrapper paths, and swallows any failure. It is refused like * everything else, so it does no host work, but it is not counted against the scenario. + * + * The Windows ACL belt is the other exception. Every private config write on Windows tightens + * its ACL with `%SystemRoot%\\System32\\icacls.exe` (src/config/atomic-write.ts, + * src/config/paths.ts), and these scenarios write config inside the test home. Before this + * helper existed, those calls ran for real on Windows CI. Exactly that executable, called in one + * of the ACL belt's own argv shapes (one target, then nothing or /grant:r, /inheritance:r, + * /remove:g or /findsid with principal/SID/ACE tokens only), with the target canonicalized and + * inside the test's OPENCODEX_HOME or the temp directory, is passed through to the real spawn. + * It is recorded in a separate `allowed` list (readable through `observed`) rather than the + * refused list, so a caller can assert on it. Everything else is still refused. */ -export async function withoutHostProcesses(run: () => Promise): Promise { +export async function withoutHostProcesses( + run: () => Promise, + observed?: { allowed: string[] }, +): Promise { const bun = Bun as unknown as { spawnSync: (...args: unknown[]) => unknown }; const original = bun.spawnSync; const spawned: string[] = []; + const allowed: string[] = observed?.allowed ?? []; bun.spawnSync = (...args: unknown[]) => { const first = args[0] as string[] | { cmd?: string[] } | undefined; const command = String(Array.isArray(first) ? first[0] : first?.cmd?.[0]); + if (isTestScopedWindowsAcl(command, Array.isArray(first) ? first : first?.cmd ?? [])) { + allowed.push(command); + return (original as (...a: unknown[]) => unknown).apply(bun, args); + } spawned.push(command); throw new Error(`unexpected host process launch: ${command}`); }; @@ -42,6 +62,7 @@ export async function withoutHostProcesses(run: () => Promise): Promise bun.spawnSync = original; } expect(spawned.filter(command => !isWindowsWrapperCleanup(command))).toEqual([]); + expect(allowed.every(command => process.platform === "win32" && isWindowsIcaclsBinary(command))).toBe(true); if (!outcome.ok) throw outcome.error; return outcome.value; } @@ -49,3 +70,48 @@ export async function withoutHostProcesses(run: () => Promise): Promise function isWindowsWrapperCleanup(command: string): boolean { return process.platform === "win32" && /[\\/]powershell\.exe$/i.test(command); } + +/** Exported for the helper's own test; see the Windows ACL note on {@link withoutHostProcesses}. */ +export function isTestScopedWindowsAcl( + command: string, + argv: readonly unknown[], + platform: string = process.platform, + roots: readonly string[] = testScopedRoots(), + systemRoot: string = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", +): boolean { + if (platform !== "win32" || !isWindowsIcaclsBinary(command, systemRoot)) return false; + // Exactly the argv shapes src/lib/windows-secret-acl.ts builds: one target path, then nothing, + // or one of its four switches followed only by principal/SID/ACE tokens. Any other switch + // (/save, /restore, /setowner, ...) or any further path or file operand is refused. + const [, target, step, ...rest] = argv.map(String); + if (target === undefined || !/^(?:[a-zA-Z]:[\\/]|\\\\)/.test(target)) return false; + if (step !== undefined && !ACL_BELT_STEPS.has(step)) return false; + if (step === undefined && rest.length > 0) return false; + if (!rest.every(token => ACE_TOKEN.test(token))) return false; + // win32.resolve collapses "." and ".." segments, so a traversal cannot pass the prefix check. + const canonical = normalizeWindowsPath(win32.resolve(target)); + return roots.filter(Boolean) + .map(root => normalizeWindowsPath(win32.resolve(root))) + .some(root => canonical === root || canonical.startsWith(`${root}/`)); +} + +/** The four switches the production ACL belt passes to icacls (src/lib/windows-secret-acl.ts). */ +const ACL_BELT_STEPS = new Set(["/grant:r", "/inheritance:r", "/remove:g", "/findsid"]); +/** A principal or SID, optionally with rights: "user:(OI)(CI)(F)", "*S-1-1-0", "*S-1-5-32-545:(R)". */ +const ACE_TOKEN = /^\*?[^\\/:\s][^\\/:]*(?::(?:\([A-Z]+\))+)?$/; + +/** True only for the exact `%SystemRoot%\\System32\\icacls.exe`, compared case-insensitively. */ +export function isWindowsIcaclsBinary( + command: string, + systemRoot: string = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", +): boolean { + return normalizeWindowsPath(command) === normalizeWindowsPath(win32.join(systemRoot, "System32", "icacls.exe")); +} + +function normalizeWindowsPath(value: string): string { + return value.replace(/[\\/]+/g, "/").replace(/\/$/, "").toLowerCase(); +} + +function testScopedRoots(): string[] { + return [process.env.OPENCODEX_HOME ?? "", tmpdir()]; +} diff --git a/tests/update/update-recovery-host-free.test.ts b/tests/update/update-recovery-host-free.test.ts new file mode 100644 index 00000000000..8e4517ce7a9 --- /dev/null +++ b/tests/update/update-recovery-host-free.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { isTestScopedWindowsAcl, isWindowsIcaclsBinary } from "../helpers/update-recovery-host-free"; + +const ROOT = "C:\\Windows"; +const ICACLS = "C:\\Windows\\system32\\icacls.exe"; +const HOME = "C:\\Users\\runner\\AppData\\Local\\Temp\\ocx-test-home"; +const roots = [HOME, "D:\\a\\_temp"]; +const SID = "*S-1-5-21-1111-2222-3333-1001"; +const target = `${HOME}\\config.json`; +const allowed = (argv: string[], command = ICACLS) => isTestScopedWindowsAcl(command, [command, ...argv], "win32", roots, ROOT); + +describe("update-recovery host-free helper: Windows ACL exemption", () => { + test("matches only the exact System32 icacls binary", () => { + expect(isWindowsIcaclsBinary(ICACLS, ROOT)).toBe(true); + expect(isWindowsIcaclsBinary("c:/windows/SYSTEM32/ICACLS.EXE", ROOT)).toBe(true); + expect(isWindowsIcaclsBinary("icacls.exe", ROOT)).toBe(false); + expect(isWindowsIcaclsBinary("C:\\tools\\icacls.exe", ROOT)).toBe(false); + expect(isWindowsIcaclsBinary("C:\\Windows\\System32\\icacls.exe.bak", ROOT)).toBe(false); + }); + + test("allows every argv shape the production ACL belt builds (src/lib/windows-secret-acl.ts)", () => { + expect(allowed([target])).toBe(true); + expect(allowed([target, "/grant:r", `${SID}:(F)`])).toBe(true); + expect(allowed([HOME, "/grant:r", `${SID}:(OI)(CI)(F)`, "*S-1-5-18:(R)"])).toBe(true); + expect(allowed([target, "/inheritance:r"])).toBe(true); + expect(allowed([target, "/remove:g", "*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"])).toBe(true); + expect(allowed([target, "/findsid", "*S-1-1-0"])).toBe(true); + expect(allowed(["D:\\a\\_temp\\x"])).toBe(true); + }); + + test("canonicalizes the target, so traversal out of a root is refused", () => { + expect(allowed([`${HOME}\\..\\victim`, "/inheritance:r"])).toBe(false); + expect(allowed(["D:\\a\\_temp\\..\\..\\x"])).toBe(false); + expect(allowed([`${HOME}\\sub\\..\\config.json`])).toBe(true); + expect(allowed([`${HOME}-sibling\\a`])).toBe(false); + expect(allowed(["C:\\Users\\runner\\.opencodex\\auth.json"])).toBe(false); + }); + + test("refuses switches and operands the ACL belt never passes", () => { + expect(allowed([HOME, "/save", "aclfile"])).toBe(false); + expect(allowed([HOME, "/restore", "aclfile"])).toBe(false); + expect(allowed([target, "/setowner", SID])).toBe(false); + expect(allowed([target, "/grant:r", `${SID}:(F)`, "/T"])).toBe(false); + expect(allowed([target, "/grant:r", "relative\\file"])).toBe(false); + expect(allowed([target, `${HOME}\\second.json`])).toBe(false); + expect(allowed([target, "/grant:r", `${HOME}\\x`])).toBe(false); + expect(allowed(["relative\\config.json"])).toBe(false); + expect(allowed(["/grant:r", target])).toBe(false); + expect(allowed([])).toBe(false); + }); + + test("never allows anything off Windows or for another binary", () => { + expect(isTestScopedWindowsAcl(ICACLS, [ICACLS, target], "linux", roots, ROOT)).toBe(false); + expect(isTestScopedWindowsAcl(ICACLS, [ICACLS, target], "darwin", roots, ROOT)).toBe(false); + expect(allowed([target], "C:\\Windows\\System32\\cmd.exe")).toBe(false); + }); + + test("accepts UNC targets under a UNC root, case-insensitively", () => { + const unc = "\\\\host\\share\\home"; + expect(isTestScopedWindowsAcl(ICACLS, [ICACLS, "\\\\HOST\\Share\\home\\cfg"], "win32", [unc], ROOT)).toBe(true); + expect(isTestScopedWindowsAcl(ICACLS, [ICACLS, "\\\\host\\share\\other"], "win32", [unc], ROOT)).toBe(false); + }); +}); + From 7a9dc77bdbb89b1e15dd6cac869a4b87a1c91a0b Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 22 Oct 2026 02:36:11 +0900 Subject: [PATCH 11/25] fix(chat): carry Anthropic thinking signatures through reasoning_details (#7469) * fix(chat): carry Anthropic thinking signatures through reasoning_details (#7429) Chat Completions clients routed to Claude echoed only reasoning_content, so every tool step reached Anthropic without the signed thinking block it requires and the adapter dropped the reasoning. Return provider-issued thinking state as OpenRouter-style reasoning_details (reasoning.text with signature, reasoning.encrypted for redacted blocks) on the bridge converter and the direct encoder, streaming and not, including Opus progress updates. Accept the entries back only for a single first-party Anthropic route; anything malformed or any other destination keeps today's plaintext projection byte for byte. Fixes #7429 * fix(chat): replay progress content as sent and narrow the fallback claim (#7429) Review: the Chat wire carries no marker that tells an Opus progress copy from answer text, so stripping a signed-text prefix from content could cut a real answer that begins with a signed thought. Replay content as the client sent it (today's behavior) and only add the signed block. Document that validation is structural: malformed entries fall back to reasoning_content, while an edited text or signature is forwarded and Anthropic refuses it. * test(chat): cover the pooled OAuth Anthropic route in the #7429 round trip Review: every replay case used an API key. A Claude subscription reaches the same first-party host with a pooled OAuth account, so run the three-step tool loop there too. * test(layout): keep the reasoning-details entry on its sibling line A new line in scripts/test-layout/layout.json reached 2000 lines on current dev and tripped the file-size ratchet (NEW_OVERSIZED). * docs(structure): move the #7429 reasoning_details section into its own data-plane doc With #7470 on dev, the section pushed structure/data-planes/inbound-compat.md to 606 lines, over the 600-line budget. Move it to structure/data-planes/chat-reasoning-details.md (owner of src/chat/reasoning-details.ts), leave a cross-reference, repoint protocol-paths, and regenerate INDEX.md. --- .../content/docs/reference/proxy-formats.md | 12 + scripts/test-layout/layout.json | 2 +- src/adapters/anthropic.ts | 7 +- src/chat/inbound.ts | 22 +- src/chat/outbound.ts | 40 ++- src/chat/reasoning-details.ts | 137 ++++++++ src/protocols/codecs/chat.ts | 6 +- src/protocols/encoders/adapter-events.ts | 28 +- src/protocols/encoders/chat.ts | 58 +++- src/responses/reasoning-envelope.ts | 12 + src/server/chat-completions.ts | 9 +- structure/INDEX.md | 2 + .../data-planes/chat-reasoning-details.md | 32 ++ structure/data-planes/inbound-compat.md | 4 +- structure/data-planes/protocol-paths.md | 2 +- structure/manifest.json | 9 + tests/fixtures/test-layout-expected.json | 1 + .../chat-anthropic-reasoning-details.test.ts | 312 ++++++++++++++++++ .../protocol-direct-encoders-chat.test.ts | 76 +++++ 19 files changed, 748 insertions(+), 23 deletions(-) create mode 100644 src/chat/reasoning-details.ts create mode 100644 structure/data-planes/chat-reasoning-details.md create mode 100644 tests/responses/chat-anthropic-reasoning-details.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 534c9aa049b..fe6d7330b18 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -546,6 +546,18 @@ effort default to `reasoning.summary: "auto"` so thinking streams back as `reasoning.summary: "none"`. An explicit `reasoning.summary` of `auto`, `concise`, `detailed`, or `none` wins over `include_reasoning`. +When the model is Claude, the response also carries `reasoning_details` in the same shape +OpenRouter uses: `{"type": "reasoning.text", "text", "signature", "format": "anthropic-claude-v1"}` +for each signed thinking block and `{"type": "reasoning.encrypted", "data", ...}` for each redacted +one, on `message` or as `delta.reasoning_details` (each entry arrives complete in one chunk). Send +the array back unchanged on the assistant message, next to its `tool_calls`, and Claude keeps its +reasoning across tool steps. The signature is opaque and is the same value `/v1/messages` +clients receive. OpenCodex replays it only when the model routes to Anthropic's own API +(`api.anthropic.com`) as a single route; on any other destination, or when an entry is +malformed (an unknown type or format, or a missing signature), the turn falls back to the plain +`reasoning_content` text. An entry whose text or signature was edited is forwarded as is, and +Anthropic rejects it, so return the array exactly as received. + Structured output is part of that translation: `response_format` with `json_object` or `json_schema` is forwarded to routed `openai-chat` models. On `POST /v1/responses` the equivalent request field is `text.format`: native Responses routes preserve it in the raw diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6813278866d..f0c2a2fa919 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -355,7 +355,7 @@ "messages-native-oauth-eligibility.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", - "chat-inbound-reasoning-replay.test.ts": "responses", + "chat-inbound-reasoning-replay.test.ts": "responses", "chat-anthropic-reasoning-details.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-media-translation.test.ts": "responses", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index aef61bfad9d..4251de21e29 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -32,6 +32,7 @@ import { redactSecretString } from "../lib/redact"; import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { decodeServerSentEvents } from "../lib/sse-decoder"; +import { isLikelyRealAnthropicThinkingSignature } from "../responses/reasoning-envelope"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort"; import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; @@ -291,11 +292,7 @@ function normalizeTtlOrdering(body: Record): void { } } -export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { - if (typeof signature !== "string" || signature.length < 16) return false; - if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false; - return /^[A-Za-z0-9+/_=-]+$/.test(signature); -} +export { isLikelyRealAnthropicThinkingSignature }; /** * Bridge error fidelity (web-search/images loops): extract a display-safe summary from an diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index 7286bb5c5c8..4e60b362e2b 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -6,6 +6,7 @@ * responsesRequestSchema so routing/OAuth/pool/sidecars are inherited unchanged. */ import { chatImageUrlFromPart } from "./image-parts"; +import { signedReasoningInputItems } from "./reasoning-details"; import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../responses/input-media"; export class ChatCompletionsRequestError extends Error {} @@ -439,7 +440,16 @@ function resolveReasoningSummary(raw: Rec): string | undefined { * Translate an OpenAI Chat Completions request body into a /v1/responses request body. * Throws ChatCompletionsRequestError (-> 400) on malformed input. */ -export function chatCompletionsToResponsesBody(raw: unknown): Rec { +export interface ChatResponsesProjectionOptions { + /** + * The route is one first-party Anthropic destination that cannot change credential domain, so + * signed thinking a client returns in `reasoning_details` may be replayed to it (#7429). + * Every other route keeps the plaintext projection. + */ + acceptAnthropicReasoningSignatures?: boolean; +} + +export function chatCompletionsToResponsesBody(raw: unknown, options: ChatResponsesProjectionOptions = {}): Rec { assertChatCompletionsRoutingBody(raw); // Only the translated path reaches this function. Native Chat can retain its // provider-specific file/audio blocks; projecting them here would discard them. @@ -510,7 +520,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { // is no attachment point on the message itself, and the parser buffers a // reasoning item and prepends it to the NEXT assistant message. Emitting it // here keeps that adjacency intact. - const reasoningText = assistantReasoningText(msg); + const signedReasoning = options.acceptAnthropicReasoningSignatures === true + ? signedReasoningInputItems(msg.reasoning_details) + : undefined; + const reasoningText = signedReasoning ? undefined : assistantReasoningText(msg); + // Signed entries carry their own text, so `reasoning_content` (the same text) is not + // projected a second time beside them. + if (signedReasoning) input.push(...signedReasoning); if (reasoningText !== undefined) { // `summary` is required on a reasoning input item by the OpenAI Responses API, and our // own responsesRequestSchema marks it optional, so a summary-less item validated locally @@ -525,6 +541,8 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { content: [{ type: "reasoning_text", text: reasoningText }], }); } + // An Opus progress update also sits in `content`. The wire has no marker that tells it + // from answer text, so the content is replayed as sent, as it was before #7429. const blocks = assistantContentToBlocks(msg.content); if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks }); if (msg.tool_calls !== undefined) { diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index d5da058caa8..191efcc460e 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -23,6 +23,7 @@ import { } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite"; +import { reasoningDetailEntriesForItem } from "./reasoning-details"; function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); @@ -149,7 +150,7 @@ export function chunkBase(id: string, model: string, created: number): Rec { }; } -function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number { +export function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number { let nextBytes = previousBytes + Buffer.byteLength(fragment); const previousLast = previous.charCodeAt(previous.length - 1); const fragmentFirst = fragment.charCodeAt(0); @@ -511,6 +512,17 @@ export function responsesSseToChatCompletionsSse( frame.choices = [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }]; emit(frame); }; + // #7429: provider-issued thinking state, one complete frame per finished reasoning item. + let nextReasoningDetailIndex = 0; + const emitReasoningDetails = (item: Rec) => { + const entries = reasoningDetailEntriesForItem(item, nextReasoningDetailIndex, translatorBudget); + if (entries.length === 0) return; + nextReasoningDetailIndex += entries.length; + ensureRole(); + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }]; + emit(frame); + }; const resolveFinalArguments = (candidate: unknown, streamed: string) => { if (typeof candidate !== "string") return streamed; // Some compatible providers send an empty final snapshot. Do not let that erase @@ -717,6 +729,7 @@ export function responsesSseToChatCompletionsSse( && refusalItems.has(data.output_index)) { refusalItem(data.output_index, data, "item_id"); } + if (item.type === "reasoning") emitReasoningDetails(item); if (item.type === "function_call") { sawToolUse = true; const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -872,6 +885,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran let contentBytes = 0; let reasoningBytes = 0; const toolCalls: Rec[] = []; + const reasoningDetails: Rec[] = []; const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => { if (!fragment) return { text: previous, bytes: previousBytes }; const scope = { kind: "retained_collectors" as const }; @@ -902,6 +916,11 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran } } } else if (raw.type === "reasoning") { + const entries = reasoningDetailEntriesForItem(raw, reasoningDetails.length, translatorBudget); + if (entries.length > 0) { + translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(entries)), { kind: "retained_collectors" }); + reasoningDetails.push(...entries); + } if (Array.isArray(raw.summary)) { for (const part of raw.summary) { if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") { @@ -948,6 +967,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran refusal, }; if (reasoning) message.reasoning_content = reasoning; + if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails; if (toolCalls.length > 0) message.tool_calls = toolCalls; return { @@ -981,7 +1001,8 @@ export async function collectChatCompletion( let content = ""; let refusal: string | null = null; let reasoning = ""; - const retainedBytes = { content: 0, refusal: 0, reasoning: 0 }; + const reasoningDetails: Rec[] = []; + const retainedBytes = { content: 0, refusal: 0, reasoning: 0, reasoningDetails: 0 }; const toolCalls = new Map(); // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the // wire index, which is stable across deltas and present before the call id. @@ -1005,8 +1026,8 @@ export async function collectChatCompletion( } }; const releaseCollectors = () => { - translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning, - { kind: "retained_collectors" }); + translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning + + retainedBytes.reasoningDetails, { kind: "retained_collectors" }); }; try { // Share native relay framing, while retaining the collector's existing admission @@ -1055,6 +1076,16 @@ export async function collectChatCompletion( refusal = appendRetained("refusal", refusal ?? "", delta.refusal); } if (typeof delta.reasoning_content === "string") reasoning = appendRetained("reasoning", reasoning, delta.reasoning_content); + // Each entry arrives complete in one frame (#7429), so the fold is a concatenation. + if (Array.isArray(delta.reasoning_details)) { + for (const entry of delta.reasoning_details) { + if (!isRec(entry)) continue; + const entryBytes = Buffer.byteLength(JSON.stringify(entry)); + translatorBudget.chargeRetained(entryBytes, { kind: "retained_collectors" }); + retainedBytes.reasoningDetails += entryBytes; + reasoningDetails.push(entry); + } + } if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) { if (!isRec(tc)) continue; @@ -1121,6 +1152,7 @@ export async function collectChatCompletion( refusal, }; if (reasoning) message.reasoning_content = reasoning; + if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails; if (toolCalls.size > 0) { // Final owner transfer is itself a charging operation: if it overflows, // release every copy already charged in this loop and close every scope diff --git a/src/chat/reasoning-details.ts b/src/chat/reasoning-details.ts new file mode 100644 index 00000000000..51cabea129a --- /dev/null +++ b/src/chat/reasoning-details.ts @@ -0,0 +1,137 @@ +/** + * Anthropic thinking state on the Chat Completions wire, as OpenRouter-style `reasoning_details` + * (#7429). + * + * Chat has no slot for a thinking block's signature, so a Chat client echoing `reasoning_content` + * gave Claude back unsigned text, which the Anthropic adapter must drop: every tool step lost its + * reasoning. The carrier is the one OpenRouter documents for Claude and Hermes and pi already round + * trip: an ordered array on the assistant message, `{type: "reasoning.text", text, signature}` + * for a signed thinking block and `{type: "reasoning.encrypted", data}` for a redacted one. + * + * Outbound, the entries are built from the signature and redacted payloads the provider issued, + * never from anything this proxy computes. A signature that signs text the client never saw + * (hidden thinking) is withheld, because pairing it with the visible text would attest to content + * Anthropic did not sign. Inbound, the entries are read only for a single-destination first-party + * Anthropic route (the caller decides that), only when every entry is well formed, and become the + * same `ocxr1` envelope the Responses wire already uses; anything else falls back to plaintext. + */ +import { + decodeReasoningEnvelope, + encodeReasoningEnvelope, + isLikelyRealAnthropicThinkingSignature, + type ReasoningEnvelope, +} from "../responses/reasoning-envelope"; +import type { TranslatorBudget } from "../lib/translator-budget"; + +type Rec = Record; + +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** OpenRouter's `format` for Anthropic thinking entries. */ +export const ANTHROPIC_REASONING_DETAIL_FORMAT = "anthropic-claude-v1"; + +/** One closed reasoning item, as either outbound path sees it. */ +export interface ReasoningDetailSource { + /** The thinking text the client was shown for this item. */ + text: string; + signature?: string; + redacted?: readonly string[]; + /** The signature covers thinking text the client was not shown. */ + hidden?: boolean; +} + +/** + * The `reasoning_details` entries for one reasoning item, numbered from `firstIndex`. Redacted + * blocks come first: that is the order the adapter replays them in. Empty when the item carries no + * provider-issued state a Chat client can return. + */ +export function reasoningDetailEntries(source: ReasoningDetailSource, firstIndex: number): Rec[] { + const entries: Rec[] = []; + for (const data of source.redacted ?? []) { + if (typeof data !== "string" || data.length === 0) continue; + entries.push({ + type: "reasoning.encrypted", + data, + format: ANTHROPIC_REASONING_DETAIL_FORMAT, + index: firstIndex + entries.length, + }); + } + if (source.hidden !== true && isLikelyRealAnthropicThinkingSignature(source.signature)) { + entries.push({ + type: "reasoning.text", + text: source.text, + signature: source.signature, + format: ANTHROPIC_REASONING_DETAIL_FORMAT, + index: firstIndex + entries.length, + }); + } + return entries; +} + +/** The visible text of a Responses reasoning item, read the way the parser reads it. */ +export function responsesReasoningItemText(item: Rec): string { + const pick = (parts: unknown, type: string): string => Array.isArray(parts) + ? parts.filter((part): part is Rec => isRec(part) && part.type === type && typeof part.text === "string") + .map(part => part.text as string).join("") + : ""; + return pick(item.summary, "summary_text") || pick(item.content, "reasoning_text"); +} + +/** + * The entries for a finished Responses reasoning item from the internal bridge. Its `ocxr1` + * envelope holds what the provider issued. `txt` alone means the signed text was hidden; `txt` + * with `upd` is an Opus progress update, whose signed text the client received as `content`. + */ +export function reasoningDetailEntriesForItem(item: Rec, firstIndex: number, budget?: TranslatorBudget): Rec[] { + if (typeof item.encrypted_content !== "string") return []; + const envelope = decodeReasoningEnvelope(item.encrypted_content, budget); + if (!envelope || (!envelope.sig && !envelope.red)) return []; + const progress = envelope.upd !== undefined && typeof envelope.txt === "string"; + return reasoningDetailEntries({ + text: progress ? envelope.txt! : responsesReasoningItemText(item), + ...(envelope.sig ? { signature: envelope.sig } : {}), + ...(envelope.red ? { redacted: envelope.red } : {}), + hidden: !progress && envelope.txt !== undefined, + }, firstIndex); +} + +function reasoningInputItem(text: string, envelope: ReasoningEnvelope): Rec { + return { + type: "reasoning", + summary: text ? [{ type: "summary_text", text }] : [], + ...(text ? { content: [{ type: "reasoning_text", text }] } : {}), + encrypted_content: encodeReasoningEnvelope(envelope), + }; +} + +/** + * Responses reasoning input items carrying the signatures a client returned in + * `reasoning_details`, or `undefined` when the array is absent, empty or holds anything this + * carrier did not issue — an unsigned or malformed signature, a summary entry, another provider's + * encrypted format. Undefined means the caller keeps its plaintext projection unchanged. + */ +export function signedReasoningInputItems(details: unknown): Rec[] | undefined { + if (!Array.isArray(details) || details.length === 0) return undefined; + const items: Rec[] = []; + let redacted: string[] = []; + for (const raw of details) { + if (!isRec(raw)) return undefined; + if (raw.type === "reasoning.encrypted") { + if (raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT || typeof raw.data !== "string" || raw.data.length === 0) { + return undefined; + } + redacted.push(raw.data); + continue; + } + if (raw.type !== "reasoning.text") return undefined; + if (raw.format !== undefined && raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT) return undefined; + const signature = typeof raw.signature === "string" ? raw.signature : undefined; + if (typeof raw.text !== "string" || !isLikelyRealAnthropicThinkingSignature(signature)) return undefined; + items.push(reasoningInputItem(raw.text, { sig: signature, ...(redacted.length > 0 ? { red: redacted } : {}) })); + redacted = []; + } + if (redacted.length > 0) items.push(reasoningInputItem("", { red: redacted })); + return items; +} diff --git a/src/protocols/codecs/chat.ts b/src/protocols/codecs/chat.ts index eddef8404ec..c0933160ed5 100644 --- a/src/protocols/codecs/chat.ts +++ b/src/protocols/codecs/chat.ts @@ -6,12 +6,12 @@ * (`src/chat/inbound.ts`), including its validation and the fields it drops, which * `src/protocols/features.ts` declares. */ -import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody } from "../../chat/inbound"; +import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody, type ChatResponsesProjectionOptions } from "../../chat/inbound"; import { featuresFromChatBody } from "../features"; /** Project a Chat Completions body onto the internal Responses bridge body. */ -export function chatToResponsesBody(body: unknown): Record { - return chatCompletionsToResponsesBody(body); +export function chatToResponsesBody(body: unknown, options?: ChatResponsesProjectionOptions): Record { + return chatCompletionsToResponsesBody(body, options); } /** diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index cf240923bef..4fc36ce90e3 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -109,7 +109,13 @@ export interface ClientWireWriter { text(delta: string): void; messageDone(): void; reasoning(delta: string, itemId: string, channel: "summary" | "raw"): void; - reasoningDone(item: { itemId: string; signature?: string; redacted?: string[] }): void; + /** `hidden`: the signature covers thinking text the client was not shown (the bridge's `txt`). */ + reasoningDone(item: { itemId: string; signature?: string; redacted?: string[]; hidden?: true }): void; + /** + * An Anthropic progress update: `text` is shown as content and is also the signed thinking + * block's text. Optional — a writer without it renders the update as ordinary text. + */ + progressThinking?(text: string, signature: string): void; toolStart(call: ClientToolCall): void; toolArgsDelta(itemId: string, delta: string): void; toolArgsDone(itemId: string, args: string): void; @@ -331,6 +337,8 @@ export function encodeAdapterEventStream( let currentRawReasoning: { itemId: string } | null = null; let pendingSignature: string | undefined; let pendingRedacted: string[] = []; + // The bridge's hiddenThinking: non-empty thinking text suppressed by hideThinkingSummary. + let hiddenThinkingSeen = false; let hiddenRawBytes = 0; let pendingKiroRedacted: string | undefined; let currentToolCall: OpenToolCall | null = null; @@ -367,9 +375,11 @@ export function encodeAdapterEventStream( }; // hideThinkingSummary: a signed or redacted block still round-trips as an envelope-only item. const flushHiddenReasoningEnvelope = () => { + const hidden = hiddenThinkingSeen; + hiddenThinkingSeen = false; const envelope = takeReasoningEnvelope(); if (!envelope) return; - writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope }); + writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope, ...(hidden ? { hidden: true as const } : {}) }); }; const flushHiddenRawReasoning = () => { if (hiddenRawBytes === 0) return; @@ -570,6 +580,19 @@ export function encodeAdapterEventStream( break; } case "text_delta": { + if (event.anthropicThinkingSignature && writer.progressThinking) { + // The bridge's progress items: close everything, the carrier, then the visible copy. + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + flushHiddenReasoningEnvelope(); + if (currentToolCall) closeCurrentToolCall(); + writer.progressThinking(event.text, event.anthropicThinkingSignature); + writer.text(event.text); + writer.messageDone(); + break; + } if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); @@ -585,6 +608,7 @@ export function encodeAdapterEventStream( } case "thinking_delta": { if (options.hideThinkingSummary) { + if (event.thinking.length > 0) hiddenThinkingSeen = true; flushHiddenRawReasoning(); break; } diff --git a/src/protocols/encoders/chat.ts b/src/protocols/encoders/chat.ts index 1b636b03f1f..4df4ce71353 100644 --- a/src/protocols/encoders/chat.ts +++ b/src/protocols/encoders/chat.ts @@ -13,6 +13,7 @@ import type { AdapterEvent } from "../../types"; import type { TranslatorBudget } from "../../lib/translator-budget"; import { responsesUsage } from "../../bridge/internal"; import { + appendedUtf8Bytes, chatCompletionsErrorResponse, chatCompletionsFailedResponse, chatCompletionsIncompleteOutcome, @@ -32,6 +33,7 @@ import { type ClientWireWriter, } from "./adapter-events"; import type { RelayedEventObservation } from "../../usage/attempt-delivery"; +import { reasoningDetailEntries } from "../../chat/reasoning-details"; type Rec = Record; @@ -54,6 +56,41 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien const emittedToolIndexes = new Set(); let nextToolIndex = 0; let staged: { text: string; observation?: RelayedEventObservation }[] | undefined; + // #7429: the shown thinking text of the open reasoning item, which its signature pairs with. + const budget = sink.budget; + let reasoningItemId: string | undefined; + let reasoningText = ""; + let reasoningTextBytes = 0; + let nextReasoningDetailIndex = 0; + const releaseReasoningText = () => { + budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" }); + reasoningItemId = undefined; + reasoningText = ""; + reasoningTextBytes = 0; + }; + const retainReasoningText = (itemId: string, delta: string) => { + if (reasoningItemId !== itemId) releaseReasoningText(); + reasoningItemId = itemId; + const nextBytes = appendedUtf8Bytes(reasoningText, reasoningTextBytes, delta); + const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" }); + try { + reasoningText += delta; + reservation.commitRetained(); + budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" }); + reasoningTextBytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } + }; + const emitReasoningDetails = (entries: Rec[]) => { + if (entries.length === 0) return; + nextReasoningDetailIndex += entries.length; + ensureRole(); + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }]; + emit(frame); + }; const emit = (payload: Rec | "[DONE]", observation?: RelayedEventObservation) => { if (failed) return; @@ -138,15 +175,30 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien emit(frame, { semanticBytes: Buffer.byteLength(delta) }); }, messageDone() { /* the converter reads nothing from a finished message */ }, - reasoning(delta) { + reasoning(delta, itemId, channel) { if (!delta) return; ensureRole(); // Many OpenAI-compatible clients accept reasoning_content; harmless if ignored. const frame = chunkBase(id, model, created); frame.choices = [{ index: 0, delta: { reasoning_content: delta }, finish_reason: null }]; emit(frame, { semanticBytes: Buffer.byteLength(delta) }); + // Only summary-channel thinking can carry a signature; raw reasoning never does. + if (channel === "summary") retainReasoningText(itemId, delta); + }, + reasoningDone({ itemId, signature, redacted, hidden }) { + // The bridge converter's output_item.done for a reasoning item (#7429), entry for entry. + const entries = reasoningDetailEntries({ + text: reasoningItemId === itemId ? reasoningText : "", + ...(signature ? { signature } : {}), + ...(redacted ? { redacted } : {}), + ...(hidden ? { hidden } : {}), + }, nextReasoningDetailIndex); + releaseReasoningText(); + emitReasoningDetails(entries); + }, + progressThinking(text, signature) { + emitReasoningDetails(reasoningDetailEntries({ text, signature }, nextReasoningDetailIndex)); }, - reasoningDone() { /* reasoning items carry nothing a Chat client reads */ }, toolStart(call) { // Custom and tool-search calls have no Chat representation; the converter skips them. if (call.kind !== "function") return; @@ -198,7 +250,7 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien type: "upstream_error", }); }, - dispose() { /* nothing retained beyond the driver's frames */ }, + dispose() { releaseReasoningText(); }, }; } diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index edfc7e464d7..628bd82838a 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -121,6 +121,18 @@ export function isProviderIssuedThinkingSignature( } } +/** + * Whether a value has the spelling of a signature Anthropic issued. An allow-list on top of + * `isProviderIssuedThinkingSignature`: Anthropic's signature is base64, so item ids and the + * parser's serialized placeholder never pass. Shared by the adapter, which forwards only such a + * signature, and the Chat `reasoning_details` carrier, which accepts and emits only such a one. + */ +export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { + if (typeof signature !== "string" || signature.length < 16) return false; + if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false; + return /^[A-Za-z0-9+/_=-]+$/.test(signature); +} + /** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */ export function decodeReasoningEnvelope(encryptedContent: string, budget?: TranslatorBudget): ReasoningEnvelope | null { if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null; diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 870647414a6..9d1b2dfac1e 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -81,6 +81,7 @@ import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDec import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract"; import { createProtocolEnvelope } from "../protocols/envelope"; import { featuresFromChatBody } from "../protocols/features"; +import { credentialDomainFor } from "../protocols/opaque-state"; import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; import { requestPathForLane } from "../protocols/path"; import { resolveProtocolSettings } from "../protocols/settings"; @@ -344,7 +345,13 @@ async function handleChatCompletionsWithBudget( directConversation = chatConversationToIr(chatBody, chatBody.model as string); internalBody = chatSettingsBody(chatBody); } else { - internalBody = chatToResponsesBody(chatBody); + internalBody = chatToResponsesBody(chatBody, { + // #7429: only a route that is and stays first-party Anthropic may receive signed thinking + // a Chat client returns; a combo, policy or rewritten route resolves its target later. + acceptAnthropicReasoningSignatures: settledRoute !== null && !routeMayChangeCredentialDomain + && settledRoute.provider.adapter === "anthropic" + && credentialDomainFor(settledRoute.provider)?.firstPartyAnthropic === true, + }); } if (effortRow) { internalBody.reasoning = { diff --git a/structure/INDEX.md b/structure/INDEX.md index 38e94aa3dfc..c370ead65da 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -61,6 +61,7 @@ The wire surfaces a client actually talks to. | [`data-planes/images.md`](data-planes/images.md) | Standalone image generation/edit relay and local hosted Responses image display. | | [`data-planes/search.md`](data-planes/search.md) | Hosted search relay and exact account selectors. | | [`data-planes/inbound-compat.md`](data-planes/inbound-compat.md) | Chat Completions inbound, Anthropic-shaped clients, and JSON-upstream streaming clients. | +| [`data-planes/chat-reasoning-details.md`](data-planes/chat-reasoning-details.md) | The reasoning_details carrier that lets Chat Completions clients round-trip signed Anthropic thinking across tool steps (#7429). | | [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | Shared protocol vocabulary, declared feature dispositions, the ingress-by-upstream baseline, plan/trace shapes, and protocol settings. | | [`remote-workspace.md`](remote-workspace.md) | Opt-in workspace identity, executor grants, runtime adapters, management and dashboard integration. | | [`remote-link.md`](remote-link.md) | SSH machine-link building blocks: OpenSSH argument policy, ssh_config candidates, tunnel lifecycle reducer, the private link store, and standalone pairing-grant delivery. | @@ -180,6 +181,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/chat/reasoning-details.ts` | [`data-planes/chat-reasoning-details.md`](data-planes/chat-reasoning-details.md) | | `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/data-planes/chat-reasoning-details.md b/structure/data-planes/chat-reasoning-details.md new file mode 100644 index 00000000000..a0743f29f1f --- /dev/null +++ b/structure/data-planes/chat-reasoning-details.md @@ -0,0 +1,32 @@ +# Anthropic Thinking Signatures on the Chat Wire (#7429) + +The one exception to the rule in [inbound-compat.md](inbound-compat.md) that opaque reasoning +state never crosses a Chat Completions boundary. + +A Chat client routed to Claude echoed `reasoning_content` only, so every tool step reached +Anthropic without the signed thinking block it requires and the adapter dropped the reasoning. +`src/chat/reasoning-details.ts` owns the carrier, OpenRouter's `reasoning_details` shape that +Hermes and pi already round-trip: `{type:"reasoning.text", text, signature, format: +"anthropic-claude-v1", index}` for a signed block and `{type:"reasoning.encrypted", data, …}` +for a `redacted_thinking` block, redacted entries first as the adapter replays them. + +Outbound, the bridge converter (`src/chat/outbound.ts`, stream and JSON) builds the entries from +each reasoning item's `ocxr1` envelope and the direct writer (`src/protocols/encoders/chat.ts`) +from `reasoningDone` and the optional `progressThinking` hook; the parity suite holds them +frame-equal. Only a value with Anthropic's signature spelling is emitted. A signature over text +the client never saw (hidden thinking: `txt` without `upd`, or the driver's `hidden` flag) is +withheld and only redacted entries go out. An Opus progress update (`txt` with `upd`) is +shown as `content` and also returned as a signed entry over that same text. + +Inbound, `chatCompletionsToResponsesBody` reads the entries only when the ingress sets +`acceptAnthropicReasoningSignatures`: the settled route is one `anthropic` adapter destination +that is first-party (`credentialDomainFor(...).firstPartyAnthropic`) and cannot change +credential domain (no combo, policy or credential rewrite). Every entry must be well formed or +the whole array is ignored and the plaintext projection above applies unchanged; a client that +sends no `reasoning_details` gets a byte-identical body. Accepted entries become reasoning +items carrying the same `ocxr1` envelope the Responses wire uses, so the serving-identity +strip, the rejection memo and thinking recovery all apply. A progress update's visible copy in +`content` is replayed as sent: no marker distinguishes it from answer text, so nothing is cut. +Validation is structural only: an edited `text` or a well-formed altered signature is forwarded +and Anthropic refuses it. No signature is computed, cached server side, or synthesized; the +chat-ir placeholder still never passes the adapter's signature check. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index c658d8881c7..121dd38b79a 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -482,7 +482,9 @@ projection as a `reasoning` input item emitted immediately before its assistant message, matching the parser's buffer-and-prepend adjacency. Only representable plaintext crosses: no signature, encrypted payload or provider item id is reconstructed, because those attest to content this proxy never received. Opaque -reasoning replay across a Chat boundary remains unimplemented by design. +reasoning replay across a Chat boundary has exactly one exception: Anthropic thinking +signatures carried in `reasoning_details` (#7429), documented in +[chat-reasoning-details.md](chat-reasoning-details.md). `presence_penalty` and `frequency_penalty` are carried too; per-model `noPenaltyModels` opt-outs still apply at the adapter. diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 6b682b88ded..16e940dd4ed 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -223,7 +223,7 @@ argument gating, the integral-float repair, every terminal with its usage and du the wire-silence heartbeat and stall watchdog, pull-based stepping, cancellation) and calls one `ClientWireWriter` method wherever the bridge would emit a frame a client converter reads. `chat.ts` (`encodeChatCompletionSse`, `foldChatCompletion`) and `messages.ts` -(`encodeAnthropicMessageSse`, `foldAnthropicMessage`) are the writers. They reuse the +(`encodeAnthropicMessageSse`, `foldAnthropicMessage`) are the writers; the Chat one also emits `reasoning_details` ([#7429](chat-reasoning-details.md)). They reuse the converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/outbound.ts` (ids, chunk and frame builders, usage mapping, `chatCompletionsStreamErrorPayload`, `chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`, diff --git a/structure/manifest.json b/structure/manifest.json index a41c7c38ee5..04535025bc3 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -404,6 +404,15 @@ "src/server/" ] }, + { + "path": "data-planes/chat-reasoning-details.md", + "tier": 3, + "title": "Anthropic Thinking Signatures on the Chat Wire", + "scope": "The reasoning_details carrier that lets Chat Completions clients round-trip signed Anthropic thinking across tool steps (#7429).", + "documents": [ + "src/chat/reasoning-details.ts" + ] + }, { "path": "data-planes/protocol-paths.md", "tier": 3, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e5304118cb2..0cecb8cffc5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -331,6 +331,7 @@ "responses-first-send-reservation.test.ts": "responses", "responses-first-send-spend-policy.test.ts": "responses", "responses-dispatch-receipt.test.ts": "responses", "responses-hosted-send-hop.test.ts": "responses", "responses-hosted-send-producer.test.ts": "responses", "responses-hosted-send-reservation.test.ts": "responses", "responses-hosted-send-round-parity.test.ts": "responses", "responses-hosted-send-unsent-hop.test.ts": "responses", "responses-hosted-send-ws.test.ts": "responses", "messages-native-oauth-eligibility.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", + "chat-anthropic-reasoning-details.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-media-translation.test.ts": "responses", "chat-native-developer-role.test.ts": "responses", "chat-native-reasoning-replay.test.ts": "responses", diff --git a/tests/responses/chat-anthropic-reasoning-details.test.ts b/tests/responses/chat-anthropic-reasoning-details.test.ts new file mode 100644 index 00000000000..e9958ce89c2 --- /dev/null +++ b/tests/responses/chat-anthropic-reasoning-details.test.ts @@ -0,0 +1,312 @@ +/** + * #7429: a Chat Completions client routed to Claude kept losing its reasoning on every tool step. + * + * The proxy now returns provider-issued thinking state as OpenRouter-style `reasoning_details` + * and, for a single first-party Anthropic route only, replays what the client echoes back as the + * signed thinking block Anthropic requires before a `tool_use`. These cases drive the real Chat + * ingress against a stubbed Messages endpoint, on the bridge converter and on the direct encoder, + * streaming and not. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; +import { collectChatCompletion } from "../../src/chat/outbound"; +import { resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +type Item = Record; +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +const MODEL = "claude-sonnet-4-5"; +/** Opus 5.5 thinks with `display: "updates"`: each signed block is shown to the client as content. */ +const PROGRESS_MODEL = "claude-opus-5-5"; +const FIRST_PARTY = "https://api.anthropic.com"; +const COMPATIBLE = "https://anthropic.example.test"; +const SIGNATURES = ["SyntheticStepOneSignature0000==", "SyntheticStepTwoSignature0000=="]; +const THINKING = ["Step one reasoning.", "Step two reasoning."]; +const REDACTED = "SyntheticRedactedThinkingPayload"; +const FORMAT = "anthropic-claude-v1"; +const TOOLS = [{ type: "function", function: { name: "lookup", parameters: { type: "object", properties: {} } } }]; +const USER = { role: "user", content: "Find it." }; +let home = ""; +let release: (() => void) | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-chat-reasoning-details-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); +}); +afterEach(() => { + try { release?.(); } finally { + release = undefined; + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } +}); + +/** The OAuth store knows Anthropic accounts by the canonical provider name. */ +const OAUTH_PROVIDER = "anthropic"; + +function config(baseUrl: string, direct: boolean, oauth = false): OcxConfig { + const name = oauth ? OAUTH_PROVIDER : "anth"; + return { + defaultProvider: name, emptyCompletionRetry: false, + providers: { [name]: { adapter: "anthropic", baseUrl, proxy: "direct", models: [MODEL, PROGRESS_MODEL], + ...(oauth ? { authMode: "oauth" } : { authMode: "key", apiKey: "synthetic-test-key" }) } }, + ...(direct ? { protocols: { rollout: { directEncoders: true } } } : {}), + } as OcxConfig; +} + +function frame(type: string, data: Item = {}): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; +} + +/** One assistant turn: optional redacted block, signed thinking, then a tool call (or text). */ +function upstreamTurn(step: number, stream: boolean, options: { redacted?: boolean; tool?: boolean } = {}): Response { + const tool = options.tool !== false; + const blocks: Item[] = [ + ...(options.redacted ? [{ type: "redacted_thinking", data: REDACTED }] : []), + { type: "thinking", thinking: THINKING[step] ?? "Final reasoning.", signature: SIGNATURES[step] ?? "SyntheticFinalSignature00000==" }, + tool ? { type: "tool_use", id: `toolu_${step + 1}`, name: "lookup", input: {} } : { type: "text", text: "Done." }, + ]; + const stopReason = tool ? "tool_use" : "end_turn"; + if (!stream) { + return Response.json({ id: `msg_${step}`, type: "message", role: "assistant", model: MODEL, content: blocks, + stop_reason: stopReason, usage: { input_tokens: 2, output_tokens: 3 } }); + } + const frames = [frame("message_start", { message: { id: `msg_${step}`, role: "assistant", content: [], usage: { input_tokens: 2 } } })]; + blocks.forEach((block, index) => { + if (block.type === "redacted_thinking") { + frames.push(frame("content_block_start", { index, content_block: block })); + } else if (block.type === "thinking") { + frames.push( + frame("content_block_start", { index, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index, delta: { type: "thinking_delta", thinking: block.thinking } }), + frame("content_block_delta", { index, delta: { type: "signature_delta", signature: block.signature } }), + ); + } else if (block.type === "tool_use") { + frames.push( + frame("content_block_start", { index, content_block: { ...block, input: {} } }), + frame("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: "{}" } }), + ); + } else { + frames.push( + frame("content_block_start", { index, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index, delta: { type: "text_delta", text: block.text } }), + ); + } + frames.push(frame("content_block_stop", { index })); + }); + frames.push(frame("message_delta", { delta: { stop_reason: stopReason }, usage: { output_tokens: 3 } }), frame("message_stop")); + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); +} + +/** + * A Messages endpoint that, like Anthropic with thinking on, refuses a tool-use continuation whose + * latest assistant turn does not open with a signed or redacted thinking block. + */ +function transport(baseUrl: string, enforce: boolean, answer: (step: number, stream: boolean) => Response) { + const bodies: Item[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== `${baseUrl}/v1/messages`) throw new Error(`Unexpected network target blocked: ${url}`); + const body = JSON.parse(String(init?.body)) as Item; + bodies.push(body); + const messages = body.messages as { role: string; content: Item[] | string }[]; + const lastAssistant = [...messages].reverse().find(message => message.role === "assistant"); + if (enforce && lastAssistant && Array.isArray(lastAssistant.content)) { + const first = lastAssistant.content[0]; + const signed = first?.type === "redacted_thinking" + || (first?.type === "thinking" && typeof first.signature === "string" && SIGNATURES.includes(first.signature)); + if (!signed) { + return Response.json({ type: "error", error: { type: "invalid_request_error", + message: "messages: Expected `thinking` or `redacted_thinking`, but found `tool_use`." } }, { status: 400 }); + } + } + return answer(bodies.length - 1, body.stream === true); + }) as typeof fetch; + return bodies; +} + +async function chat(cfg: OcxConfig, messages: unknown[], stream: boolean, model = MODEL, provider = "anth"): Promise { + const request = new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `${provider}/${model}`, messages, tools: TOOLS, reasoning_effort: "high", stream }), + }); + const response = await handleChatCompletions(request, cfg, { model: "", provider: "" }); + expect(response.status).toBe(200); + const completion = stream + ? await collectChatCompletion(response.body!, MODEL, createTestTranslatorBudget()) + : await response.json() as Item; + const message = ((completion.choices as Item[])[0]!.message) as Item; + // What a Chat client stores and echoes back. + return { + role: "assistant", content: message.content ?? null, + ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}), + ...(message.reasoning_content ? { reasoning_content: message.reasoning_content } : {}), + ...(message.reasoning_details ? { reasoning_details: message.reasoning_details } : {}), + }; +} + +function toolResult(assistant: Item): Item { + const call = (assistant.tool_calls as { id: string }[])[0]!; + return { role: "tool", tool_call_id: call.id, content: "result" }; +} + +function assistantTurns(body: Item): Item[][] { + return (body.messages as { role: string; content: Item[] }[]) + .filter(message => message.role === "assistant").map(message => message.content); +} + +const LANES = [ + { name: "bridge", direct: false, stream: false }, + { name: "bridge stream", direct: false, stream: true }, + { name: "direct encoder", direct: true, stream: false }, + { name: "direct encoder stream", direct: true, stream: true }, +]; + +describe("#7429 Chat reasoning_details carry Anthropic thinking across tool steps", () => { + for (const lane of LANES) { + for (const model of [MODEL, PROGRESS_MODEL]) { + const progress = model === PROGRESS_MODEL; + test(`${lane.name} ${model}: signatures survive a multi-step tool loop and Anthropic accepts them`, async () => { + const cfg = config(FIRST_PARTY, lane.direct); + const bodies = transport(FIRST_PARTY, true, (step, stream) => upstreamTurn(step, stream, { tool: step < 2 })); + const first = await chat(cfg, [USER], lane.stream, model); + if (progress) { + // The update is shown as content; the same text is what the signature covers. + expect(first.content).toBe(THINKING[0]); + expect(first.reasoning_content).toBeUndefined(); + } else { + expect(first.reasoning_content).toBe(THINKING[0]); + } + expect(first.reasoning_details).toEqual([ + { type: "reasoning.text", text: THINKING[0], signature: SIGNATURES[0], format: FORMAT, index: 0 }, + ]); + const second = await chat(cfg, [USER, first, toolResult(first)], lane.stream, model); + expect((second.reasoning_details as Item[])[0]!.signature).toBe(SIGNATURES[1]); + const final = await chat(cfg, [USER, first, toolResult(first), second, toolResult(second)], lane.stream, model); + expect(final.content).toBe(progress ? "Final reasoning.Done." : "Done."); + + expect(bodies).toHaveLength(3); + if (progress) expect(bodies[2]!.thinking).toMatchObject({ display: "updates" }); + const turns = assistantTurns(bodies[2]!); + expect(turns).toHaveLength(2); + turns.forEach((content, step) => { + // The signed block first. A progress update's visible copy stays in the text the + // client sent: nothing on the wire tells it from answer text, so nothing is removed. + expect(content).toEqual([ + { type: "thinking", thinking: THINKING[step], signature: SIGNATURES[step] }, + ...(progress ? [{ type: "text", text: THINKING[step] }] : []), + expect.objectContaining({ type: "tool_use", id: `toolu_${step + 1}` }), + ]); + }); + }); + } + + test(`${lane.name}: redacted thinking round-trips through reasoning.encrypted`, async () => { + const cfg = config(FIRST_PARTY, lane.direct); + const bodies = transport(FIRST_PARTY, true, (step, stream) => upstreamTurn(step, stream, { redacted: step === 0, tool: step === 0 })); + const first = await chat(cfg, [USER], lane.stream); + expect(first.reasoning_details).toEqual([ + { type: "reasoning.encrypted", data: REDACTED, format: FORMAT, index: 0 }, + { type: "reasoning.text", text: THINKING[0], signature: SIGNATURES[0], format: FORMAT, index: 1 }, + ]); + await chat(cfg, [USER, first, toolResult(first)], lane.stream); + expect(assistantTurns(bodies[1]!)[0]!.slice(0, 2)).toEqual([ + { type: "redacted_thinking", data: REDACTED }, + { type: "thinking", thinking: THINKING[0], signature: SIGNATURES[0] }, + ]); + }); + } + + // A Claude subscription reaches the same first-party host with a pooled OAuth account. + test("pooled OAuth route: signatures survive a multi-step tool loop", async () => { + await saveCredential(OAUTH_PROVIDER, { access: "synthetic-oauth-access", refresh: "synthetic-oauth-refresh", + expires: Date.now() + 3_600_000, accountId: "synthetic-oauth-account", source: "oauth" }); + await setActiveAccount(OAUTH_PROVIDER, getAccountSet(OAUTH_PROVIDER)!.accounts[0]!.id); + const cfg = config(FIRST_PARTY, false, true); + const bodies = transport(FIRST_PARTY, true, (step, stream) => upstreamTurn(step, stream, { tool: step < 2 })); + const first = await chat(cfg, [USER], true, MODEL, OAUTH_PROVIDER); + expect((first.reasoning_details as Item[])[0]!.signature).toBe(SIGNATURES[0]); + const second = await chat(cfg, [USER, first, toolResult(first)], true, MODEL, OAUTH_PROVIDER); + const final = await chat(cfg, [USER, first, toolResult(first), second, toolResult(second)], true, MODEL, OAUTH_PROVIDER); + expect(final.content).toBe("Done."); + expect(bodies).toHaveLength(3); + assistantTurns(bodies[2]!).forEach((content, step) => { + expect(content[0]).toEqual({ type: "thinking", thinking: THINKING[step], signature: SIGNATURES[step] }); + }); + }); + + test("a non-first-party Anthropic destination never receives the echoed signature", async () => { + const cfg = config(COMPATIBLE, false); + const bodies = transport(COMPATIBLE, false, (step, stream) => upstreamTurn(step, stream, { redacted: step === 0 })); + const first = await chat(cfg, [USER], false); + // The response still offers the state; the gate is on what is accepted back. + expect(first.reasoning_details).toHaveLength(2); + await chat(cfg, [USER, first, toolResult(first)], false); + const { reasoning_details: _dropped, ...plain } = first; + await chat(cfg, [USER, plain, toolResult(first)], false); + const sent = JSON.stringify(bodies[1]); + expect(sent).not.toContain(SIGNATURES[0]); + expect(sent).not.toContain(REDACTED); + // Exactly today's request: as if the client had sent reasoning_content alone. + expect(bodies[1]!.messages).toEqual(bodies[2]!.messages); + }); + + test("a missing or invalid signature keeps today's plaintext fallback", async () => { + const cfg = config(FIRST_PARTY, false); + const bodies = transport(FIRST_PARTY, false, (step, stream) => upstreamTurn(step, stream)); + const first = await chat(cfg, [USER], false); + const { reasoning_details: _dropped, ...plain } = first; + const variants: Item[] = [ + plain, + { ...plain, reasoning_details: [{ type: "reasoning.text", text: THINKING[0], format: FORMAT, index: 0 }] }, + { ...plain, reasoning_details: [{ type: "reasoning.text", text: THINKING[0], signature: "rs_not_a_signature", format: FORMAT }] }, + { ...plain, reasoning_details: [{ type: "reasoning.text", text: THINKING[0], signature: SIGNATURES[0], format: "openai-responses-v1" }] }, + { ...plain, reasoning_details: [...(first.reasoning_details as Item[]), { type: "reasoning.summary", summary: "x" }] }, + ]; + for (const assistant of variants) await chat(cfg, [USER, assistant, toolResult(first)], false); + const replays = bodies.slice(1).map(body => body.messages); + for (const messages of replays) { + expect(messages).toEqual(replays[0]!); + expect(JSON.stringify(messages)).not.toContain(SIGNATURES[0]); + } + }); + + test("without reasoning_details the projection is byte for byte unchanged", () => { + const messages = [USER, { role: "assistant", content: null, reasoning_content: "prior", tool_calls: [ + { id: "toolu_1", type: "function", function: { name: "lookup", arguments: "{}" } }, + ] }, { role: "tool", tool_call_id: "toolu_1", content: "result" }]; + const body = { model: "m", messages, tools: TOOLS }; + expect(JSON.stringify(chatCompletionsToResponsesBody(body, { acceptAnthropicReasoningSignatures: true }))) + .toBe(JSON.stringify(chatCompletionsToResponsesBody(body))); + }); + + test("answer text that begins with a signed thought is replayed intact", () => { + const messages = [USER, { role: "assistant", content: "Same answer", reasoning_details: [ + { type: "reasoning.text", text: "Same", signature: SIGNATURES[0], format: FORMAT, index: 0 }, + ] }]; + const input = chatCompletionsToResponsesBody({ model: "m", messages }, { acceptAnthropicReasoningSignatures: true }).input as Item[]; + expect(input.at(-1)).toEqual({ type: "message", role: "assistant", content: [{ type: "output_text", text: "Same answer" }] }); + }); +}); + + diff --git a/tests/responses/protocol-direct-encoders-chat.test.ts b/tests/responses/protocol-direct-encoders-chat.test.ts index 029ae974e03..ff81a2e50ad 100644 --- a/tests/responses/protocol-direct-encoders-chat.test.ts +++ b/tests/responses/protocol-direct-encoders-chat.test.ts @@ -192,6 +192,56 @@ const SCENARIOS: Record