diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 761862d2ea3..57cef595cb0 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -1,6 +1,6 @@ --- -title: ChatGPT Desktop app-server shim (experimental) -description: An opt-in macOS experiment that rewrites plain-quota gate fields on the bundled app-server stdout pipe. +title: ChatGPT Desktop integrations (experimental) +description: Opt-in macOS app-server shim and local-CA TLS intercept experiments for plain-quota send gates. --- This experiment is **macOS only and off by default**. It filters the bundled ChatGPT @@ -91,7 +91,7 @@ folders and trusted sticky folders retain their normal permissions behavior. These are ownership, POSIX-permission, and signature checks; native ACL and volume ownership-policy behavior has not been verified. -This integration installs no certificate, network listener, PAC, or background +The app-server shim alone installs no certificate, network listener, PAC, or background watcher. It does not log the app's messages or environment. Status reports whether the running ChatGPT bundle process carries the expected launcher override. @@ -122,3 +122,97 @@ This standalone shim does not rewrite conversation metadata or route model calls Other app gates or upstream refusals can still prevent sending. Evidence reported on an exhausted Plus account also used an intercept, so it does not establish that this shim alone resolves every desktop send lock. + +## Local-CA TLS intercept (experimental candidate) + +The separate `chatgptDesktop.unblockSend` experiment terminates TLS for the +`chatgpt.com` apex host on loopback, relays the account's cookies and credentials, +and rewrites known quota send gates in conversation metadata and usage responses. +It is **off by default**, macOS only, and independent of `appServerShim`: + +```json +{ + "chatgptDesktop": { + "unblockSend": true, + "port": 10300 + } +} +``` + +`port` is optional; its default is the running proxy's public port plus 200 +(`10100` → `10300`). A derived port outside the TCP range requires an explicit +free port. Client-role processes do not start the intercept. A bind or certificate +failure warns without stopping the proxy's other services. + +Start OpenCodex with this config, then run `ocx chatgpt status`. The listener +creates or reuses the local authority shared with the Claude intercept. **You +must trust this CA yourself** in the macOS login keychain before launching the +intercepted app. Status prints the exact command; with the default config path: + +```bash +security add-trusted-cert -r trustRoot -p ssl -k "$HOME/Library/Keychains/login.keychain-db" "$HOME/.opencodex/claude-intercept/ca.pem" +ocx chatgpt launch +ocx chatgpt status +``` + +Use the certificate path reported by status if your OpenCodex home differs. The +CLI prints the trust command and never runs it. Trusting a local CA changes the +login keychain's TLS trust: anyone controlling its private key can issue trusted +certificates. The listener sees the decrypted account traffic, including cookies, +authorization headers and message content that it relays. Protect the config +directory and CA key. The relay does not log request bodies or credentials. +`restore` removes launch overrides; it does **not** remove CA trust or delete the +shared authority. Remove trust manually through Keychain Access when you no +longer need it, accounting for other integrations using the same authority. + +Launch restarts ChatGPT with +`--host-resolver-rules=MAP chatgpt.com 127.0.0.1:`. Explicit system HTTP/SOCKS +proxies get an apex-host bypass while other hosts retain the proxy with a direct +fallback; TUN/direct networking needs only the resolver rule. An existing system +PAC cannot be combined with that bypass, so it may prevent the intercept from +seeing traffic. This candidate creates no PAC file or CONNECT entry proxy. + +If both flags are true, `ocx chatgpt launch` applies the existing app-server shim +and the intercept together. The shim alone still works without a running proxy. +The intercept requires OpenCodex's identity-confirmed listener. When OpenCodex +stops, an app still carrying the resolver rule cannot reach `chatgpt.com`; run +`ocx chatgpt restore` to relaunch with native networking. + +### Intercept rewrite boundary + +Only `/backend-api/conversation/init`, `/backend-api/conversation` and +`/backend-api/f/conversation` (including child paths), plus the exact +`/backend-api/wham/usage` and `/backend-api/wham/usage/stream` paths are rewritten. +Conversation metadata loses known quota `send` / `tpp_send` blocks and exhausted +send progress entries. Unknown and subscription/policy/workspace reasons remain; +status reports preserved reasons. Usage rewriting reuses the shim's gate helpers, +keeping workspace, credit and spend-control gates and usage display intact. Other +HTTP responses pass through; WebSocket upgrades, voice and dictation relay +without rewriting through direct, HTTP CONNECT or shared SOCKS5 transport. + +### Optional intercept launch watcher + +```bash +ocx chatgpt install-watcher --yes +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +Installation without `--yes` asks in an interactive terminal. The launchd agent +watches the app's Electron `SingletonLock` and the `chatgpt-unblock.ready` marker. In watch +mode, it restarts an app launched without intercept switches only while the listener answers as +OpenCodex and the app is no more than five minutes old; a missing or unparseable process age +counts as fresh, and explicit `ocx chatgpt launch` is not age-limited. This can interrupt +startup work; it does nothing while the listener is unavailable. It manages +**intercept launches only**; use explicit launch for the app-server shim. A loaded +watcher must be uninstalled before `restore`, so it cannot put the switches back. + +### Evidence and decision limits + +[#6196](https://github.com/lidge-jun/opencodex/issues/6196) reported zero established +listener connections over about 20 hours on current Desktop builds: the bundled +app-server performs the gate reads and may bypass Chromium's resolver rule. +The later exhausted-Plus-account report used the shim, intercept and restart +together and does not isolate the intercept's effectiveness. This candidate +conflicts with the maintainer's provider-aware admission design, which rejects +local CA installation and quota-data rewriting; maintainers may close it. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 807444659c2..c8956658467 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -23,6 +23,17 @@ "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", "cli-combo-partial-update.test.ts": "cli", + "desktop-unblock-ws-frame.test.ts": "clients", + "desktop-unblock-watcher-install.test.ts": "clients", + "desktop-unblock-ws-relay.test.ts": "clients", + "desktop-unblock-ws-upstream.test.ts": "clients", + "desktop-unblock-runtime.test.ts": "clients", + "desktop-unblock-listener.test.ts": "clients", + "desktop-unblock-launch-script.test.ts": "clients", + "desktop-unblock-config-boundary.test.ts": "clients", + "desktop-unblock-ca-trust.test.ts": "clients", + "desktop-rewrite.test.ts": "clients", + "socks5-handshake.test.ts": "lib", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 817a98fbc03..8b209cc8d8b 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -532,13 +532,13 @@ Each of these writes. Check the flags column before running one unattended. ### `ocx chatgpt` -Experimental ChatGPT app-server shim: launch, restore or status (macOS only). +Experimental ChatGPT shim/intercept: launch, restore, status and watcher management (macOS only). Drives no management route. JSON mode: `none`. -- Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher. +- Default off; launch requires chatgptDesktop.appServerShim or unblockSend. Intercept needs the running proxy and manual CA trust. Watcher manages intercept launches only; restore removes the shim launcher. ### `ocx link issue` diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts index 4864a7bece3..7cf13393fb6 100644 --- a/src/chatgpt/app-server-shim/gate-rewrite.ts +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -1,7 +1,7 @@ /** Plain-quota gate rewriting; usage display and non-quota restrictions are preserved. */ const PLAIN_QUOTA_REACHED_TYPE = "rate_limit_reached"; -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/chatgpt/desktop-unblock/ca-trust.ts b/src/chatgpt/desktop-unblock/ca-trust.ts new file mode 100644 index 00000000000..a85d5187d0f --- /dev/null +++ b/src/chatgpt/desktop-unblock/ca-trust.ts @@ -0,0 +1,51 @@ +import { X509Certificate } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultSecurityRunner, loginKeychainPath, type SecurityRunner } from "../../claude/intercept/picker-trust"; + +/** + * Whether macOS trusts the intercept CA the ChatGPT listener's leaf is issued from. + * + * Without that trust every request the app sends to chatgpt.com fails certificate + * verification, which the app does not report: account, usage and settings pages just stay + * empty. `ocx chatgpt status` surfaces this state so the cause is visible. Trust is matched by + * the CA's SHA-1 fingerprint in the user's exported trust settings, so a different or + * regenerated certificate with the same name never counts. + */ + +export type ChatgptCaTrust = "trusted" | "untrusted" | "missing" | "unknown" | "unsupported"; + +export function certificateSha1(pem: string): string { + return new X509Certificate(pem).fingerprint.replace(/:/g, "").toUpperCase(); +} + +export async function inspectChatgptCaTrust( + caPath: string, + run: SecurityRunner = defaultSecurityRunner, + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform !== "darwin") return "unsupported"; + if (!existsSync(caPath)) return "missing"; + let dir: string | undefined; + try { + const sha1 = certificateSha1(readFileSync(caPath, "utf8")); + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-trust-")); + const file = join(dir, "trust-settings.plist"); + const exported = await run(["trust-settings-export", file]); + if (exported.code !== 0) { + // A user domain with no trust settings at all cannot be exported; that is plain "untrusted". + return /no trust settings/i.test(`${exported.stdout}${exported.stderr}`) ? "untrusted" : "unknown"; + } + return readFileSync(file, "utf8").includes(`${sha1}`) ? "trusted" : "untrusted"; + } catch { // no-excuse-ok: catch -- unreadable certificate or trust settings are no evidence of trust. + return "unknown"; + } finally { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +} + +/** The command that restores trust; it prompts for the login password, so only the user runs it. */ +export function chatgptCaTrustCommand(caPath: string): string { + return `security add-trusted-cert -r trustRoot -p ssl -k "${loginKeychainPath()}" "${caPath}"`; +} diff --git a/src/chatgpt/desktop-unblock/launch-watcher.ts b/src/chatgpt/desktop-unblock/launch-watcher.ts new file mode 100644 index 00000000000..ade6f333d52 --- /dev/null +++ b/src/chatgpt/desktop-unblock/launch-watcher.ts @@ -0,0 +1,580 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { connect as connectSocket } from "node:net"; +import { connect as connectTls } from "node:tls"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { CHATGPT_INTERCEPT_HOST, CHATGPT_UNBLOCK_IDENTITY_PATH, CHATGPT_UNBLOCK_SERVICE_ID } from "./listener"; +import type { PreservedSendBlock } from "./rewrite"; +import { chatgptUnblockReadyPath, chatgptUnblockResolverArg } from "./runtime"; +import { chatgptShimLauncherPath } from "../app-server-shim/launcher"; + +/** + * Launch integration for the ChatGPT desktop send-unblock intercept. + * + * The launch switches only apply when the app is launched with them, so a normal + * Dock/Spotlight start reaches the real chatgpt.com and the composer locks again. This module + * installs a launchd agent that watches the app's Electron `SingletonLock` -- written on every + * launch -- and, exactly once per launch, restarts the app with the switches if it was + * started without them. It also watches a readiness marker opencodex writes once its listener is + * up, so an app that started before opencodex (both open at login) is corrected as soon as the + * listener answers. There is no resident polling process: launchd wakes the script on either + * event and the script exits after one check. In watch mode it only restarts an app that started + * within the last five minutes, so it never quits an app the user has been working in. + * + * The watcher only acts when the opencodex intercept listener answers its identity path, so + * with the feature off -- or another process holding the port -- the app is left native. + * + * The watcher, `ocx chatgpt launch` and `ocx chatgpt restore` run the same script, so the launch + * arguments are built in exactly one place. The mode decides the switch set: + * + * resolver-rule mode (default): + * - always `--host-resolver-rules=MAP chatgpt.com 127.0.0.1:`; + * - with an HTTP(S)/SOCKS system proxy (a VPN in system-proxy mode), also + * `--proxy-server=,direct://` and `--proxy-bypass-list=chatgpt.com`. Chromium hands + * proxied hosts to the proxy unresolved, which would skip the resolver rule, so the apex host + * must bypass the proxy. The bypass list only takes effect beside an explicit proxy server, + * and a bare hostname there matches that host exactly (subdomains stay on the proxy). The + * `direct://` fallback keeps the app working if the VPN is switched off after launch; + * - with no proxy, or TUN mode (loopback never enters the tunnel), the resolver rule alone; + * - with a PAC file, the resolver rule alone: PAC cannot be combined with a bypass, so + * chatgpt.com may stay on the proxy and the composer may lock, but nothing else breaks. + * + * PAC (regenerated at every opencodex start) sends chatgpt.com to the CONNECT entry listener, + * which splices onto the TLS origin listener; when opencodex is down the refused CONNECT makes + * Chromium fall through to the captured system chain and finally DIRECT -- no resolver rule + * may be present, or it would blackhole that fallback to the dead origin port. No app restart + * is needed to recover. + */ + +export const CHATGPT_APP_PATH = "/Applications/ChatGPT.app"; +/** The desktop app is `openai-codex-electron` internally: its Electron userData dir is `Codex`. */ +export const CHATGPT_SINGLETON_LOCK_PATH = "Library/Application Support/Codex/SingletonLock"; + +/** How recently the app must have started for watch mode to restart it. */ +export const CHATGPT_WATCHER_FRESH_APP_SECONDS = 300; +export const CHATGPT_UNBLOCK_WATCHER_LABEL = "com.opencodex.chatgpt-unblock-watcher"; + +function expandHome(path: string): string { + return path.startsWith("~") ? join(homedir(), path.slice(1)) : path; +} + +/** A value for a single-quoted bash word: `'` becomes `'\''`. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** A value for a plist `` element. */ +function xmlEscape(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +export interface ChatgptUnblockWatcherPaths { + scriptPath: string; + plistPath: string; + errPath: string; + lockPath: string; + /** Written by opencodex once its listener is up; the agent wakes on it too. */ + readyPath: string; +} + +export function chatgptUnblockWatcherPaths(configDir?: string): ChatgptUnblockWatcherPaths { + const dir = configDir ?? getConfigDir(); + return { + scriptPath: join(dir, "chatgpt-unblock-watcher.sh"), + plistPath: expandHome(`~/Library/LaunchAgents/${CHATGPT_UNBLOCK_WATCHER_LABEL}.plist`), + errPath: join(dir, "chatgpt-unblock-watcher.err"), + lockPath: expandHome(`~/${CHATGPT_SINGLETON_LOCK_PATH}`), + readyPath: chatgptUnblockReadyPath(dir), + }; +} + +/** Where the launch script records what it did; kept beside the rest of the opencodex state. */ +function chatgptUnblockWatcherLogPath(configDir?: string): string { + return join(configDir ?? getConfigDir(), "chatgpt-unblock-watcher.log"); +} + +/** + * The launch script. + * watch (launchd, on every app launch) only corrects a running app that lacks the switches; + * launch (`ocx chatgpt launch`) also starts the app when it is not running; + * native (`ocx chatgpt restore`) restarts a switched app WITHOUT them, for when the + * listener is gone or the feature is being turned off. + * + * `pacMode` switches the argument set (see the module doc): the PAC switch alone when on, the + * resolver rule [+ proxy/bypass] otherwise. The app is "flagged" by whichever switch the mode + * uses, so a mode change makes the watcher correct an app launched under the other mode. + */ +export function buildChatgptUnblockWatcherScript(port: number, configDir?: string, shimMode = false): string { + return `#!/bin/bash +# opencodex ChatGPT send-unblock launcher. +# watch (launchd, fired by the app's Electron SingletonLock on every launch and by the +# readiness marker opencodex writes once its listener is up): if the app is running +# WITHOUT the launch switches (a normal Dock/Spotlight launch) and started within the +# last FRESH_APP_SECONDS, restart it once with them. A correctly launched app, an app +# the user has been working in, or an absent intercept, is left alone. +# launch (ocx chatgpt launch): same, and start the app if it is not running. +# native (ocx chatgpt restore): restart an app that carries the switches without them. + +PORT=${port} +MODE="\${1:-watch}" +unset CODEX_CLI_PATH +RESOLVER_ARG=${shellQuote(chatgptUnblockResolverArg(port))} +SHIM_MODE=${shimMode ? "1" : "0"} +SHIM_SCRIPT=${shellQuote(chatgptShimLauncherPath(configDir ?? getConfigDir()))} +BYPASS_HOST=${shellQuote(CHATGPT_INTERCEPT_HOST)} +APP_PATTERN='ChatGPT.app/Contents/MacOS/ChatGPT' +IDENTITY_URL=${shellQuote(`https://127.0.0.1:${port}${CHATGPT_UNBLOCK_IDENTITY_PATH}`)} +SERVICE_ID=${shellQuote(`"service":"${CHATGPT_UNBLOCK_SERVICE_ID}"`)} +LOG=${shellQuote(chatgptUnblockWatcherLogPath(configDir))} +LOCK_DIR="\${TMPDIR:-/tmp}/opencodex-chatgpt-launch.lock" +FRESH_APP_SECONDS=${CHATGPT_WATCHER_FRESH_APP_SECONDS} + +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } +say() { [ "$MODE" != watch ] && echo "$*"; log "$*"; } +# The app-server shim is chosen through the app's CODEX_CLI_PATH environment variable, which 'ps eww' +# shows for the app's own process as soon as it exists (a child process would only appear later). +app_has_shim() { + ps eww -o command= -p "$1" 2>/dev/null | grep -qF "CODEX_CLI_PATH=$SHIM_SCRIPT" +} +# The app's main process: found by exact process name, then confirmed by path. Matching the +# whole command line instead would also match any shell whose command mentions the rule. +# -a: pgrep skips its own ancestors by default, and a command run from a terminal inside the +# desktop app has the app as an ancestor. +app_pid() { + local pid + for pid in $(pgrep -a -x ChatGPT 2>/dev/null); do + case "$(ps -o command= -p "$pid" 2>/dev/null)" in + *"$APP_PATTERN"*) echo "$pid"; return 0 ;; + esac + done + return 1 +} +app_running() { app_pid >/dev/null; } +# Seconds a process has been running, from ps's [[dd-]hh:]mm:ss elapsed time. Fails when it +# cannot be read, which the caller treats as a fresh launch. +app_age() { + local etime days=0 h=0 m s a b c + etime=$(ps -o etime= -p "$1" 2>/dev/null | tr -d ' ') + [ -n "$etime" ] || return 1 + case "$etime" in *-*) days=\${etime%%-*}; etime=\${etime#*-} ;; esac + IFS=: read -r a b c <<< "$etime" + if [ -n "$c" ]; then h=$a; m=$b; s=$c; else m=$a; s=$b; fi + case "$days:$h:$m:$s" in *::*|:*|*:|*[!0-9:]*) return 1 ;; esac + echo $(( 10#$days * 86400 + 10#$h * 3600 + 10#$m * 60 + 10#$s )) +} +app_flagged() { + local pid cmdline + pid=$(app_pid) || return 1 + cmdline=$(ps -o command= -p "$pid" 2>/dev/null) + case "$cmdline" in *" $RESOLVER_ARG"*) return 0 ;; esac + return 1 +} + +# The shim is wanted only while its launcher exists: opencodex writes it after the bundle passes +# the OpenAI signature check. Pointing the app at a missing launcher would leave it without an +# app-server. +shim_wanted() { + [ "$SHIM_MODE" = 1 ] && [ -x "$SHIM_SCRIPT" ] +} + +# Either launch switch: restore must also undo the switch an app was launched with, and an app +# started through the shim counts too, so restore can hand it back native. +app_switched() { + local pid + pid=$(app_pid) || return 1 + local cmdline + cmdline=$(ps -o command= -p "$pid" 2>/dev/null) + case "$cmdline" in *" $RESOLVER_ARG"*) return 0 ;; esac + app_has_shim "$pid" && return 0 + return 1 +} +# The port must be held by opencodex's listener, not just by any process. The listener answers +# its identity path itself; -k because its certificate names chatgpt.com, --noproxy because a +# proxy in the environment must not be asked to reach loopback. +listener_ours() { + curl -sk --noproxy '*' --max-time 3 "$IDENTITY_URL" 2>/dev/null | grep -qF "$SERVICE_ID" +} +# \`open\` on a running app only activates it and drops the arguments, so the old instance must +# be fully gone first. Quit is re-sent because the app can ignore it while starting up. +quit_app() { + for attempt in 1 2 3; do + osascript -e 'quit app "ChatGPT"' >/dev/null 2>&1 + for _ in $(seq 1 20); do + app_running || return 0 + sleep 0.25 + done + done + ! app_running +} + +# Extra switches for the current system proxy, one per line (none without a proxy). Only in +# resolver-rule mode: the PAC carries the system chain itself. +proxy_args() { + local out + out=$(scutil --proxy 2>/dev/null) || return 0 + val() { printf '%s\\n' "$out" | awk -v k="$1" '$1 == k { print $3; exit }'; } + [ "$(val ProxyAutoConfigEnable)" = 1 ] && return 0 + local scheme host port + if [ "$(val HTTPSEnable)" = 1 ]; then scheme=http; host=$(val HTTPSProxy); port=$(val HTTPSPort) + elif [ "$(val HTTPEnable)" = 1 ]; then scheme=http; host=$(val HTTPProxy); port=$(val HTTPPort) + elif [ "$(val SOCKSEnable)" = 1 ]; then scheme=socks5; host=$(val SOCKSProxy); port=$(val SOCKSPort) + else return 0 + fi + [ -n "$host" ] && [ -n "$port" ] || return 0 + printf '%s\\n' "--proxy-server=$scheme://$host:$port,direct://" "--proxy-bypass-list=$BYPASS_HOST" +} + +if [ "$MODE" != native ] && ! listener_ours; then + [ "$MODE" = launch ] && { echo "opencodex's ChatGPT listener is not answering on port $PORT; start opencodex first" >&2; exit 1; } + exit 0 +fi +# One run at a time: quitting the app deletes the SingletonLock, which fires launchd again. +# A lock left by a killed run expires after two minutes. +find "$LOCK_DIR" -maxdepth 0 -mmin +2 -exec rmdir {} \\; 2>/dev/null +mkdir "$LOCK_DIR" 2>/dev/null || { + [ "$MODE" = launch ] && { echo "another ChatGPT launch is in progress; retry shortly" >&2; exit 1; } + exit 0 +} +trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT + +if [ "$MODE" = native ]; then + if ! app_running; then say "ChatGPT is not running"; exit 0; fi + if ! app_switched; then say "ChatGPT is already running without the launch switches"; exit 0; fi + say "ChatGPT carries the launch switches; restarting it without" + quit_app || { say "ChatGPT did not quit; quit it manually and reopen it"; exit 1; } + open -a ChatGPT || { say "could not relaunch ChatGPT with native networking"; exit 1; } + say "relaunched ChatGPT with native networking" + exit 0 +fi + +if app_running; then + if app_flagged; then + if shim_wanted && ! app_has_shim "$(app_pid)"; then + : # started without the shim; fall through and correct it below + else + say "ChatGPT is already running with the launch switches" + exit 0 + fi + fi + # The readiness marker fires this while the app may have been open for hours (opencodex + # restarted under it). Only an app that just started is restarted; the explicit launch command + # always acts. + if [ "$MODE" = watch ] && age=$(app_age "$(app_pid)") && [ "$age" -gt "$FRESH_APP_SECONDS" ]; then + log "ChatGPT has been running for \${age}s without the launch switches; leaving it (run 'ocx chatgpt launch' to restart it with them)" + exit 0 + fi + say "ChatGPT is running without the launch switches; restarting it" + quit_app || { say "ChatGPT did not quit; leaving it running without the switches"; exit 1; } +elif [ "$MODE" != launch ]; then + exit 0 +fi + +ARGS=("$RESOLVER_ARG") +while IFS= read -r arg; do + [ -n "$arg" ] && ARGS+=("$arg") +done < <(proxy_args) +OPEN_ENV=() +if [ "$SHIM_MODE" = 1 ]; then OPEN_ENV=(--env "CODEX_CLI_PATH=$SHIM_SCRIPT"); fi +open -a ChatGPT \${OPEN_ENV[@]+"\${OPEN_ENV[@]}"} --args "\${ARGS[@]}" || { say "could not launch ChatGPT"; exit 1; } +say "launched ChatGPT with: \${ARGS[*]}" +`; +} + +/** `bash -n` on a script file: parses without running it. */ +export function checkChatgptWatcherScriptSyntax(scriptPath: string): CommandResult { + return sh("/bin/bash", ["-n", scriptPath]); +} + +/** One-shot launchd agent: wake on the app's SingletonLock or the readiness marker, run the script, exit. */ +export function buildChatgptUnblockWatcherPlist(scriptPath: string, watchPaths: string | readonly string[], errPath: string): string { + const watched = (typeof watchPaths === "string" ? [watchPaths] : watchPaths).map(path => ` ${xmlEscape(path)}`).join("\n"); + return ` + + + + Label + ${CHATGPT_UNBLOCK_WATCHER_LABEL} + ProgramArguments + + /bin/bash + ${xmlEscape(scriptPath)} + watch + + WatchPaths + +${watched} + + StandardErrorPath + ${xmlEscape(errPath)} + + +`; +} + +interface CommandResult { + ok: boolean; + status: number | null; + output: string; +} + +function sh(command: string, args: string[]): CommandResult { + try { + const output = execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + return { ok: true, status: 0, output }; + } catch (error) { + const err = error as { status?: number | null; stdout?: string; stderr?: string }; + return { ok: false, status: err.status ?? null, output: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() }; + } +} + +/** Runs `launchctl `; injectable so install/uninstall can be tested without launchd. */ +export type LaunchctlRunner = (args: string[]) => CommandResult; +const defaultLaunchctl: LaunchctlRunner = args => sh("launchctl", args); + +/** + * Whether a `bootout` exit status means "nothing was loaded there" rather than a failure: 3 is + * "No such process", 113/112 answer for the service and the domain. Same statuses as + * `launchctlBootoutBenign` in src/service/launchd.ts, restated so the server-side lifecycle that + * imports this module does not pull in the service manager. + */ +function launchctlBootoutBenign(status: number | null): boolean { + return status === 0 || status === 3 || status === 112 || status === 113; +} + +function watcherDomain(): string { + return `gui/${process.getuid?.() ?? 0}`; +} + +export interface InstallChatgptUnblockWatcherOptions { + port: number; + /** PAC mode: the CONNECT entry port the generated script checks and the app is pointed at. */ + entryPort?: number; + /** Start the app through the app-server shim (`CODEX_CLI_PATH`). */ + shimMode?: boolean; + configDir?: string; + /** Test seam: skip the macOS / app-presence guards. */ + assumeSupported?: boolean; + /** Test seam: where the agent plist is written instead of ~/Library/LaunchAgents. */ + plistPath?: string; + /** Test seam: the script body written instead of the generated one. */ + scriptText?: string; + launchctl?: LaunchctlRunner; +} + +/** + * Install the launch watcher: write script + agent plist and load it with launchd. Throws with + * launchctl's diagnostic when the agent cannot be loaded, and removes the files it wrote so a + * failed install leaves nothing half-installed behind. + */ +export function installChatgptUnblockWatcher(options: InstallChatgptUnblockWatcherOptions): void { + if (process.platform !== "darwin" && !options.assumeSupported) { + throw new Error("the ChatGPT launch watcher is only supported on macOS"); + } + if (!options.assumeSupported && !existsSync(CHATGPT_APP_PATH)) { + throw new Error(`${CHATGPT_APP_PATH} not found; install the ChatGPT desktop app first`); + } + const launchctl = options.launchctl ?? defaultLaunchctl; + const paths = { ...chatgptUnblockWatcherPaths(options.configDir), ...(options.plistPath ? { plistPath: options.plistPath } : {}) }; + // Unload any previous generation first; "not loaded" is the expected answer on a fresh install. + const previous = launchctl(["bootout", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + if (!launchctlBootoutBenign(previous.status)) { + throw new Error(`could not unload the previous watcher (launchctl bootout exited ${previous.status}): ${previous.output}`); + } + if (!options.plistPath) mkdirSync(expandHome("~/Library/LaunchAgents"), { recursive: true }); + writeFileSync( + paths.scriptPath, + options.scriptText ?? buildChatgptUnblockWatcherScript(options.port, options.configDir, options.shimMode === true), + { mode: 0o700 }, + ); + // The script is generated from a template literal and never parsed as bash before launchd runs + // it, so a quoting slip would install a watcher that dies on every app launch. Refuse it here. + const syntax = checkChatgptWatcherScriptSyntax(paths.scriptPath); + if (!syntax.ok) { + rmSync(paths.scriptPath, { force: true }); + throw new Error(`the generated watcher script is not valid bash: ${syntax.output || `bash -n exited ${syntax.status}`}`); + } + writeFileSync(paths.plistPath, buildChatgptUnblockWatcherPlist(paths.scriptPath, [paths.lockPath, paths.readyPath], paths.errPath)); + const loaded = launchctl(["bootstrap", watcherDomain(), paths.plistPath]); + if (!loaded.ok) { + rmSync(paths.plistPath, { force: true }); + rmSync(paths.scriptPath, { force: true }); + throw new Error(`launchctl bootstrap exited ${loaded.status}: ${loaded.output || "no diagnostic"}`); + } +} + +export interface UninstallChatgptUnblockWatcherOptions { + configDir?: string; + plistPath?: string; + launchctl?: LaunchctlRunner; +} + +/** + * Remove the launch watcher: unload the agent, then delete its files. An agent that was not + * loaded is fine; any other unload failure throws and keeps the files, so the installed state + * and what is on disk never disagree. + */ +export function uninstallChatgptUnblockWatcher(options: UninstallChatgptUnblockWatcherOptions = {}): void { + const launchctl = options.launchctl ?? defaultLaunchctl; + const paths = { ...chatgptUnblockWatcherPaths(options.configDir), ...(options.plistPath ? { plistPath: options.plistPath } : {}) }; + const unloaded = launchctl(["bootout", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + if (!launchctlBootoutBenign(unloaded.status)) { + throw new Error(`launchctl bootout exited ${unloaded.status}: ${unloaded.output || "no diagnostic"}; watcher files kept`); + } + rmSync(paths.plistPath, { force: true }); + rmSync(paths.scriptPath, { force: true }); +} + +export interface ChatgptUnblockWatcherStatus { + scriptInstalled: boolean; + plistInstalled: boolean; + agentLoaded: boolean; + scriptUpToDate: boolean; + plistUpToDate: boolean; +} + +export function chatgptUnblockWatcherStatus(port: number, configDir?: string, shimMode = false): ChatgptUnblockWatcherStatus { + const paths = chatgptUnblockWatcherPaths(configDir); + const scriptInstalled = existsSync(paths.scriptPath); + const plistInstalled = existsSync(paths.plistPath); + const agentLoaded = sh("launchctl", ["print", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]).ok; + const scriptUpToDate = scriptInstalled + && readFileSync(paths.scriptPath, "utf8") === buildChatgptUnblockWatcherScript(port, configDir, shimMode); + const plistUpToDate = plistInstalled + && readFileSync(paths.plistPath, "utf8") === buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, paths.errPath); + return { scriptInstalled, plistInstalled, agentLoaded, scriptUpToDate, plistUpToDate }; +} + +/** + * The running app's command line, or null. Mirrors the script's `app_pid`: exact process name + * (ancestors included, since `ocx` may run in a terminal inside the app), then the bundle path, + * never a match against every command line. + */ +export function chatgptAppCommandLine(): string | null { + const pids = sh("pgrep", ["-a", "-x", "ChatGPT"]); + if (!pids.ok) return null; + for (const pid of pids.output.split(/\s+/).filter(Boolean)) { + const command = sh("ps", ["-o", "command=", "-p", pid]); + if (command.ok && command.output.includes("ChatGPT.app/Contents/MacOS/ChatGPT")) return command.output.trim(); + } + return null; +} + +/** + * Whether the running app was started through the app-server shim. The choice travels in the + * app's CODEX_CLI_PATH environment variable, which "ps eww" shows for the app's own process. + */ +export function chatgptAppHasShim(configDir: string): boolean { + const pids = sh("pgrep", ["-a", "-x", "ChatGPT"]); + if (!pids.ok) return false; + const marker = `CODEX_CLI_PATH=${chatgptShimLauncherPath(configDir)}`; + for (const pid of pids.output.split(/\s+/).filter(Boolean)) { + const command = sh("ps", ["eww", "-o", "command=", "-p", pid]); + if (command.ok && command.output.includes("ChatGPT.app/Contents/MacOS/ChatGPT")) return command.output.includes(marker); + } + return false; +} + +/** Whether a command line carries the resolver switch for `port`. */ +export function chatgptCommandLineHasRule(commandLine: string, port: number): boolean { + return commandLine.includes(` ${chatgptUnblockResolverArg(port)}`); +} + + +export type ChatgptListenerProbe = + | { state: "ours"; preservedSendBlocks: (PreservedSendBlock & { lastSeen: string })[] } + /** Something answers on the port, but not opencodex's listener. */ + | { state: "foreign" } + | { state: "down" }; + +/** Whether anything accepts a TCP connection on the loopback port. */ +function loopbackPortOpen(port: number): Promise { + return new Promise(resolve => { + const socket = connectSocket({ host: "127.0.0.1", port }); + const done = (open: boolean) => { socket.destroy(); resolve(open); }; + socket.setTimeout(3000, () => done(false)); + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + }); +} + +/** + * GET the listener's identity path over a raw TLS socket and return the response body, or null. + * Not fetch: Bun's fetch sends even loopback requests through HTTP(S)_PROXY from the user's + * shell and ignores `proxy: false` (Bun 1.4), and a proxy cannot reach this machine's loopback. + */ +function requestIdentity(port: number): Promise { + return new Promise(resolve => { + let raw = ""; + let settled = false; + const finish = (body: string | null) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(body); + }; + // The leaf names chatgpt.com, not 127.0.0.1; identity is established by the answer, not TLS. + const socket = connectTls({ host: "127.0.0.1", port, servername: CHATGPT_INTERCEPT_HOST, rejectUnauthorized: false }, () => { + socket.write(`GET ${CHATGPT_UNBLOCK_IDENTITY_PATH} HTTP/1.1\r\nHost: ${CHATGPT_INTERCEPT_HOST}\r\nConnection: close\r\n\r\n`); + }); + socket.setTimeout(3000, () => finish(null)); + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { raw += chunk; }); + socket.on("error", () => finish(null)); + socket.on("end", () => { + const split = raw.indexOf("\r\n\r\n"); + finish(split !== -1 && /^HTTP\/1\.[01] 200\b/.test(raw) ? raw.slice(split + 4) : null); + }); + }); +} + +/** + * Ask the port who holds it. A plain TCP connect decides "down" -- error codes for a refused + * connection vary by runtime and network setup -- and only an open port is asked for its + * identity through the listener's local identity path. + */ +export async function probeChatgptUnblockListener( + port: number, + request: (port: number) => Promise = requestIdentity, + portOpen: (port: number) => Promise = loopbackPortOpen, +): Promise { + if (!(await portOpen(port))) return { state: "down" }; + const text = await request(port); + if (text === null) return { state: "foreign" }; + try { + const body = JSON.parse(text) as { service?: unknown; preservedSendBlocks?: unknown }; + if (body.service !== CHATGPT_UNBLOCK_SERVICE_ID) return { state: "foreign" }; + const blocks = Array.isArray(body.preservedSendBlocks) ? body.preservedSendBlocks : []; + return { state: "ours", preservedSendBlocks: blocks as (PreservedSendBlock & { lastSeen: string })[] }; + } catch { + return { state: "foreign" }; + } +} + +function runLaunchScript(mode: "launch" | "native", port: number, configDir?: string, shimMode = false): { ok: boolean; output: string } { + if (process.platform !== "darwin") { + throw new Error("launching the ChatGPT desktop app is only supported on macOS"); + } + // The script goes in on stdin, so no file is needed and the script's own command line never + // looks like the app's. + // An inherited CODEX_CLI_PATH (ocx run inside the app) must not leak into the relaunch. + const env = { ...process.env }; + delete env.CODEX_CLI_PATH; + const result = spawnSync("/bin/bash", ["-s", mode], { + env, + input: buildChatgptUnblockWatcherScript(port, configDir, shimMode), + encoding: "utf8", + }); + return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; +} + +/** Start the app with the launch arguments, restarting it if it runs without them (macOS). */ +export function launchChatgptWithRule(port: number, configDir?: string, shimMode = false): { ok: boolean; output: string } { + return runLaunchScript("launch", port, configDir, shimMode); +} + +/** Restart an app that carries the launch switches without them, returning it to native networking. */ +export function restoreChatgptNative(port: number, configDir?: string, shimMode = false): { ok: boolean; output: string } { + return runLaunchScript("native", port, configDir, shimMode); +} diff --git a/src/chatgpt/desktop-unblock/listener.ts b/src/chatgpt/desktop-unblock/listener.ts new file mode 100644 index 00000000000..cc8ac522001 --- /dev/null +++ b/src/chatgpt/desktop-unblock/listener.ts @@ -0,0 +1,271 @@ +import type { Server } from "bun"; +import type { PemKeyPair } from "../../claude/intercept/local-ca"; +import { forwardHeadersForUpstream } from "../../claude/intercept/listener"; +import { rewriteSurfaceFor, stripSendBlocksFromJson, stripSendBlocksFromSseLine } from "./rewrite"; +import type { PreservedSendBlock, RewriteSurface } from "./rewrite"; +import { handleWebSocketUpgrade, isRelayableUpgrade } from "./ws-relay"; +import type { WsRelaySocketData } from "./ws-relay"; +import type { DialUpstreamOptions } from "./ws-upstream"; + +/** + * TLS listener for the ChatGPT desktop send-unblock intercept. + * + * Launched with `--host-resolver-rules="MAP chatgpt.com 127.0.0.1:"`, the desktop app + * dialls this listener believing it reached chatgpt.com. Requests are relayed verbatim to the + * real upstream with the caller's own auth headers; responses pass through untouched except + * that conversation payloads lose their client-side send-lock entries. Nothing is logged and + * no credential is persisted -- the listener is a pipe, not a store. + * + * Only the exact host `chatgpt.com` is ever presented here. Subdomains (`ab.chatgpt.com`, + * `codex-cloud-backend.chatgpt.com`) and `auth.openai.com` are not mapped by the launcher, so + * login, telemetry and cloud sessions stay native. + */ + +export const CHATGPT_UNBLOCK_UPSTREAM = "https://chatgpt.com"; +export const CHATGPT_INTERCEPT_HOST = "chatgpt.com"; + +// fetch() transparently decodes the body, so the encoding headers would describe bytes the +// client never sees. `alt-svc` is dropped so the app never tries HTTP/3: QUIC is UDP, which +// the TCP listener cannot answer, and a stray attempt only costs the app a fallback delay. +const RESPONSE_STRIP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", "alt-svc", +]); + +/** Statuses that carry no body; constructing a Response with one throws. */ +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + +/** + * Local-only path the listener answers itself, never relayed. The launch watcher and + * `ocx chatgpt status` use it to tell this listener apart from any other process that happens + * to hold the port, and it reports the send blocks the rewrite deliberately preserved. + */ +export const CHATGPT_UNBLOCK_IDENTITY_PATH = "/__opencodex/chatgpt-unblock"; +export const CHATGPT_UNBLOCK_SERVICE_ID = "opencodex-chatgpt-unblock"; + +/** How many distinct preserved send blocks the listener remembers for status output. */ +const PRESERVED_BLOCKS_KEPT = 8; + +/** + * In-memory record of send blocks the rewrite left in place: feature name and reason only, no + * payload, account or request data. Nothing is written to disk. + */ +export class ChatgptUnblockDiagnostics { + private readonly preserved = new Map(); + + record(blocks: readonly PreservedSendBlock[]): void { + for (const block of blocks) { + const key = `${block.name}\u0000${block.reason}`; + this.preserved.delete(key); + this.preserved.set(key, { ...block, lastSeen: new Date().toISOString() }); + if (this.preserved.size > PRESERVED_BLOCKS_KEPT) this.preserved.delete(this.preserved.keys().next().value!); + } + } + + snapshot(): { service: string; preservedSendBlocks: (PreservedSendBlock & { lastSeen: string })[] } { + return { service: CHATGPT_UNBLOCK_SERVICE_ID, preservedSendBlocks: [...this.preserved.values()] }; + } +} + +export interface ChatgptUnblockListenerOptions { + leaf: PemKeyPair; + upstreamBase?: string; + idleTimeout?: number; + fetchImpl?: typeof fetch; + /** Test seam: bind a fixed port instead of an ephemeral one. */ + port?: number; + /** Test seam: where WebSocket upgrades dial instead of chatgpt.com through the configured proxy. */ + wsUpstream?: DialUpstreamOptions; + diagnostics?: ChatgptUnblockDiagnostics; +} + +function responseHeaders(source: Response): Headers { + const headers = new Headers(); + source.headers.forEach((value, name) => { + if (!RESPONSE_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +/** + * Line-oriented SSE rewriter. Complete lines are checked one at a time so an untouched stream + * keeps its exact chunking and line endings; only `data:` lines whose JSON loses an entry are + * re-serialized. + */ +export function sseRewriteStream(options: { + surface?: RewriteSurface; + diagnostics?: ChatgptUnblockDiagnostics; +} = {}): TransformStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + const rewriteLine = (line: string): string | null => { + const preserved: PreservedSendBlock[] = []; + const rewritten = stripSendBlocksFromSseLine(line, options.surface, preserved); + options.diagnostics?.record(preserved); + return rewritten; + }; + return new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }); + let index: number; + while ((index = pending.indexOf("\n")) !== -1) { + const line = pending.slice(0, index); + pending = pending.slice(index + 1); + const rewritten = rewriteLine(line); + controller.enqueue(encoder.encode(`${rewritten ?? line}\n`)); + } + }, + flush(controller) { + if (pending.length === 0) return; + const rewritten = rewriteLine(pending); + controller.enqueue(encoder.encode(rewritten ?? pending)); + pending = ""; + }, + }); +} + +/** Largest JSON body the relay buffers to rewrite; anything bigger passes through unchanged. */ +export const MAX_REWRITE_BODY_BYTES = 8 * 1024 * 1024; + +type BoundedText = { text: string } | { overflow: ReadableStream }; + +/** + * Read a body as text up to `cap` bytes. Past the cap the bytes already read are replayed in + * front of the rest of the stream, so the caller can pass the whole body on unchanged. + */ +async function readBoundedText(body: ReadableStream | null, cap: number): Promise { + if (!body) return { text: "" }; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + size += value.byteLength; + if (size > cap) { + return { + overflow: new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + }, + async pull(controller) { + const next = await reader.read(); + if (next.done) controller.close(); + else controller.enqueue(next.value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }), + }; + } + } + const whole = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + whole.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(whole) }; +} + +function isJsonContentType(contentType: string): boolean { + return contentType.includes("application/json") || contentType.endsWith("+json"); +} + +function isEventStreamContentType(contentType: string): boolean { + return contentType.includes("text/event-stream"); +} + +export async function relayWithSendUnblock( + req: Request, + upstreamBase: string, + fetchImpl: typeof fetch = fetch, + diagnostics?: ChatgptUnblockDiagnostics, +): Promise { + const url = new URL(req.url); + if (url.pathname === CHATGPT_UNBLOCK_IDENTITY_PATH) { + return Response.json((diagnostics ?? new ChatgptUnblockDiagnostics()).snapshot(), { headers: { "cache-control": "no-store" } }); + } + const target = `${upstreamBase.replace(/\/$/, "")}${url.pathname}${url.search}`; + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + let upstream: Response; + try { + upstream = await fetchImpl(target, { + method: req.method, + headers: forwardHeadersForUpstream(req.headers), + body: hasBody ? req.body : undefined, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); + } catch (error) { + return Response.json( + { error: { message: `chatgpt unblock relay failed: ${error instanceof Error ? error.message : String(error)}` } }, + { status: 502 }, + ); + } + const headers = responseHeaders(upstream); + const init = { status: upstream.status, statusText: upstream.statusText, headers }; + if (NULL_BODY_STATUSES.has(upstream.status) || req.method === "HEAD") return new Response(null, init); + // Everything but the composer's conversation and usage endpoints passes through untouched. + const surface = rewriteSurfaceFor(url.pathname); + if (surface === null) return new Response(upstream.body, init); + const contentType = upstream.headers.get("content-type") ?? ""; + if (isJsonContentType(contentType)) { + // The gate payloads are small; a body past the cap is a transcript or listing that carries + // nothing to rewrite, so it streams through untouched instead of being buffered and copied. + const declared = Number(upstream.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_REWRITE_BODY_BYTES) return new Response(upstream.body, init); + let read: BoundedText; + try { + read = await readBoundedText(upstream.body, MAX_REWRITE_BODY_BYTES); + } catch { + return new Response(JSON.stringify({ error: { message: "chatgpt unblock upstream read failed" } }), { status: 502, headers }); + } + if ("overflow" in read) return new Response(read.overflow, init); + const text = read.text; + const preserved: PreservedSendBlock[] = []; + const rewritten = stripSendBlocksFromJson(text, surface, preserved); + diagnostics?.record(preserved); + return new Response(rewritten ?? text, init); + } + if (isEventStreamContentType(contentType) && upstream.body) { + return new Response(upstream.body.pipeThrough(sseRewriteStream({ surface, diagnostics })), init); + } + return new Response(upstream.body, init); +} + +/** + * Bind the intercept TLS listener on an ephemeral loopback port. WebSocket upgrades are + * relayed by `handleWebSocketUpgrade` (voice/dictation and every other WS endpoint on the + * intercepted host); everything else keeps going through the fetch-based relay untouched. + */ +export function startChatgptUnblockListener(options: ChatgptUnblockListenerOptions): Server { + const upstreamBase = options.upstreamBase ?? CHATGPT_UNBLOCK_UPSTREAM; + const diagnostics = options.diagnostics ?? new ChatgptUnblockDiagnostics(); + return Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert: options.leaf.certPem, key: options.leaf.keyPem }, + idleTimeout: options.idleTimeout ?? 255, + websocket: { + // Frames both directions once Bun finished the client-side upgrade; see WsRelay. + open(ws) { + ws.data.relay.attach(ws); + }, + message(ws, message) { + ws.data.relay.clientMessage(message); + }, + close(ws, code, reason) { + ws.data.relay.clientClose(code, reason); + }, + }, + async fetch(req, server) { + if (isRelayableUpgrade(req)) return handleWebSocketUpgrade(req, server, options.wsUpstream); + return relayWithSendUnblock(req, upstreamBase, options.fetchImpl, diagnostics); + }, + }); +} diff --git a/src/chatgpt/desktop-unblock/rewrite.ts b/src/chatgpt/desktop-unblock/rewrite.ts new file mode 100644 index 00000000000..58c8236b5dc --- /dev/null +++ b/src/chatgpt/desktop-unblock/rewrite.ts @@ -0,0 +1,180 @@ +import { isRecord, unlockRateLimitGate } from "../app-server-shim/gate-rewrite"; +export { unlockRateLimitGate } from "../app-server-shim/gate-rewrite"; + +/** + * Send-unblock rewriting for the ChatGPT desktop intercept. + * + * The ChatGPT desktop app disables the conversation composer from two backend data shapes: + * + * 1. Conversation metadata (`POST /backend-api/conversation/init`, and the + * `conversation_detail_metadata` events of the `/backend-api/f/conversation` stream) carries + * `blocked_features` entries named `send` (or `tpp_send`) and `limits_progress` entries for + * `send` with `remaining <= 0`. + * 2. The desktop usage snapshot (`/backend-api/wham/usage[/stream]`) carries + * `rate_limit.allowed: false` + `rate_limit.limit_reached: true` while the logged-in + * ChatGPT subscription quota is exhausted. The bundled `codex app-server` additionally + * derives its own "limit reached" state from the sibling `rate_limit_reached_type` object. + * + * Only the account's own usage quota is lifted -- data that is meaningless for turns whose + * model calls opencodex routes to third-party providers. Scope is deliberately narrow: + * + * - Only the endpoints above are rewritten (`rewriteSurfaceFor`); every other response passes + * through byte-identical, even when it happens to contain the same field names. + * - A send block is removed only when its `block_reason` says quota (or is absent, the shape + * of a plain usage limit). Eligibility blocks such as `work_subscription_required`, and any + * reason not recognised as quota, are preserved and reported, so the app keeps its real + * explanation and `ocx chatgpt status` can show why a composer stays locked. + * - Quota display stays honest: `banner_info` / `rate_limit_upsell`, `used_percent`, + * `reset_at` and window fields, `model_limits`, `model_usage` and every other key pass + * through untouched, so the app keeps showing the account's real usage. + */ + +/** `blocked_features[].name` values the desktop composer treats as a send lock. */ +const SEND_BLOCKED_FEATURE_NAMES = new Set(["send", "tpp_send"]); + +/** `limits_progress[].feature_name` value for the composer's send gate. */ +const SEND_LIMIT_FEATURE_NAME = "send"; + +/** + * `block_reason` values that describe the account's own usage quota. Anything else -- + * subscription, policy, a workspace or credit limit such as `workspace_owner_usage_limit_reached`, + * or a reason this code has never seen -- is left in place. + */ +const QUOTA_BLOCK_REASONS = new Set(["usage_limit", "usage_limit_reached", "rate_limit_exceeded", "rate_limit_reached", "quota_exhausted"]); + +/** Which part of the rewrite applies to a response. */ +export type RewriteSurface = "conversation" | "usage"; + +const CONVERSATION_PATHS = ["/backend-api/conversation/init", "/backend-api/conversation", "/backend-api/f/conversation"]; +const USAGE_PATHS = ["/backend-api/wham/usage", "/backend-api/wham/usage/stream"]; + +/** + * The rewrite that applies to a request path, or null for everything else. Conversation paths + * match exactly or as a prefix segment (`/backend-api/f/conversation/prepare`); the conversation + * list (`/backend-api/conversations`) and the other usage endpoints (thread usage, plan history) + * do not match. + */ +export function rewriteSurfaceFor(pathname: string): RewriteSurface | null { + if (USAGE_PATHS.includes(pathname)) return "usage"; + if (CONVERSATION_PATHS.some(path => pathname === path || pathname.startsWith(`${path}/`))) return "conversation"; + return null; +} + +/** A send block the rewrite deliberately left in place. */ +export interface PreservedSendBlock { + name: string; + reason: string; +} + +export interface RewriteResult { + value: unknown; + changed: boolean; +} + +function isSendBlockedFeature(entry: unknown): entry is Record { + return isRecord(entry) && SEND_BLOCKED_FEATURE_NAMES.has(String(entry.name ?? "")); +} + +/** Absent/empty reason is the plain usage-limit shape; otherwise the reason must say quota. */ +function isQuotaBlockReason(reason: unknown): boolean { + if (reason === undefined || reason === null || reason === "") return true; + return typeof reason === "string" && QUOTA_BLOCK_REASONS.has(reason); +} + +function isExhaustedSendLimit(entry: unknown): boolean { + if (!isRecord(entry) || (entry.feature_name ?? entry.featureName) !== SEND_LIMIT_FEATURE_NAME) return false; + const remaining = entry.remaining; + return typeof remaining === "number" && remaining <= 0; +} + +/** + * Recursively strip quota send-lock entries from any `blocked_features` / `limits_progress` + * arrays. Non-quota send blocks are kept and appended to `preserved`. Malformed entries are + * kept: the rewrite owns removal of known-shaped blocks, not validation. + */ +export function stripSendBlocks(value: unknown, preserved: PreservedSendBlock[] = []): RewriteResult { + if (Array.isArray(value)) { + let changed = false; + const items = value.map(item => { + const result = stripSendBlocks(item, preserved); + changed ||= result.changed; + return result.value; + }); + return { value: items, changed }; + } + if (!isRecord(value)) return { value, changed: false }; + let changed = false; + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + if ((key === "blocked_features" || key === "blockedFeatures") && Array.isArray(child)) { + const kept = child.filter(entry => { + if (!isSendBlockedFeature(entry)) return true; + const reason = entry.block_reason ?? entry.blockReason; + if (isQuotaBlockReason(reason)) return false; + preserved.push({ name: String(entry.name), reason: String(reason) }); + return true; + }); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + if ((key === "limits_progress" || key === "limitsProgress") && Array.isArray(child)) { + const kept = child.filter(entry => !isExhaustedSendLimit(entry)); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + const result = stripSendBlocks(child, preserved); + changed ||= result.changed; + out[key] = result.value; + } + return { value: out, changed }; +} + +/** + * Rewrite a JSON response body for `surface` (both parts when omitted). Returns `null` when the + * body is not valid JSON or contains nothing to rewrite, so callers can pass the original bytes + * through untouched. Non-quota send blocks left in place are appended to `preserved`. + */ +export function stripSendBlocksFromJson( + text: string, + surface?: RewriteSurface, + preserved: PreservedSendBlock[] = [], +): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + let value = parsed; + let changed = false; + if (surface !== "usage") { + const stripped = stripSendBlocks(value, preserved); + value = stripped.value; + changed ||= stripped.changed; + } + if (surface !== "conversation") changed = unlockRateLimitGate(value) || changed; + return changed ? JSON.stringify(value) : null; +} + +/** + * Rewrite a single SSE line. ChatGPT conversation and usage-stream events carry one JSON + * document per `data:` line; lines that parse to a payload with send blocks or a closed usage + * gate are replaced, everything else passes through byte-identical. A CRLF-framed line keeps + * its `\r`. Returns `null` when the line is unchanged. + */ +export function stripSendBlocksFromSseLine( + line: string, + surface?: RewriteSurface, + preserved: PreservedSendBlock[] = [], +): string | null { + const cr = line.endsWith("\r") ? "\r" : ""; + const body = cr ? line.slice(0, -1) : line; + // `s`: a JSON string may legally hold U+2028/U+2029, which `.` would otherwise refuse. + const match = /^(data: ?)(.*)$/s.exec(body); + if (!match) return null; + const rewritten = stripSendBlocksFromJson(match[2]!, surface, preserved); + if (rewritten === null) return null; + return `${match[1]}${rewritten}${cr}`; +} diff --git a/src/chatgpt/desktop-unblock/runtime.ts b/src/chatgpt/desktop-unblock/runtime.ts new file mode 100644 index 00000000000..681ed0d9859 --- /dev/null +++ b/src/chatgpt/desktop-unblock/runtime.ts @@ -0,0 +1,115 @@ +import type { Server } from "bun"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { OcxConfig } from "../../types"; +import { getConfigDir } from "../../config/paths"; +import { + claudeInterceptCaCertPath, + ensureLocalInterceptCaForStartup, + issueLocalInterceptLeaf, +} from "../../claude/intercept/local-ca"; +import { CHATGPT_INTERCEPT_HOST, startChatgptUnblockListener } from "./listener"; +import type { WsRelaySocketData } from "./ws-relay"; + +/** + * Lifecycle for the ChatGPT desktop send-unblock listener. + * + * Opt-in via `chatgptDesktop.unblockSend`. The listener shares the Claude intercept authority + * (one trusted certificate covers both features) and binds a stable loopback port derived from + * the public port so the launcher's `--host-resolver-rules` value survives restarts. A bind + * failure degrades to a warning exactly like the Claude intercept pair: the proxy's other + * duties never depend on this listener existing. + */ + +export const CHATGPT_UNBLOCK_PORT_OFFSET = 200; + +export function chatgptUnblockEnabled(config: Pick): boolean { + if (process.platform !== "darwin" || config.runtimeRole === "client") return false; + return config.chatgptDesktop?.unblockSend === true; +} + +/** + * The listener port: `chatgptDesktop.port` when valid, else the public port plus the offset. + * Throws when the derived port would leave the TCP range (a public port of 65336 or more); + * callers report that as the optional integration being unavailable. + */ +export function chatgptUnblockPort(config: Pick, publicPort: number): number { + const configured = config.chatgptDesktop?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + const derived = publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; + if (derived > 65535) { + throw new Error( + `the default ChatGPT unblock port (${publicPort} + ${CHATGPT_UNBLOCK_PORT_OFFSET} = ${derived}) is out of range; set chatgptDesktop.port to a free port`, + ); + } + return derived; +} + +/** The resolver rule to hand the ChatGPT desktop app at launch. */ +export function chatgptUnblockResolverRule(port: number): string { + return `MAP ${CHATGPT_INTERCEPT_HOST} 127.0.0.1:${port}`; +} + +/** + * The rule as the app's command-line switch. Chromium silently ignores a bare rule passed as + * a positional argument, so every launch path must pass this form. + */ +export function chatgptUnblockResolverArg(port: number): string { + return `--host-resolver-rules=${chatgptUnblockResolverRule(port)}`; +} + +/** + * Rewritten once the listener is up. The launch watcher wakes on it as well as on the app's own + * launch, so an app that started before opencodex (both opened at login) is still routed. + */ +export const CHATGPT_UNBLOCK_READY_FILENAME = "chatgpt-unblock.ready"; + +export function chatgptUnblockReadyPath(configDir: string): string { + return join(configDir, CHATGPT_UNBLOCK_READY_FILENAME); +} + + +export interface ChatgptUnblockState { + port: number; + caCertPath: string; +} + +export interface ChatgptUnblockHandle extends ChatgptUnblockState { + listener: Server; + stop(): Promise; +} + +export interface StartChatgptUnblockOptions { + config: OcxConfig; + /** Bound public port; the derived listener port is offset from it. */ + publicPort: number; + configDir?: string; +} + +/** + * Bind the listener. Resolves `null` when the feature is disabled. A bind failure is reported + * by rejecting; callers treat it as a degraded optional integration, never a startup failure. + */ +export async function startChatgptUnblock(options: StartChatgptUnblockOptions): Promise | null> { + if (!chatgptUnblockEnabled(options.config)) return null; + const port = chatgptUnblockPort(options.config, options.publicPort); + const configDir = options.configDir ?? getConfigDir(); + const ca = await ensureLocalInterceptCaForStartup(configDir); + const leaf = issueLocalInterceptLeaf(ca, [CHATGPT_INTERCEPT_HOST]); + // The port must be the configured one, not ephemeral: the launcher's launch arguments name it. + const listener = startChatgptUnblockListener({ leaf, port }); + // Best effort: without the marker the watcher still acts on the app's next launch. + try { + writeFileSync(chatgptUnblockReadyPath(configDir), `${port} ${new Date().toISOString()}\n`, { mode: 0o644 }); + } catch { + // An unwritable config dir already failed the CA write; nothing to add here. + } + return { + port, + caCertPath: claudeInterceptCaCertPath(configDir), + listener, + stop: async () => { + await listener.stop(true); + }, + }; +} diff --git a/src/chatgpt/desktop-unblock/ws-frame.ts b/src/chatgpt/desktop-unblock/ws-frame.ts new file mode 100644 index 00000000000..f1ac430843b --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-frame.ts @@ -0,0 +1,156 @@ +/** + * Minimal RFC 6455 framing for the ChatGPT desktop intercept's WebSocket relay. + * + * The listener hands WebSocket upgrades to Bun's server-side stack on the client side, + * so only the upstream side needs hand-rolled framing: parse the upstream's (unmasked) + * frames off the tunnel socket, and encode the client's messages as masked frames back. + * + * Frames larger than WEBSOCKET_MAX_FRAME_BYTES are treated as a protocol violation and + * end the relay rather than being buffered indefinitely: the buffer model is "concatenate + * everything the socket has given us", so a runaway length field would otherwise grow + * memory until the connection is torn down. Voice/dictation audio frames are small + * (a few KB at most), far under this ceiling. + */ + +export const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +export const WEBSOCKET_MAX_FRAME_BYTES = 16 * 1024 * 1024; + +export const WsOpcode = { + CONTINUATION: 0x0, + TEXT: 0x1, + BINARY: 0x2, + CLOSE: 0x8, + PING: 0x9, + PONG: 0xa, +} as const; + +export type WsOpcode = (typeof WsOpcode)[keyof typeof WsOpcode]; + +export interface WsFrame { + opcode: WsOpcode; + fin: boolean; + payload: Buffer; +} + +/** + * Parse as many complete frames as `chunk` (plus anything left over from previous + * chunks) yields. Returns the frames parsed and, when the byte stream ends in a + * protocol violation (bad RSV/opcode bits, oversize length, or a masked server frame), + * the reason; the caller tears the relay down when it is non-null. Nothing about the + * parse is retryable, so a violation discards the remaining buffer. + */ +export function parseWsFrames(chunk: Buffer, pending: Buffer): { frames: WsFrame[]; violation: string | null; rest: Buffer } { + const buffer = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]); + const frames: WsFrame[] = []; + let offset = 0; + while (true) { + const header = parseFrameHeader(buffer, offset); + if (header.error !== null) return { frames, violation: header.error, rest: EMPTY }; + if (!header.complete) return { frames, violation: null, rest: buffer.subarray(offset) }; + const { opcode, fin, length, mask, payloadStart } = header; + let payload = buffer.subarray(payloadStart, payloadStart + length); + if (mask !== null) payload = unmask(payload, mask); + frames.push({ opcode, fin, payload: Buffer.from(payload) }); + offset = payloadStart + length; + } +} + +const EMPTY = Buffer.alloc(0); + +interface WsFrameHeader { + opcode: WsOpcode; + fin: boolean; + length: number; + mask: Buffer | null; + payloadStart: number; + complete: boolean; + error: string | null; +} + +function parseFrameHeader(buffer: Buffer, offset: number): WsFrameHeader { + if (buffer.length < offset + 2) return INCOMPLETE; + const b0 = buffer[offset]!; + const b1 = buffer[offset + 1]!; + const rsv = b0 & 0x70; + if (rsv !== 0) return violation("RSV bits set without a negotiated extension"); + const opcode = b0 & 0x0f; + const known = opcode === WsOpcode.CONTINUATION || opcode === WsOpcode.TEXT || opcode === WsOpcode.BINARY + || opcode === WsOpcode.CLOSE || opcode === WsOpcode.PING || opcode === WsOpcode.PONG; + if (!known) return violation("unknown opcode"); + const control = opcode >= WsOpcode.CLOSE; + if (control && (b0 & 0x80) === 0) return violation("control frame without FIN"); + const fin = (b0 & 0x80) !== 0; + // Upstream here is the server role: its frames are unmasked per RFC 6455 §5.1. A masked + // frame is still parsed rather than rejected -- the relay's job is to pass bytes, not to + // police the server -- but only the unmasked shape is expected in practice. + const masked = (b1 & 0x80) !== 0; + let length = b1 & 0x7f; + let cursor = offset + 2; + if (control && length > 0x7d) return violation("control frame payload over 125 bytes"); + if (length === 126) { + if (buffer.length < cursor + 2) return INCOMPLETE; + length = buffer.readUInt16BE(cursor); + cursor += 2; + } else if (length === 127) { + if (buffer.length < cursor + 8) return INCOMPLETE; + const big = buffer.readBigUInt64BE(cursor); + if (big > BigInt(WEBSOCKET_MAX_FRAME_BYTES)) return violation("frame exceeds the relay's size ceiling"); + length = Number(big); + cursor += 8; + } + if (length > WEBSOCKET_MAX_FRAME_BYTES) return violation("frame exceeds the relay's size ceiling"); + const mask: Buffer | null = masked + ? (buffer.length < cursor + 4 ? null : buffer.subarray(cursor, cursor + 4)) + : null; + if (masked) { + if (mask === null) return INCOMPLETE; + cursor += 4; + } + if (buffer.length < cursor + length) return INCOMPLETE; + return { opcode, fin, length, mask, payloadStart: cursor, complete: true, error: null }; +} + +const INCOMPLETE: WsFrameHeader = { opcode: 0, fin: false, length: 0, mask: null, payloadStart: 0, complete: false, error: null }; + +function violation(reason: string): WsFrameHeader { + return { opcode: 0, fin: false, length: 0, mask: null, payloadStart: 0, complete: false, error: reason }; +} + +function unmask(payload: Buffer, mask: Buffer): Buffer { + const out = Buffer.from(payload); + for (let i = 0; i < out.length; i++) out[i] = out[i]! ^ mask[i % 4]!; + return out; +} + +/** + * Encode a frame. Client-to-server frames are masked as RFC 6455 requires; the tunnel + * speaks the client role, so `mask` defaults to true and only tests turn it off. + */ +export function encodeWsFrame(opcode: WsOpcode, payload: Buffer, mask = true): Buffer { + let length: number; + let extended: Buffer; + if (payload.length < 126) { + length = payload.length; + extended = EMPTY; + } else if (payload.length <= 0xffff) { + length = 126; + extended = Buffer.alloc(2); + extended.writeUInt16BE(payload.length); + } else { + length = 127; + extended = Buffer.alloc(8); + extended.writeBigUInt64BE(BigInt(payload.length)); + } + const b0 = Buffer.from([0x80 | opcode]); + const b1 = Buffer.from([(mask ? 0x80 : 0) | length]); + if (!mask) return Buffer.concat([b0, b1, extended, payload]); + const key = randomMask(); + return Buffer.concat([b0, b1, extended, key, unmask(payload, key)]); +} + +function randomMask(): Buffer { + const key = Buffer.alloc(4); + crypto.getRandomValues(key); + return key; +} diff --git a/src/chatgpt/desktop-unblock/ws-relay.ts b/src/chatgpt/desktop-unblock/ws-relay.ts new file mode 100644 index 00000000000..057b7992f67 --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-relay.ts @@ -0,0 +1,347 @@ +import type { Server, ServerWebSocket } from "bun"; +import { randomBytes } from "node:crypto"; +import type { TLSSocket } from "node:tls"; +import { encodeWsFrame, parseWsFrames, WEBSOCKET_MAX_FRAME_BYTES, WsOpcode } from "./ws-frame"; +import type { WsFrame } from "./ws-frame"; +import { CHATGPT_UPSTREAM_HOST, dialUpstreamTunnel } from "./ws-upstream"; +import type { DialUpstreamOptions, UpstreamTunnel } from "./ws-upstream"; + +/** + * WebSocket upgrade relay for the ChatGPT desktop intercept. + * + * The app's voice/dictation stream (wss://chatgpt.com/dictation/stream) and any other + * WebSocket endpoint on the intercepted apex host reach this listener as HTTP upgrades, + * which the fetch-based relay cannot carry: it strips hop-by-hop headers. This module + * performs the upgrade itself. It dials chatgpt.com (directly or through the configured + * proxy, the same selection as every other outbound request), forwards the app's + * handshake headers, awaits the upstream's 101, and only then completes the app's own + * upgrade through Bun's WebSocket stack. From there messages pipe both directions: Bun + * speaks WebSocket to the app, hand-rolled RFC 6455 framing speaks it to the upstream. + * + * Path-agnostic by design: every WebSocket endpoint on the intercepted host takes the + * same pipe, so endpoints the app adds later need no allowlist. Nothing is logged and + * no payload is inspected or rewritten. + */ + +const HANDSHAKE_TIMEOUT_MS = 10_000; +/** How long a client-initiated close waits for the upstream's closing handshake. */ +const CLOSE_DRAIN_MS = 500; +/** Continuation frames accepted for one message before the relay gives up on it. */ +const MAX_MESSAGE_CHUNKS = 1024; +/** Total payload bytes accepted for one fragmented message, mirroring the per-frame ceiling. */ +const MAX_MESSAGE_BYTES = WEBSOCKET_MAX_FRAME_BYTES; + +/** + * Handshake headers the relay regenerates for the upstream leg. Extensions are dropped + * so the upstream never negotiates permessage-deflate, which the hand-rolled framing + * does not implement; Bun negotiates the app leg independently. + */ +const HANDSHAKE_REGENERATED_HEADERS = new Set([ + "host", + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", +]); + +/** Response headers that describe the upstream body or connection, not the relay's reply. */ +const REFUSAL_STRIP_HEADERS = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "content-encoding", + "content-length", + "upgrade", +]); + +/** Data each upgraded app socket carries: its live relay. */ +export interface WsRelaySocketData { + relay: WsRelay; +} + +/** + * Whether the relay should take a request. Version 13 is the only RFC 6455 version; an + * upgrade asking for anything else falls through to the HTTP relay unchanged. + */ +export function isRelayableUpgrade(request: Request): boolean { + if ((request.headers.get("upgrade") ?? "").toLowerCase() !== "websocket") return false; + return request.headers.get("sec-websocket-version") === "13"; +} + +/** + * Handshake headers forwarded upstream: everything the app sent except the ones the relay + * regenerates. Cookies, Origin, User-Agent and the offered subprotocols pass through. + */ +export function forwardedHandshakeHeaders(request: Request): Headers { + const headers = new Headers(); + request.headers.forEach((value, name) => { + if (!HANDSHAKE_REGENERATED_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +export type UpstreamHandshakeResult = + | { ok: true; tunnel: UpstreamTunnel; protocol: string | null; early: Buffer } + /** `head` is the upstream's response head when it answered without upgrading. */ + | { ok: false; head: string | null }; + +/** + * Upstream half of the handshake: dial, send the app's request with a fresh key, await + * the response head. Never throws; a failed dial or a refusal comes back as `ok: false`. + * On success the tunnel is left paused so frames arriving before the app's socket + * attaches wait in the socket instead of being dropped. + */ +export async function performUpstreamHandshake( + request: Request, + dialOptions: DialUpstreamOptions = {}, +): Promise { + const tunnel = await dialUpstreamTunnel(dialOptions); + if (!tunnel) return { ok: false, head: null }; + const url = new URL(request.url); + const lines = [`GET ${url.pathname}${url.search} HTTP/1.1`, `Host: ${CHATGPT_UPSTREAM_HOST}`]; + forwardedHandshakeHeaders(request).forEach((value, name) => lines.push(`${name}: ${value}`)); + lines.push( + "Connection: Upgrade", + "Upgrade: websocket", + `Sec-WebSocket-Key: ${randomBytes(16).toString("base64")}`, + "Sec-WebSocket-Version: 13", + ); + tunnel.socket.write(`${lines.join("\r\n")}\r\n\r\n`); + const response = await readResponseHead(tunnel.socket, HANDSHAKE_TIMEOUT_MS); + if (response === null || !/^HTTP\/1\.[01] 101\b/.test(response.head)) { + tunnel.socket.destroy(); + return { ok: false, head: response?.head ?? null }; + } + const protocol = /^sec-websocket-protocol:[ \t]*([^\r\n]+)/im.exec(response.head)?.[1]?.trim() ?? null; + return { ok: true, tunnel, protocol, early: response.early }; +} + +/** Read through the blank line; bytes after it are the first frames. Pauses the socket. */ +export function readResponseHead(socket: TLSSocket, timeoutMs: number): Promise<{ head: string; early: Buffer } | null> { + return new Promise(resolve => { + let buffer = Buffer.alloc(0); + let settled = false; + const finish = (result: { head: string; early: Buffer } | null) => { + if (settled) return; + settled = true; + socket.removeListener("data", onData); + socket.removeListener("error", onFailure); + socket.removeListener("close", onFailure); + // Until WsRelay.attach() installs its own handlers (or the socket is destroyed), a late + // 'error' with no listener is thrown by the emitter and would take the whole proxy down. + socket.on("error", () => {}); + socket.setTimeout(0); + socket.pause(); + resolve(result); + }; + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end !== -1) finish({ head: buffer.subarray(0, end).toString("latin1"), early: buffer.subarray(end + 4) }); + }; + const onFailure = () => finish(null); + socket.on("data", onData); + socket.once("error", onFailure); + socket.once("close", onFailure); + socket.setTimeout(timeoutMs, onFailure); + }); +} + +/** + * One live relay between an upgraded app socket and its upstream tunnel. Upstream + * fragments are reassembled into whole messages because Bun's server socket sends whole + * messages; text stays text and binary stays binary in both directions. + */ +export class WsRelay { + private client: ServerWebSocket | null = null; + private pendingParse: Buffer = Buffer.alloc(0); + private fragmentation: { opcode: WsOpcode; chunks: Buffer[]; bytes: number } | null = null; + private clientClosed = false; + private closed = false; + private drainTimer: ReturnType | null = null; + + constructor( + private readonly tunnel: UpstreamTunnel, + private readonly early: Buffer, + ) {} + + /** Bun's `open` handler: start piping, beginning with any frames that beat the upgrade. */ + attach(client: ServerWebSocket): void { + this.client = client; + const socket = this.tunnel.socket; + socket.on("data", this.onTunnelData); + socket.on("error", this.onTunnelFailure); + socket.on("close", this.onTunnelFailure); + if (this.early.length > 0) this.onTunnelData(this.early); + socket.resume(); + } + + /** Bun's `message` handler: app -> upstream, message type preserved. */ + clientMessage(message: string | Buffer): void { + if (this.clientClosed || this.closed) return; + const frame = typeof message === "string" + ? encodeWsFrame(WsOpcode.TEXT, Buffer.from(message, "utf8")) + : encodeWsFrame(WsOpcode.BINARY, message); + this.tunnel.socket.write(frame); + } + + /** Bun's `close` handler: forward the app's close, then give the upstream a moment to answer. */ + clientClose(code: number, reason: string): void { + if (this.clientClosed || this.closed) return; + this.clientClosed = true; + const reasonBytes = Buffer.from(reason ?? "", "utf8"); + const payload = Buffer.alloc(2 + reasonBytes.length); + payload.writeUInt16BE(sendableCloseCode(code), 0); + reasonBytes.copy(payload, 2); + this.tunnel.socket.write(encodeWsFrame(WsOpcode.CLOSE, payload)); + this.drainTimer = setTimeout(() => this.shutdown(), CLOSE_DRAIN_MS); + } + + /** Tear down a relay whose app-side upgrade never completed. */ + abort(): void { + this.shutdown(); + } + + private readonly onTunnelData = (chunk: Buffer): void => { + const { frames, violation, rest } = parseWsFrames(chunk, this.pendingParse); + this.pendingParse = rest; + for (const frame of frames) this.handleUpstreamFrame(frame); + if (violation !== null) this.failClient(1002, "upstream protocol error"); + }; + + /** Upstream vanished without a closing handshake: the app sees an abnormal closure. */ + private readonly onTunnelFailure = (): void => { + if (this.closed) return; + this.shutdown(); + try { + this.client?.terminate(); + } catch { + // Already gone. + } + }; + + private handleUpstreamFrame(frame: WsFrame): void { + if (this.closed) return; + const { opcode, fin, payload } = frame; + if (opcode === WsOpcode.TEXT || opcode === WsOpcode.BINARY) { + // A new data frame mid-fragmentation is a protocol violation; the relay restarts + // assembly rather than tearing the connection down over it. + this.fragmentation = { opcode, chunks: [payload], bytes: payload.length }; + if (fin) this.flushMessage(); + return; + } + if (opcode === WsOpcode.CONTINUATION) { + if (this.fragmentation === null) return; + this.fragmentation.chunks.push(payload); + this.fragmentation.bytes += payload.length; + if (this.fragmentation.chunks.length > MAX_MESSAGE_CHUNKS || this.fragmentation.bytes > MAX_MESSAGE_BYTES) { + this.failClient(1009, "message too fragmented"); + return; + } + if (fin) this.flushMessage(); + return; + } + if (opcode === WsOpcode.PING) { + this.tunnel.socket.write(encodeWsFrame(WsOpcode.PONG, payload)); + return; + } + if (opcode === WsOpcode.CLOSE) { + const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000; + const reason = payload.length > 2 ? payload.subarray(2).toString("utf8") : ""; + this.shutdown(); + try { + this.client?.close(sendableCloseCode(code), reason); + } catch { + // The app already closed. + } + } + // PONG: a keepalive answer to nothing the relay sent; ignore. + } + + private flushMessage(): void { + const { opcode, chunks } = this.fragmentation!; + this.fragmentation = null; + const message = chunks.length === 1 ? chunks[0]! : Buffer.concat(chunks); + try { + // Send the Buffer itself: for a view, `message.buffer` spans bytes outside the message. + if (opcode === WsOpcode.TEXT) this.client!.send(message.toString("utf8")); + else this.client!.send(message); + } catch { + this.onTunnelFailure(); + } + } + + private failClient(code: number, reason: string): void { + if (this.closed) return; + this.shutdown(); + try { + this.client?.close(code, reason); + } catch { + // Already gone. + } + } + + private shutdown(): void { + this.closed = true; + if (this.drainTimer !== null) { + clearTimeout(this.drainTimer); + this.drainTimer = null; + } + this.tunnel.socket.destroy(); + } +} + +/** + * Codes an endpoint may put in a close frame. 1005/1006/1015 are reserved for reporting + * and 1004 is undefined; anything outside the registered and application ranges maps to 1000. + */ +export function sendableCloseCode(code: number): number { + if (code === 1004 || code === 1005 || code === 1006 || code === 1015) return 1000; + if (code >= 1000 && code <= 1014) return code; + if (code >= 3000 && code <= 4999) return code; + return 1000; +} + +/** + * The listener's fetch-handler entry for WebSocket upgrades. The upstream handshake + * finishes first, so the app's upgrade only succeeds once chatgpt.com has accepted; + * an unreachable or refusing upstream surfaces as an ordinary failed upgrade. + */ +export async function handleWebSocketUpgrade( + request: Request, + server: Server, + dialOptions: DialUpstreamOptions = {}, +): Promise { + const handshake = await performUpstreamHandshake(request, dialOptions); + if (!handshake.ok) return refusalResponse(handshake.head); + const relay = new WsRelay(handshake.tunnel, handshake.early); + const upgraded = server.upgrade(request, { + data: { relay }, + // Bun rejects an empty headers object, so omit it when there is no subprotocol. + ...(handshake.protocol ? { headers: { "sec-websocket-protocol": handshake.protocol } } : {}), + }); + if (!upgraded) { + relay.abort(); + return new Response("websocket upgrade failed", { status: 400 }); + } + return undefined; +} + +/** Answer a failed upstream handshake: the upstream's own status and headers, or a 502. */ +function refusalResponse(head: string | null): Response { + if (head === null) return new Response("chatgpt upstream unreachable", { status: 502 }); + const [statusLine = "", ...headerLines] = head.split("\r\n"); + const match = /^HTTP\/1\.[01] (\d{3})(?: (.*))?$/.exec(statusLine); + const status = match ? Number(match[1]) : 502; + // A Response cannot carry a 1xx status; anything but 101 still means "no upgrade". + const safeStatus = status >= 200 && status <= 599 ? status : 502; + const headers = new Headers(); + for (const line of headerLines) { + const colon = line.indexOf(":"); + if (colon <= 0) continue; + const name = line.slice(0, colon).trim(); + if (!REFUSAL_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, line.slice(colon + 1).trim()); + } + return new Response(null, { status: safeStatus, statusText: match?.[2] ?? "", headers }); +} diff --git a/src/chatgpt/desktop-unblock/ws-upstream.ts b/src/chatgpt/desktop-unblock/ws-upstream.ts new file mode 100644 index 00000000000..020f60b903c --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-upstream.ts @@ -0,0 +1,273 @@ +import { connect as connectSocket } from "node:net"; +import { connect as connectTls } from "node:tls"; +import { effectiveProxyFor } from "../../lib/proxy-env"; +import { socks5Credentials, socks5Handshake } from "../../lib/socks5-handshake"; +import type { Socket } from "node:net"; +import type { TLSSocket } from "node:tls"; + +/** + * Upstream transport for the ChatGPT desktop intercept's WebSocket relay. + * + * Bun's WebSocket client ignores proxy environment variables and has no proxy option + * (verified on Bun 1.4.0), so the relay dials chatgpt.com itself over a raw socket it + * fully controls. The dial honors the same proxy selection as every other outbound + * request the server makes: `effectiveProxyFor` reads HTTP(S)_PROXY/ALL_PROXY, which + * `applyProxyEnv` populates from `config.proxy` at startup. A configured http(s) proxy + * is reached through an HTTP CONNECT tunnel; a SOCKS5 ALL_PROXY through a SOCKS5 CONNECT + * (sharing `src/lib/socks5-handshake.ts` with the fetch tunnel, so a credentialed + * `socks5://` proxy authenticates on both routes); no proxy means a direct TLS + * connection. The VPN's own mode (system proxy / TUN / off) therefore never has to be + * detected: the tunnel rides whatever egress opencodex already uses for provider traffic. + */ + +export const CHATGPT_UPSTREAM_HOST = "chatgpt.com"; +export const CHATGPT_UPSTREAM_TLS_PORT = 443; + +/** How the tunnel reached chatgpt.com; surfaced for tests and diagnostics. */ +export interface UpstreamTunnel { + socket: TLSSocket; + route: "direct" | "http-connect" | "socks5"; +} + +export interface DialUpstreamOptions { + /** Override the proxy picked from the environment; tests use it to point at a local proxy. */ + proxy?: string | null; + /** Connect timeout for the TCP dial and the proxy handshake, milliseconds. */ + connectTimeoutMs?: number; + /** Test seam: dial this address instead of chatgpt.com:443 (SNI still names chatgpt.com). */ + target?: { host: string; port: number }; + /** Test seam: trust this CA for the upstream certificate instead of the system store. */ + ca?: string; +} + +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; + +const CRLF = "\r\n"; + +/** + * Establish the TLS connection to chatgpt.com the relay pipes frames through. + * Resolves null when the TCP dial, the proxy handshake, or the TLS handshake fails + * within the timeout, so the fetch handler can answer the app with a plain 502 + * instead of hanging the upgrade. + */ +export async function dialUpstreamTunnel(options: DialUpstreamOptions = {}): Promise { + const timeout = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + const proxy = options.proxy !== undefined + ? options.proxy + : effectiveProxyFor(new URL(`https://${CHATGPT_UPSTREAM_HOST}`), process.env); + const route: UpstreamTunnel["route"] = socks5Route(proxy) ? "socks5" : proxy ? "http-connect" : "direct"; + try { + const target = options.target ?? { host: CHATGPT_UPSTREAM_HOST, port: CHATGPT_UPSTREAM_TLS_PORT }; + const raw = await dialRaw(target, proxy, route, timeout, options.ca); + const socket = await wrapTls(raw, timeout, options.ca); + return { socket, route }; + } catch { + return null; + } +} + +interface RawTarget { + host: string; + port: number; +} + +function socks5Route(proxy: string | null): boolean { + return proxy !== null && /^socks5h?:\/\//i.test(proxy.trim()); +} + +function isIpLiteral(host: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":"); +} + +/** + * Port to dial for a proxy URL. `URL.port` is empty both when the port is omitted and when it + * equals the scheme default, so `http://proxy:80` must fall back to 80, never to an invented + * 8080; that would diverge from the fetch tunnel, which honors the URL as written. + */ +export function proxyDialPort(proxyUrl: URL, route: UpstreamTunnel["route"]): number { + const explicit = Number(proxyUrl.port); + if (explicit) return explicit; + if (route === "socks5") return 1080; + return proxyUrl.protocol === "https:" ? 443 : 80; +} + +async function dialRaw(target: RawTarget, proxy: string | null, route: UpstreamTunnel["route"], timeout: number, ca: string | undefined): Promise { + if (route === "direct") return tcpConnect(target.host, target.port, timeout); + const proxyUrl = new URL(proxy!); + const proxyHost = proxyUrl.hostname.replace(/^\[|\]$/g, ""); + const proxyPort = proxyDialPort(proxyUrl, route); + const proxySocket = await tcpConnect(proxyHost, proxyPort, timeout); + // An https:// proxy speaks TLS on its own port before any handshake, so the CONNECT + // request must ride that TLS session, with the proxy's hostname as the SNI. + const plain = proxyUrl.protocol === "https:" ? await wrapProxyTls(proxySocket, proxyHost, timeout, ca) : proxySocket; + if (plain !== proxySocket) { + plain.once("error", () => proxySocket.destroy()); + } + const reader = new ProxyHandshakeReader(plain, timeout); + const socks5 = route === "socks5"; + try { + // A socks5:// URL with credentials must authenticate here exactly as the fetch tunnel + // would with the same URL; refusing selection instead would disable voice and dictation + // for proxies fetch traffic handles fine. Credential decoding stays inside the try so a + // malformed URL destroys the proxy socket instead of leaking it. + const credentials = socks5 ? socks5Credentials(proxyUrl) : {}; + if (!socks5) await httpConnectThrough(reader, target, proxyUrl); + else await socks5Handshake(reader, target, credentials); + } catch (error) { + reader.dispose(); + plain.destroy(); + throw error; + } + // Handshake done: hand leftover bytes and data events back to the socket so the TLS + // layer above starts from a clean stream. + reader.dispose(); + return plain; +} + +async function tcpConnect(host: string, port: number, timeout: number): Promise { + return new Promise((resolve, reject) => { + const socket = connectSocket({ host, port }); + const onError = (error: Error) => { socket.destroy(); reject(error); }; + socket.setTimeout(timeout, () => onError(new Error("tcp connect timeout"))); + socket.once("error", onError); + socket.once("connect", () => { + socket.setTimeout(0); + socket.removeListener("error", onError); + resolve(socket); + }); + }); +} + +/** + * Accumulates proxy-handshake bytes until each awaited step has what it needs, then + * hands any leftover bytes back to the socket so the TLS layer above sees a clean + * stream. A socket error, timeout, or a proxy that closes the connection before finishing + * its reply fails every pending step, so the upgrade answers 502 instead of hanging; after + * `dispose()` the reader no longer owns the socket's data, end, or close events. + */ +class ProxyHandshakeReader { + private buffer: Buffer = Buffer.alloc(0); + private pending: { ready: (buffer: Buffer) => boolean; resolve: () => void; reject: (error: Error) => void } | null = null; + private failure: Error | null = null; + private readonly onData = (chunk: Buffer) => this.feed(chunk); + private readonly onError = (error: Error) => this.fail(error); + private readonly onTimeout = () => this.fail(new Error("proxy handshake timeout")); + private readonly onClosed = () => this.fail(new Error("proxy closed the connection during the handshake")); + + constructor(private readonly socket: Socket, timeout: number) { + socket.on("data", this.onData); + socket.on("error", this.onError); + socket.on("end", this.onClosed); + socket.on("close", this.onClosed); + socket.setTimeout(timeout, this.onTimeout); + } + + write(bytes: Uint8Array | string): void { + this.socket.write(bytes); + } + + /** Await until the buffer holds at least `bytes` bytes, then consume exactly that many. */ + readExact(bytes: number): Promise> { + return this.wait(buffer => buffer.length >= bytes, () => this.consume(bytes)); + } + + /** Await the full HTTP response head (through the blank line), consuming it. */ + readHttpHead(): Promise { + return this.wait( + buffer => buffer.indexOf("\r\n\r\n") !== -1, + () => this.consume(this.buffer.indexOf("\r\n\r\n") + 4).toString("latin1"), + ); + } + + /** Only one handshake step is ever outstanding, so one pending slot suffices. */ + private wait(ready: (buffer: Buffer) => boolean, take: () => T): Promise { + if (this.failure) return Promise.reject(this.failure); + if (ready(this.buffer)) return Promise.resolve(take()); + return new Promise((resolve, reject) => { + this.pending = { ready, resolve: () => resolve(take()), reject }; + }); + } + + private consume(bytes: number): Buffer { + const consumed: Buffer = this.buffer.subarray(0, bytes); + this.buffer = this.buffer.subarray(bytes); + return consumed; + } + + private feed(chunk: Buffer): void { + this.buffer = this.buffer.length === 0 ? (chunk satisfies Buffer) : Buffer.concat([this.buffer, chunk]); + if (this.pending && this.pending.ready(this.buffer)) { + const waiter = this.pending; + this.pending = null; + waiter.resolve(); + } + } + + private fail(error: Error): void { + this.failure = error; + const waiter = this.pending; + this.pending = null; + waiter?.reject(error); + } + + dispose(): void { + this.socket.removeListener("data", this.onData); + this.socket.removeListener("error", this.onError); + this.socket.removeListener("end", this.onClosed); + this.socket.removeListener("close", this.onClosed); + this.socket.setTimeout(0); + if (this.buffer.length > 0) this.socket.unshift(this.buffer); + this.buffer = Buffer.alloc(0); + this.fail(new Error("proxy handshake reader disposed")); + } +} + +async function httpConnectThrough(reader: ProxyHandshakeReader, target: RawTarget, proxyUrl: URL): Promise { + const authority = `${target.host}:${target.port}`; + const lines = [ + `CONNECT ${authority} HTTP/1.1`, + `Host: ${authority}`, + `Proxy-Connection: Keep-Alive`, + ]; + // Credentials in the proxy URL become Basic Proxy-Authorization; an unauthenticated + // proxy never sees the header. + if (proxyUrl.username) { + const credentials = Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString("base64"); + lines.push(`Proxy-Authorization: Basic ${credentials}`); + } + reader.write([...lines, "", ""].join(CRLF)); + const head = await reader.readHttpHead(); + const statusLine = head.split(CRLF)[0]!; + if (!/^HTTP\/1\.[01] 2\d\d/.test(statusLine)) throw new Error(`proxy refused CONNECT: ${statusLine}`); +} + +async function wrapTls(raw: Socket, timeout: number, ca: string | undefined): Promise { + return new Promise((resolve, reject) => { + const tls = connectTls({ socket: raw, servername: CHATGPT_UPSTREAM_HOST, ...(ca ? { ca } : {}) }); + const onError = (error: Error) => { tls.destroy(); reject(error); }; + tls.setTimeout(timeout, () => onError(new Error("TLS handshake timeout"))); + tls.once("error", onError); + tls.once("secureConnect", () => { + tls.setTimeout(0); + tls.removeListener("error", onError); + resolve(tls); + }); + }); +} + +/** TLS-wrap the proxy's own socket before the CONNECT handshake (https:// proxy URLs). */ +async function wrapProxyTls(raw: Socket, proxyHost: string, timeout: number, ca: string | undefined): Promise { + // An IP-literal proxy has no hostname to name in SNI; node:tls forbids it outright. + const servername = isIpLiteral(proxyHost) ? undefined : proxyHost; + return new Promise((resolve, reject) => { + const tls = connectTls({ socket: raw, ...(servername ? { servername } : {}), ...(ca ? { ca } : {}) }); + const onError = (error: Error) => { tls.destroy(); reject(error); }; + tls.setTimeout(timeout, () => onError(new Error("proxy TLS handshake timeout"))); + tls.once("error", onError); + tls.once("secureConnect", () => { + tls.setTimeout(0); + tls.removeListener("error", onError); + resolve(tls); + }); + }); +} diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index accda5db994..3db69fb440d 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -97,10 +97,10 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [ export const CAPABILITIES: readonly Capability[] = [ { command: ["chatgpt"], - summary: "Experimental ChatGPT app-server shim: launch, restore or status (macOS only).", + summary: "Experimental ChatGPT shim/intercept: launch, restore, status and watcher management (macOS only).", routes: [], flags: [], mutates: true, json: "none", - bannerLines: ["ocx chatgpt Experimental app-server shim: launch|restore|status (macOS)"], - details: ["Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher."], + bannerLines: ["ocx chatgpt Experimental shim/intercept: launch|restore|status|install-watcher|uninstall-watcher (macOS)"], + details: ["Default off; launch requires chatgptDesktop.appServerShim or unblockSend. Intercept needs the running proxy and manual CA trust. Watcher manages intercept launches only; restore removes the shim launcher."], }, { command: ["link", "port"], diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 81c1f96f9c5..d915484c74f 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -2,6 +2,18 @@ import { spawnSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { loadConfig } from "../config"; +import type { OcxConfig } from "../types"; +import { findLiveProxy } from "../server/proxy-liveness"; +import { getConfigDir } from "../config/paths"; +import { claudeInterceptCaCertPath } from "../claude/intercept/local-ca"; +import { chatgptCaTrustCommand, inspectChatgptCaTrust } from "../chatgpt/desktop-unblock/ca-trust"; +import { chatgptUnblockPort, chatgptUnblockResolverArg } from "../chatgpt/desktop-unblock/runtime"; +import { + chatgptAppCommandLine, chatgptCommandLineHasRule, chatgptUnblockWatcherStatus, + installChatgptUnblockWatcher, launchChatgptWithRule, probeChatgptUnblockListener, + uninstallChatgptUnblockWatcher, +} from "../chatgpt/desktop-unblock/launch-watcher"; +import { interactiveConfirm } from "./interactive-confirm"; import { chatgptShimLauncherPath, resolveChatgptCodexBinary, @@ -12,9 +24,20 @@ import { darwinDefaultExec, darwinDesktopAppAdapter } from "../codex/desktop-app import type { DesktopAppInstall } from "../codex/desktop-app/types"; const USAGE = `Usage (experimental, macOS only): - ocx chatgpt launch Relaunch ChatGPT with the experimental app-server shim (requires appServerShim: true) - ocx chatgpt restore Relaunch ChatGPT without the experimental shim and remove its launcher - ocx chatgpt status Inspect experimental flag, launcher and running app environment`; + ocx chatgpt launch Relaunch with configured app-server shim and/or TLS intercept + ocx chatgpt restore Relaunch with native networking and remove the shim launcher + ocx chatgpt status Inspect flags, listener, CA trust, watcher and app environment + ocx chatgpt install-watcher [--yes] Install the intercept launch watcher (requires unblockSend: true) + ocx chatgpt uninstall-watcher Remove the intercept launch watcher`; + +export function resolveChatgptUnblockPort(config: OcxConfig, livePort: number | undefined): number { + return chatgptUnblockPort(config, livePort ?? config.port ?? 10100); +} + +const WATCHER_CONSENT = `The experimental watcher quits and reopens ChatGPT after a Dock/Spotlight launch +without intercept switches while opencodex's listener is running. This can interrupt +startup work. It manages the TLS intercept only; use 'ocx chatgpt launch' for the shim. +Remove it with 'ocx chatgpt uninstall-watcher'.`; function run(command: string, args: string[]) { const result = spawnSync(command, args, { encoding: "utf8", timeout: 5000 }); @@ -76,16 +99,27 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat console.log(USAGE); return sub ? 0 : 64; } - if (!["launch", "restore", "status"].includes(sub) || args.length !== 1) { + if (!["launch", "restore", "status", "install-watcher", "uninstall-watcher"].includes(sub) + || (sub === "install-watcher" ? args.slice(1).some(arg => arg !== "--yes") || args.length > 2 : args.length !== 1)) { console.error(USAGE); return 64; } if (platform !== "darwin") { - console.error("ChatGPT app-server shim (experimental): macOS only."); + console.error("ChatGPT desktop integrations (experimental): macOS only."); return 1; } try { + // Removal remains available even if the hand-edited config or derived port is invalid. + if (sub === "uninstall-watcher") { + uninstallChatgptUnblockWatcher(); + console.log("Experimental intercept launch watcher removed."); + return 0; + } const config = loadConfig(); + const intercept = config.chatgptDesktop?.unblockSend === true; + const shim = config.chatgptDesktop?.appServerShim === true; + const live = intercept || sub === "status" ? await findLiveProxy().catch(() => null) : null; + const port = intercept && sub !== "restore" && sub !== "status" ? resolveChatgptUnblockPort(config, live?.port) : undefined; const launcher = chatgptShimLauncherPath(); const install = discoverApp(); if (sub === "status") { @@ -94,34 +128,73 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat launcher: ${existsSync(launcher) ? "present" : "absent"} app: ${install ? (app.running ? "running" : "not running") : "not installed"} CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); + try { + await printInterceptStatus(config, resolveChatgptUnblockPort(config, live?.port)); + } catch (error) { + console.log(`Intercept status unavailable: ${error instanceof Error ? error.message : String(error)}`); + } return 0; } - if (!install) { + if (sub === "install-watcher") { + if (!intercept || config.runtimeRole === "client") { + console.error("Enable chatgptDesktop.unblockSend in a server config before installing the watcher."); + return 1; + } + console.log(WATCHER_CONSENT); + if (!args.includes("--yes") && (!process.stdin.isTTY + || !(await interactiveConfirm({ question: "Install the experimental intercept watcher?", defaultYes: false })))) { + console.error("Watcher not installed; re-run with --yes to confirm."); + return 1; + } + installChatgptUnblockWatcher({ port: port! }); + console.log(`Experimental intercept launch watcher installed for port ${port}.`); + return 0; + } + if ((sub === "launch" || sub === "restore") && !install) { if (sub === "restore") rmSync(launcher, { force: true }); console.error("ChatGPT (com.openai.codex) was not found; install or open it once, then retry."); return 1; } let binary: string | undefined; if (sub === "launch") { - if (config.chatgptDesktop?.appServerShim !== true) { - console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.'); + if (!shim && !intercept) { + console.error("Enable chatgptDesktop.appServerShim or chatgptDesktop.unblockSend before launching."); return 1; } - binary = resolveChatgptCodexBinary(install.root) ?? undefined; - if (!binary) { - console.error(`No bundled app-server binary was found in ${install.root}; the shim cannot launch this build.`); - return 1; + if (intercept) { + if (config.runtimeRole === "client" || (await probeChatgptUnblockListener(port!)).state !== "ours") { + console.error("Experimental intercept listener is not answering; start opencodex in server mode first."); + return 1; + } + } + if (shim) { + binary = resolveChatgptCodexBinary(install!.root) ?? undefined; + if (!binary) { + console.error(`No bundled app-server binary was found in ${install!.root}; the shim cannot launch this build.`); + return 1; + } } } - // Both relaunch paths execute the discovered bundle. Restore validates the app - // shell without requiring an app-server binary or the experimental opt-in flag. - const untrusted = untrustedChatgptBundleReason(install.root, binary); + // Every relaunch path executes the discovered bundle, the intercept one included. Restore + // validates the app shell without requiring an app-server binary or an experimental opt-in. + const untrusted = untrustedChatgptBundleReason(install!.root, binary); if (untrusted) { - console.error(`Refusing to ${sub === "launch" ? "launch the shim" : "restore ChatGPT"}: ${untrusted}.`); + console.error(`Refusing to ${sub === "launch" ? (shim ? "launch the shim" : "launch ChatGPT") : "restore ChatGPT"}: ${untrusted}.`); + return 1; + } + // A loaded watcher would immediately put the intercept switches back on restore. + if (sub === "restore" && chatgptUnblockWatcherStatus(0).agentLoaded) { + console.error("Uninstall the launch watcher before restoring native networking."); return 1; } if (binary) writeChatgptShimLauncher(undefined, binary); - if (!(await quitApp(install, launcher))) { + if (sub === "launch" && intercept) { + // The intercept script owns the restart under its lock so the watcher cannot race it. + const result = launchChatgptWithRule(port!, undefined, shim); + if (result.output) (result.ok ? console.log : console.error)(result.output); + return result.ok ? 0 : 1; + } + if (!(await quitApp(install!, launcher))) { console.error("ChatGPT did not quit; quit it manually and retry."); return 1; } @@ -129,15 +202,36 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); const env = { ...process.env }; delete env.CODEX_CLI_PATH; // Open the verified bundle by path, so the relaunch is the same app that was quit. - const result = spawnSync("/usr/bin/open", ["-a", install.root, ...(sub === "launch" ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { + const result = spawnSync("/usr/bin/open", ["-a", install!.root, ...(sub === "launch" && shim ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { encoding: "utf8", env, timeout: 10000, }); if (result.status !== 0) throw new Error(result.error?.message ?? (result.stderr?.trim() || "open failed")); if (sub === "restore") rmSync(launcher, { force: true }); - console.log(`ChatGPT relaunched ${sub === "launch" ? "with" : "without"} the experimental app-server shim.`); + console.log(`ChatGPT relaunched ${sub === "launch" ? "with" : "without"} the configured experimental integrations.`); return 0; } catch (error) { - console.error(`ChatGPT shim (experimental): ${error instanceof Error ? error.message : String(error)}`); + console.error(`ChatGPT desktop (experimental): ${error instanceof Error ? error.message : String(error)}`); return 1; } } + +async function printInterceptStatus(config: OcxConfig, port: number): Promise { + const listener = await probeChatgptUnblockListener(port); + const watcher = chatgptUnblockWatcherStatus(port); + const app = chatgptAppCommandLine(); + const flagged = app !== null && chatgptCommandLineHasRule(app, port); + const caPath = claudeInterceptCaCertPath(getConfigDir()); + const trust = await inspectChatgptCaTrust(caPath); + console.log(`send-unblock TLS intercept (experimental): ${config.chatgptDesktop?.unblockSend === true ? "on" : "off"} +listener: ${port} (${listener.state === "ours" ? "listening" : listener.state === "foreign" ? "held by another process" : "not listening"}) +resolver switch: ${chatgptUnblockResolverArg(port)} +CA trust: ${trust} +watcher script: ${watcher.scriptInstalled ? watcher.scriptUpToDate ? "installed" : "outdated; reinstall" : "absent"} +watcher agent: ${watcher.agentLoaded ? "loaded" : watcher.plistInstalled ? "installed but not loaded" : "absent"} +app intercept switches: ${flagged ? "yes" : "no"}`); + if (trust === "untrusted") console.log(`Trust manually with: ${chatgptCaTrustCommand(caPath)}`); + if (listener.state === "ours") { + for (const block of listener.preservedSendBlocks) console.log(`Send block kept: ${block.name}: ${block.reason} (last seen ${block.lastSeen})`); + } + if (flagged && listener.state !== "ours") console.log("ChatGPT is routed to a closed or foreign port; start opencodex again or run ocx chatgpt restore."); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index e44c1391ec8..ce6374a2af0 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -97,7 +97,7 @@ Usage: ocx config Validated configuration show/get/set/import/export ocx companion Menu-bar and widget companion usage settings ocx lab Read-only Compatibility Lab projection inspection - ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) + ocx chatgpt Experimental shim/intercept: launch|restore|status|install-watcher|uninstall-watcher (macOS) ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 87ac813ef9b..26f57d8d563 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -10,9 +10,9 @@ export interface CliCommandEntry { export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "chatgpt", - usage: "ocx chatgpt ", - summary: "Experimental ChatGPT app-server shim (macOS only, default off).", - details: ["Experimental launch requires chatgptDesktop.appServerShim: true; restore removes its launcher."], + usage: "ocx chatgpt ", + summary: "Experimental ChatGPT app-server shim and TLS intercept (macOS only, default off).", + details: ["Launch requires appServerShim or unblockSend; intercept needs a running proxy and manual CA trust. install-watcher [--yes] manages intercept only; uninstall-watcher removes it. Restore removes the shim launcher."], }, { name: "init", diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 8037724c950..3c808e67da8 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -109,6 +109,9 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") { warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform"); } + if (normalized.chatgptDesktop?.unblockSend === true && process.platform !== "darwin") { + warnings.push("chatgptDesktop.unblockSend is experimental and macOS only; ignored on this platform"); + } warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); warnings.push(...degradedListenerWarnings(rawParsed, normalized)); @@ -636,8 +639,13 @@ function skillsConfigError(value: unknown): string | null { export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const chatgptDesktop = rawConfigRecord(value)?.chatgptDesktop; - if (chatgptDesktop !== undefined && !chatgptDesktopSchema.safeParse(chatgptDesktop).success) { - return { ok: false, error: "schema_invalid: chatgptDesktop: requires an optional boolean appServerShim and no other fields" }; + if (chatgptDesktop !== undefined) { + const parsed = chatgptDesktopSchema.safeParse(chatgptDesktop); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const field = issue?.path.map(String).join("."); + return { ok: false, error: `schema_invalid: chatgptDesktop${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}` }; + } } const compactionRouting = rawConfigRecord(value)?.compactionRouting; if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 0ac3a0c3ed8..7e723408111 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -75,6 +75,7 @@ import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/inte import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; export const configSchema = z.object({ + // A malformed desktop leaf disables its optional integrations on read; writes validate strictly. chatgptDesktop: chatgptDesktopSchema.optional().catch(undefined), codexNativeSteering: z.boolean().optional().catch(false), codexNativeInjection: z.boolean().optional().catch(false), diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index fd0d7cbfc07..73df77f2228 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -44,7 +44,11 @@ import { getConfigDir } from "../paths"; import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "./compaction-triggers"; /** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ -export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); +export const chatgptDesktopSchema = z.object({ + appServerShim: z.boolean().optional(), + unblockSend: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), +}).strict(); /** One definition of "usable secret", shared by the schema and the warnings. */ export function isUsableApiKeySecret(value: unknown): value is string { diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index 52bdc3d3b50..a5f6ad9be63 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -1,6 +1,7 @@ import net, { type Socket } from "node:net"; import tls, { type TLSSocket } from "node:tls"; import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics"; +import { socks5Credentials, socks5Handshake } from "./socks5-handshake"; const DEFAULT_SOCKS5_PORT = 1080; const SOCKS5_CONNECT_TIMEOUT_MS = 30_000; @@ -9,12 +10,6 @@ const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; const MAX_BODY_SLICE_BYTES = 64 * 1024; const MAX_DECODED_BODY_BYTES = 32 * 1024 * 1024; const MAX_STREAM_DECODE_EXPANSION_RATIO = 128; -const SOCKS5_VERSION = 0x05; -const SOCKS5_NO_AUTH = 0x00; -const SOCKS5_USER_PASS = 0x02; -const SOCKS5_CONNECT = 0x01; -const SOCKS5_DOMAIN = 0x03; -const SOCKS5_SUCCESS = 0x00; const CRLF = Buffer.from("\r\n"); const HEADER_END = Buffer.from("\r\n\r\n"); @@ -22,24 +17,6 @@ export class Socks5FetchError extends Error { override readonly name = "Socks5FetchError"; } -function proxyCredentials(proxy: URL): { username?: Uint8Array; password?: Uint8Array } { - if (!proxy.username && !proxy.password) return {}; - let username: string; - let password: string; - try { - username = decodeURIComponent(proxy.username); - password = decodeURIComponent(proxy.password); - } catch { - throw new Socks5FetchError("SOCKS5 proxy credentials contain invalid percent encoding"); - } - const usernameBytes = new TextEncoder().encode(username); - const passwordBytes = new TextEncoder().encode(password); - if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) { - throw new Socks5FetchError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes"); - } - return { username: usernameBytes, password: passwordBytes }; -} - function validateProxy(proxy: string): URL { let parsed: URL; try { @@ -55,7 +32,7 @@ function validateProxy(proxy: string): URL { throw new Socks5FetchError("SOCKS5 proxy port is invalid"); } if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment"); - proxyCredentials(parsed); + socks5Credentials(parsed); return parsed; } @@ -130,6 +107,14 @@ class SocketReader { socket.resume(); } + write(bytes: Uint8Array): void { + this.socket.write(bytes); + } + + readExact(bytes: number, signal?: AbortSignal): Promise { + return this.read(bytes, signal); + } + private readonly onData = (chunk: Buffer | string): void => { const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; if (this.anyWaiters.length > 0) { @@ -263,7 +248,7 @@ class SocketReader { async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise { const parsedProxy = validateProxy(proxy); - const credentials = proxyCredentials(parsedProxy); + const credentials = socks5Credentials(parsedProxy); const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, ""); const socket = await connectSocket(proxyHost, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal); socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => { @@ -271,45 +256,7 @@ async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): }); const reader = new SocketReader(socket); try { - const methods = credentials.username ? Buffer.from([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) : Buffer.from([SOCKS5_NO_AUTH]); - socket.write(Buffer.from([SOCKS5_VERSION, methods.byteLength, ...methods])); - const greeting = await reader.read(2, signal); - if (greeting[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid greeting"); - if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { - socket.write(Buffer.from([ - 0x01, - credentials.username.byteLength, - ...credentials.username, - credentials.password.byteLength, - ...credentials.password, - ])); - const auth = await reader.read(2, signal); - if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5FetchError("SOCKS5 proxy authentication failed"); - } else if (greeting[1] !== SOCKS5_NO_AUTH) { - throw new Socks5FetchError("SOCKS5 proxy does not accept an offered authentication method"); - } - - const hostname = new TextEncoder().encode(target.hostname); - if (hostname.byteLength > 255) throw new Socks5FetchError("SOCKS5 target hostname is too long"); - const port = targetPort(target); - socket.write(Buffer.from([ - SOCKS5_VERSION, - SOCKS5_CONNECT, - 0x00, - SOCKS5_DOMAIN, - hostname.byteLength, - ...hostname, - port >> 8, - port & 0xff, - ])); - const reply = await reader.read(4, signal); - if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response"); - if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); - if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) { - throw new Socks5FetchError("SOCKS5 proxy returned an invalid address type or reserved byte"); - } - const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16; - await reader.read(addressLength + 2, signal); + await socks5Handshake(reader, { host: target.hostname, port: targetPort(target) }, credentials, signal); socket.setTimeout(0); return socket; } catch (error) { diff --git a/src/lib/socks5-handshake.ts b/src/lib/socks5-handshake.ts new file mode 100644 index 00000000000..3b1e1306bb0 --- /dev/null +++ b/src/lib/socks5-handshake.ts @@ -0,0 +1,112 @@ +/** + * The SOCKS5 handshake both raw outbound transports share: method negotiation + * (RFC 1928), the optional RFC 1929 username/password subnegotiation, and the + * CONNECT exchange. + * + * `src/lib/socks5-fetch.ts` (the HTTP tunnel) and + * `src/chatgpt/desktop-unblock/ws-upstream.ts` (the ChatGPT desktop relay's raw + * dial) each own their socket, their timeouts, and what happens after CONNECT; + * this module only frames bytes through the reader each hands it, so a + * credentialed `socks5://` proxy URL authenticates identically whether the caller + * is a fetch or a raw WebSocket upgrade. When the URL carries credentials the + * greeting offers NO-AUTH alongside USER-PASS, which keeps an unauthenticated + * proxy working for a caller that configured more than it needed. + */ + +export class Socks5HandshakeError extends Error { + override readonly name = "Socks5HandshakeError"; +} + +const SOCKS5_VERSION = 0x05; +const SOCKS5_NO_AUTH = 0x00; +const SOCKS5_USER_PASS = 0x02; +const SOCKS5_CONNECT = 0x01; +const SOCKS5_DOMAIN = 0x03; +const SOCKS5_SUCCESS = 0x00; + +/** RFC 1929 credentials decoded from the proxy URL, each bounded to one length byte. */ +export interface Socks5Credentials { + username?: Uint8Array; + password?: Uint8Array; +} + +export function socks5Credentials(proxy: URL): Socks5Credentials { + if (!proxy.username && !proxy.password) return {}; + let username: string; + let password: string; + try { + username = decodeURIComponent(proxy.username); + password = decodeURIComponent(proxy.password); + } catch { + throw new Socks5HandshakeError("SOCKS5 proxy credentials contain invalid percent encoding"); + } + const usernameBytes = new TextEncoder().encode(username); + const passwordBytes = new TextEncoder().encode(password); + if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) { + throw new Socks5HandshakeError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes"); + } + return { username: usernameBytes, password: passwordBytes }; +} + +/** + * The slice of a transport's socket reader the handshake needs: exact-length reads + * that reject on close, on error, or on the transport's own timeout, plus writes. + * Bytes read past a step stay queued in the transport's reader; the handshake never + * touches the socket itself. + */ +export interface Socks5HandshakeReader { + write(bytes: Uint8Array): void; + readExact(bytes: number, signal?: AbortSignal): Promise; +} + +export interface Socks5Target { + host: string; + port: number; +} + +/** Negotiate methods, authenticate when the proxy picks USER-PASS, and CONNECT to `target`. */ +export async function socks5Handshake( + reader: Socks5HandshakeReader, + target: Socks5Target, + credentials: Socks5Credentials, + signal?: AbortSignal, +): Promise { + const hostBytes = Buffer.from(target.host, "utf8"); + if (hostBytes.byteLength > 255) throw new Socks5HandshakeError("SOCKS5 target hostname is too long"); + const methods = credentials.username ? [SOCKS5_NO_AUTH, SOCKS5_USER_PASS] : [SOCKS5_NO_AUTH]; + reader.write(Buffer.from([SOCKS5_VERSION, methods.length, ...methods])); + const greeting = await reader.readExact(2, signal); + if (greeting[0] !== SOCKS5_VERSION) throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid greeting"); + if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { + reader.write(Buffer.from([ + 0x01, + credentials.username.byteLength, + ...credentials.username, + credentials.password.byteLength, + ...credentials.password, + ])); + const auth = await reader.readExact(2, signal); + if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5HandshakeError("SOCKS5 proxy authentication failed"); + } else if (greeting[1] !== SOCKS5_NO_AUTH) { + throw new Socks5HandshakeError("SOCKS5 proxy does not accept an offered authentication method"); + } + + reader.write(Buffer.from([ + SOCKS5_VERSION, + SOCKS5_CONNECT, + 0x00, + SOCKS5_DOMAIN, + hostBytes.byteLength, + ...hostBytes, + target.port >> 8, + target.port & 0xff, + ])); + const reply = await reader.readExact(4, signal); + if (reply[0] !== SOCKS5_VERSION) throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid connect response"); + if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5HandshakeError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); + if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) { + throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid address type or reserved byte"); + } + const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.readExact(1, signal))[0]! : 16; + await reader.readExact(addressLength + 2, signal); +} diff --git a/src/server/index/chatgpt-unblock-lifecycle.ts b/src/server/index/chatgpt-unblock-lifecycle.ts new file mode 100644 index 00000000000..61ce80c93a4 --- /dev/null +++ b/src/server/index/chatgpt-unblock-lifecycle.ts @@ -0,0 +1,70 @@ +import type { OcxConfig } from "../../types"; +import type { ChatgptUnblockHandle, StartChatgptUnblockOptions } from "../../chatgpt/desktop-unblock/runtime"; + +/** + * Owns the ChatGPT desktop send-unblock listener on behalf of `startServer`. The listener is + * an optional integration: a bind failure degrades to a warning, never to a startup failure, + * because every other duty keeps working without it. `startServer` stays synchronous, so the + * start is fire-and-forget and `stop()` awaits whatever it produced. + * + * The intercept modules (TLS listener, local CA, WebSocket relay, launch watcher) load only when + * the feature is enabled. A default install starts the server without evaluating any of them, + * the same way the other optional subsystems stay off the core startup path. + * + * Stopping cannot take the resolver switch back out of a running ChatGPT app, so a stop that + * leaves the app routed at the now-closed port says so and names the way back to native + * networking. + */ +export interface ChatgptUnblockLifecycle { + start(options: StartChatgptUnblockOptions): void; + stop(): Promise; +} + +/** Mirrors `chatgptUnblockEnabled` without loading the runtime module to ask. */ +function unblockRequested(config: Pick): boolean { + return process.platform === "darwin" && config.runtimeRole !== "client" && config.chatgptDesktop?.unblockSend === true; +} + +export function createChatgptUnblockLifecycle(): ChatgptUnblockLifecycle { + let pending: Promise | null> = Promise.resolve(null); + return { + start(options) { + if (!unblockRequested(options.config)) { + pending = Promise.resolve(null); + return; + } + pending = import("../../chatgpt/desktop-unblock/runtime") + .then(runtime => runtime.startChatgptUnblock(options)) + .then(handle => { + if (handle) { + console.log(`🔓 ChatGPT send-unblock active on https://127.0.0.1:${handle.port} (CA: ${handle.caCertPath})`); + console.log(" Launch the ChatGPT app with: ocx chatgpt launch (or `ocx chatgpt install-watcher` for Dock launches)"); + } + return handle; + }) + .catch((error: unknown) => { + console.warn(`⚠ ChatGPT send-unblock could not start: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + }, + async stop() { + const handle = await pending; + if (!handle) return; + await handle.stop(); + await warnIfAppStillRouted(handle.port); + }, + }; +} + +async function warnIfAppStillRouted(port: number): Promise { + if (process.platform !== "darwin") return; + try { + const { chatgptAppCommandLine, chatgptCommandLineHasRule } = await import("../../chatgpt/desktop-unblock/launch-watcher"); + const app = chatgptAppCommandLine(); + if (app === null) return; + if (!chatgptCommandLineHasRule(app, port)) return; + console.warn(`⚠ The ChatGPT app is still routed to port ${port}; its chatgpt.com requests fail until opencodex listens again.`); + console.warn(" To return it to native networking: ocx chatgpt restore"); + } catch { // no-excuse-ok: catch -- a diagnostic at shutdown must never fail the stop itself. + } +} diff --git a/src/server/index/optional-listeners.ts b/src/server/index/optional-listeners.ts index 7fbf5b14470..02e0a5b1a73 100644 --- a/src/server/index/optional-listeners.ts +++ b/src/server/index/optional-listeners.ts @@ -1,3 +1,4 @@ +import { createChatgptUnblockLifecycle } from "./chatgpt-unblock-lifecycle"; import { serviceApiTokenFingerprint } from "../../lib/service-secrets"; import type { Server } from "bun"; import type { OcxConfig } from "../../types"; @@ -47,6 +48,7 @@ export interface OptionalListenerSet { export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): OptionalListenerSet { const claudeIntercept: ClaudeInterceptLifecycle = createClaudeInterceptLifecycle(); + const chatgptUnblock = createChatgptUnblockLifecycle(); const linkListener: LinkListenerLifecycle = createLinkListenerLifecycle(linkDeps); let activeConfig: OcxConfig | undefined; const supervisor = createLinkSupervisor({ @@ -108,6 +110,7 @@ export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): O maxRequestBodySize: ctx.maxRequestBodySize, dispatch: ctx.dispatch, }); + chatgptUnblock.start({ config: ctx.config, publicPort: ctx.publicPort }); }, ensureStarted: () => linkListener.ensureStarted(), close: () => linkListener.close(), @@ -124,6 +127,7 @@ export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): O } try { await linkListener.stop(); } catch (error) { failure ??= error; } try { await claudeIntercept.stop(); } catch (error) { failure ??= error; } + try { await chatgptUnblock.stop(); } catch (error) { failure ??= error; } unregisterSupervisorAdmission?.(); unregisterSupervisorAdmission = undefined; if (failure) throw failure; diff --git a/src/types/config.ts b/src/types/config.ts index c771c6299ef..e35e8933db8 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -497,8 +497,8 @@ export interface OcxClientConnectionConfig { } export interface OcxConfig { - /** Experimental macOS app-server stdout shim; absent or false disables it. */ - chatgptDesktop?: { appServerShim?: boolean }; + /** Independent experimental macOS shim/intercept flags, default off; optional fixed TLS port. */ + chatgptDesktop?: { appServerShim?: boolean; unblockSend?: boolean; port?: number }; port: number; /** Runtime topology role. Absence preserves the historical standalone behavior. */ runtimeRole?: OcxRuntimeRole; diff --git a/structure/INDEX.md b/structure/INDEX.md index 2f7e3eea32c..5af28806d3b 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -81,7 +81,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. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | -| [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | +| [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim and local-CA TLS intercept, opt-in launch/watcher, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -139,6 +139,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/integrations/` | [`clients/integrations.md`](clients/integrations.md) | | `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/lib/socks5-handshake.ts` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/link/` | [`remote-link.md`](remote-link.md) | | `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/plugins/` | [`ops/plugins.md`](ops/plugins.md) | @@ -153,6 +154,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/routing/` | [`catalog.md`](catalog.md) | | `src/server/` | [`runtime.md`](runtime.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | +| `src/server/index/chatgpt-unblock-lifecycle.ts` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index daabf923d97..133fcf2ac38 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -1,9 +1,10 @@ -# ChatGPT Desktop app-server shim +# ChatGPT Desktop integrations The experimental macOS integration is owned by `src/chatgpt/` and exposed through -`src/cli/chatgpt-command.ts`. It is default off and requires -`chatgptDesktop.appServerShim === true` for an explicit launch. Its config leaf -accepts only an optional boolean; malformed reads disable the leaf, while live +`src/cli/chatgpt-command.ts`. It is default off; an explicit launch requires +`chatgptDesktop.appServerShim === true` or `chatgptDesktop.unblockSend === true`, and only the +shim launcher requires `appServerShim`. The strict config leaf accepts optional boolean `appServerShim` and `unblockSend` +flags and an optional integer `port` in 1..65535; malformed reads disable the leaf, while live writes reject malformed values and unknown fields. The launcher under the config directory re-enters the current CLI using @@ -61,13 +62,70 @@ app-server binary. It relaunches without that override and removes the launcher `com.openai.codex` bundle is found it removes the launcher, relaunches nothing and exits 1. Status reports the experimental flag, launcher presence, and the verified bundle process's override -without printing its environment. Other platforms reject all three operations. +without printing its environment. Other platforms reject the public operations. -The launcher and its executable paths are local code-execution inputs. This -integration installs no network listener, CA, PAC or background watcher, and -starts no proxy timer. Explicit launches are its only activation point. +The launcher and its executable paths are local code-execution inputs. The shim alone +installs no network listener, CA or background watcher and starts no proxy timer. +Explicit launches are its only activation point. Tests cover rewriting, byte framing, passthrough degradation, source/compiled launcher text, stub launcher execution, hidden preflight, and strict config writes in tests/clients/desktop-*.test.ts. Pipe-crash behavior is mock evidence only; no bundled-app respawn guarantee is asserted. + +## Optional local-CA send-unblock intercept + +`src/chatgpt/desktop-unblock/runtime.ts` enables the macOS listener only for +`chatgptDesktop.unblockSend === true` outside the client runtime role. The +independent `appServerShim` flag does not enable it. The TLS listener binds +127.0.0.1 on the explicit desktop port or public port + 200; an overflowing +port is rejected before creating a CA. It reuses the Claude local authority, +issues a leaf for chatgpt.com and never installs OS trust. The operator manually +adds that authority to the login keychain; this permits TLS termination of the +account's credentials and message traffic. Restore leaves the shared CA and +keychain trust in place. + +`src/server/index/chatgpt-unblock-lifecycle.ts` owns the pending startup promise +and awaited stop. `src/server/index/optional-listeners.ts` starts it without +suspending startServer; startup failures warn and keep other services available. +Shutdown warns when the app still carries the resolver rule for the closed port. +The three core entry points do not reach the new optional modules. The activation +window rationale in `tests/lab/core-lab-boundary.test.ts` records the synchronous +composition-root boundary. + +`src/chatgpt/desktop-unblock/listener.ts` forwards HTTP to the fixed chatgpt.com +upstream without following redirects. Only the conversation and usage surfaces +selected by `rewrite.ts` are rewritten. Conversation send blocks with known quota +reasons and exhausted send progress entries are removed; unknown non-quota blocks +remain. Usage gate rewriting imports the existing app-server shim gate owner +instead of maintaining a duplicate. Display data remains unchanged. JSON bodies +above the bounded rewrite ceiling stream through. SSE lines preserve untouched +framing. A local identity endpoint reports preserved block names/reasons and +timestamps; request bodies and credentials are not logged. + +`src/chatgpt/desktop-unblock/ws-relay.ts`, `ws-frame.ts` and `ws-upstream.ts` +relay upgrades and voice/dictation messages without rewriting. Upstream TLS +verification remains enabled; direct, HTTP(S) CONNECT and SOCKS5 routes follow +opencodex's outbound proxy selection. The shared method/auth/CONNECT framing +owner is `src/lib/socks5-handshake.ts`, also used by `src/lib/socks5-fetch.ts`. +Each transport still owns its socket, timeout, buffering and post-CONNECT work. + +`src/chatgpt/desktop-unblock/launch-watcher.ts` owns resolver/system-proxy launch +arguments and the optional launchd script. The watcher has no shim mode or PAC +fallback. Its launchd agent wakes on the app's `SingletonLock` and on the +`chatgpt-unblock.ready` marker that `startChatgptUnblock` rewrites once the listener is up, so an +app that opened before opencodex is still routed. Each run checks listener identity, leaves native +launches alone while the intercept is down, and serializes corrective restarts; an explicit launch +that meets another run's lock fails with a message instead of reporting success. In watch mode it +restarts only an app no more than five minutes old (`ps -o etime=`); a missing or unparseable age +counts as fresh, and explicit launch is not age-limited. A failed `open` exits non-zero rather +than printing success. +Explicit CLI launch can supply the existing shim's launcher environment along +with intercept arguments. A loaded watcher must be uninstalled before CLI +restore. The CLI preserves shim-only launch without a running proxy and reports +intercept port, trust, listener identity, watcher freshness and app switches. + +Intercept regression files use the existing `tests/clients/` domain alongside +the shim tests; shared handshake coverage is `tests/lib/socks5-handshake.test.ts`. +Both explicit layout maps register every added test basename. No PAC generator, +entry proxy, or duplicate app-server shim is part of this integration. diff --git a/structure/config.md b/structure/config.md index d51f33020c4..d71d1070157 100644 --- a/structure/config.md +++ b/structure/config.md @@ -597,4 +597,4 @@ so wrong types and unknown nested fields are rejected rather than silently saved `apiSurfaces` and `protocols` on `src/types/config.ts` are parsed by `src/protocols/settings.ts` only; [Protocol Paths](data-planes/protocol-paths.md#settings) owns their schema handling, meaning and the one writer (`PATCH /api/protocols/settings`), including why closing Messages also writes `claudeCode.enabled` through `commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, the sentinel-stamping block writer every management route uses). Stored Direct substitution follows the [credential identity contract](providers/openai-accounts.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. -Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts only optional boolean `appServerShim`, default off. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). `claudeCode.subagentModelForce` is an optional safe roster-style id. Invalid hand edits degrade with a warning without losing other config; stale but syntactically valid targets remain stored for repair and are skipped at launch. Management force updates are field-scoped against current disk state, preserving concurrent Claude sibling edits. A config absent at request start uses canonical create-only initialization; a racing file or invalid initial state refuses the save, and an existing-file mutation never recreates deleted config. Clearing removes only that leaf. +Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts optional boolean `appServerShim` and `unblockSend` flags, both default off, and an optional integer `port` in 1..65535. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). `claudeCode.subagentModelForce` is an optional safe roster-style id. Invalid hand edits degrade with a warning without losing other config; stale but syntactically valid targets remain stored for repair and are skipped at launch. Management force updates are field-scoped against current disk state, preserving concurrent Claude sibling edits. A config absent at request start uses canonical create-only initialization; a racing file or invalid initial state refuses the save, and an existing-file mutation never recreates deleted config. Clearing removes only that leaf. diff --git a/structure/manifest.json b/structure/manifest.json index 48d655727f8..4c98213b4d8 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -463,11 +463,13 @@ "path": "clients/chatgpt-desktop.md", "tier": 5, "title": "ChatGPT Desktop", - "scope": "Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries.", + "scope": "Experimental macOS app-server stdout shim and local-CA TLS intercept, opt-in launch/watcher, restore and failure boundaries.", "documents": [ "src/chatgpt/", "src/cli/", - "src/codex/" + "src/codex/", + "src/server/index/chatgpt-unblock-lifecycle.ts", + "src/lib/socks5-handshake.ts" ] }, { diff --git a/structure/runtime.md b/structure/runtime.md index 77476c0edc0..a4f58d19acf 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -237,7 +237,7 @@ The `hub-link` socket is HTTP-only and default-denies all but the fixed data rou usage, and `GET /readyz`; every `Upgrade` header, management, GUI, session, health, and unknown `/v1/*` route is rejected before dispatch. Its `opencodex-link.invalid` policy admits only configured key ids recorded by `links.json`, never the environment token. Context relays rebuild that policy for their post-body admission check, so key revocation stops an in-flight request before dispatch. `ensureStarted()` is single-flight, -final deletion closes the listener, and `src/server/index/optional-listeners.ts` runs supervisor teardown before closing this listener and the Claude intercept pair. +final deletion closes the listener, and `src/server/index/optional-listeners.ts` runs supervisor teardown before closing this listener, the Claude intercept pair and the optional ChatGPT send-unblock listener. ### Claude intercept pair At the end of the startup transaction, `startServer` also starts the optional Claude intercept pair @@ -597,4 +597,4 @@ registration succeeds. Bun updater lease and recovery behavior follows the [update transaction contract](ops/service-and-sidecars.md#bun-updater-ownership-transaction). -Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The live main-account policy projection is fetched from the attested account-list management API. `src/cli/status-oauth.ts` renders its state and effective thresholds with an optional outside-usage advisory; `src/cli/status.ts` includes the same whitelisted projection in `ocx status --json`. No policy state is inferred from the CLI process's local quota cache when the live read is unavailable. The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter remains hidden from public capability discovery. +Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The live main-account policy projection is fetched from the attested account-list management API. `src/cli/status-oauth.ts` renders its state and effective thresholds with an optional outside-usage advisory; `src/cli/status.ts` includes the same whitelisted projection in `ocx status --json`. No policy state is inferred from the CLI process's local quota cache when the live read is unavailable. The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter remains hidden from public capability discovery. The optional send-unblock listener joins synchronous startup and awaited shutdown through `src/server/index/chatgpt-unblock-lifecycle.ts`, starts only for `chatgptDesktop.unblockSend === true` outside the client role, and degrades failures to warnings; its CA trust and credential-relay boundaries follow [ChatGPT Desktop](clients/chatgpt-desktop.md#optional-local-ca-send-unblock-intercept). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0de552888a..e8c2a010fee 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -29,6 +29,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Transport | Owner | Invariant worth knowing | | --- | --- | --- | +| Shared SOCKS5 handshake | `src/lib/socks5-handshake.ts`, `src/lib/socks5-fetch.ts`, `src/chatgpt/desktop-unblock/ws-upstream.ts` | Fetch tunnels and the optional ChatGPT intercept WebSocket relay share method negotiation, bounded URL-credential decoding, RFC 1929 authentication and CONNECT framing. Callers own socket disposal, deadlines and bytes after CONNECT; the handshake imports no optional integration. | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | | Responses custom-tool preview | `src/bridge/sse.ts`, `src/server/responses-custom-tool-repair.ts`, `src/responses/progressive-freeform-input.ts`, `src/responses/freeform-wrapper-scan.ts` | Direct adapter events and routed function restoration share one progressive wrapper decoder over one bounded JSON classification of the prefix, so property order and escaped key spellings preview as the wrapper completion unwraps them. Fence-shaped `exec`/`apply_patch` prefixes stay held until authoritative completion normalization, while each caller retains its own patch-envelope and byte-budget policy. | | Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | @@ -311,7 +312,9 @@ admission or account-snapshot pairing. The forwarding contract is covered in the compatibility config facade does not own a second activation path. `src/server/responses/fetch-helpers.ts` routes the built-in HTTP executor through configured outbound fetch, preserving physical-send admission and dispatch override. -Native WebSocket selection stays on HTTP SSE while SOCKS5 is configured. +Native provider WebSocket selection stays on HTTP SSE while SOCKS5 is configured. +The optional ChatGPT intercept has a separate raw upgrade relay whose SOCKS5 CONNECT +shares `src/lib/socks5-handshake.ts` with fetch; see [ChatGPT Desktop](../clients/chatgpt-desktop.md#optional-local-ca-send-unblock-intercept). Proxy-selected discovery peers remain unpinnable, and private destinations still require explicit private-network permission plus NO_PROXY before direct transport. diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index 90bc83cd345..c871f6b32ef 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -199,6 +199,14 @@ describe("restore validates the discovered app before any quit or open", () => { expect(result.calls.filter(call => ["pgrep", "ps", "/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toEqual([]); }); } + test("restore refuses while the intercept watcher is loaded, before quit or open", () => { + // The watcher would put the intercept switches straight back on the relaunched app. + const result = run({ flag: false, missingBinary: true, running: true, watcherLoaded: true }); + expect(result.code).toBe(1); + expect(result.launcherExists).toBe(true); + expect(result.stderr).toContain("Uninstall the launch watcher"); + expect(result.calls.filter(call => ["pgrep", "ps", "/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toEqual([]); + }); test("valid restore works with the flag off and no bundled app-server", () => { const result = run({ flag: false, missingBinary: true, running: true }); expect(result.code).toBe(0); diff --git a/tests/clients/desktop-chatgpt-config.test.ts b/tests/clients/desktop-chatgpt-config.test.ts index 96df45f5e6a..84ac6b9952a 100644 --- a/tests/clients/desktop-chatgpt-config.test.ts +++ b/tests/clients/desktop-chatgpt-config.test.ts @@ -15,7 +15,7 @@ describe("experimental ChatGPT desktop config", () => { } }); test("malformed reads disable only the experimental feature; writes reject unknown and malformed fields", () => { - for (const chatgptDesktop of [null, true, [], "yes", { appServerShim: "true" }, { appServerShim: true, unblockSend: true }, { port: 1234 }]) { + for (const chatgptDesktop of [null, true, [], "yes", { appServerShim: "true" }, { appServerShim: true, unblocksend: true }, { port: 0 }]) { const candidate = { ...base, port: 12345, chatgptDesktop }; const parsed = configSchema.parse(candidate); expect(parsed.chatgptDesktop).toBeUndefined(); @@ -31,7 +31,11 @@ describe("experimental ChatGPT desktop config", () => { if (process.platform !== "darwin") expect(diagnostics.warnings?.join(" ")).toContain("macOS only"); }); test("all operations reject non-macOS without app side effects", async () => { - for (const sub of ["launch", "restore", "status"]) expect(await handleChatgptCommand([sub], "linux")).toBe(1); + for (const sub of ["launch", "restore", "status", "install-watcher", "uninstall-watcher"]) expect(await handleChatgptCommand([sub], "linux")).toBe(1); + }); + test("unexpected watcher arguments are rejected before app side effects", async () => { + expect(await handleChatgptCommand(["install-watcher", "--unknown"], "darwin")).toBe(64); + expect(await handleChatgptCommand(["uninstall-watcher", "--yes"], "darwin")).toBe(64); }); test("hidden self-test works and rejects extra options", async () => { expect(await handleInternalCommand(["chatgpt-app-server-filter", "--self-test"])).toBe(0); diff --git a/tests/clients/desktop-rewrite.test.ts b/tests/clients/desktop-rewrite.test.ts new file mode 100644 index 00000000000..b9a0c572136 --- /dev/null +++ b/tests/clients/desktop-rewrite.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from "bun:test"; +import { + rewriteSurfaceFor, + stripSendBlocks, + stripSendBlocksFromJson, + stripSendBlocksFromSseLine, + unlockRateLimitGate, + type PreservedSendBlock, +} from "../../src/chatgpt/desktop-unblock/rewrite"; + +const blockedPayload = { + banner_info: { + name: "codex_limit_reached", + banner_type: "text", + resets_after: "2026-09-26T19:46:00Z", + }, + blocked_features: [ + { name: "send", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + { name: "tpp_send", block_reason: "work_subscription_required", resets_after: null }, + { name: "image_gen", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + ], + limits_progress: [ + { feature_name: "send", remaining: 0, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "reason", remaining: 3, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "send", remaining: 2, reset_after: "2026-09-26T19:46:00Z" }, + ], + model_limits: [{ model_slug: "gpt-5-codex" }], +}; + +describe("stripSendBlocks", () => { + test("removes quota send locks, keeps eligibility blocks and quota display data", () => { + const preserved: PreservedSendBlock[] = []; + const result = stripSendBlocks(blockedPayload, preserved); + expect(result.changed).toBe(true); + const value = result.value as typeof blockedPayload; + // The usage-limit send lock goes; a work-subscription requirement is not a quota and stays. + expect(value.blocked_features).toEqual([ + { name: "tpp_send", block_reason: "work_subscription_required", resets_after: null }, + { name: "image_gen", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + ]); + expect(preserved).toEqual([{ name: "tpp_send", reason: "work_subscription_required" }]); + // An exhausted `send` limit is removed; a non-exhausted one and other features stay. + expect(value.limits_progress).toEqual([ + { feature_name: "reason", remaining: 3, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "send", remaining: 2, reset_after: "2026-09-26T19:46:00Z" }, + ]); + // Display data is untouched. + expect(value.banner_info).toEqual(blockedPayload.banner_info); + expect(value.model_limits).toEqual(blockedPayload.model_limits); + }); + + test("reports unchanged payloads and leaves non-object input alone", () => { + expect(stripSendBlocks(blockedPayload).changed).toBe(true); + expect(stripSendBlocks({ blocked_features: [] }).changed).toBe(false); + expect(stripSendBlocks({ limits_progress: [{ feature_name: "send", remaining: 1 }] }).changed).toBe(false); + expect(stripSendBlocks("text").changed).toBe(false); + expect(stripSendBlocks(null).changed).toBe(false); + }); + + test("an open quota with a non-quota send block changes nothing (preserved, not removed)", () => { + // Reproduction from review: open usage + subscription-required block must pass untouched. + const payload = { + rate_limit: { allowed: true, limit_reached: false }, + limits_progress: [{ feature_name: "send", remaining: 2 }], + blocked_features: [{ name: "tpp_send", block_reason: "work_subscription_required", resets_after: null }], + }; + const preserved: PreservedSendBlock[] = []; + expect(stripSendBlocks(payload, preserved).changed).toBe(false); + expect(preserved).toEqual([{ name: "tpp_send", reason: "work_subscription_required" }]); + }); + + test("unrecognised send-block reasons are preserved; absent or quota reasons are removed", () => { + const preserved: PreservedSendBlock[] = []; + const result = stripSendBlocks({ + blocked_features: [ + { name: "send", block_reason: "policy_violation" }, + { name: "send", block_reason: null }, + { name: "send" }, + { name: "send", block_reason: "rate_limit_exceeded" }, + { name: "send", block_reason: "quota_exhausted" }, + ], + }, preserved); + expect((result.value as { blocked_features: unknown[] }).blocked_features).toEqual([ + { name: "send", block_reason: "policy_violation" }, + ]); + expect(preserved).toEqual([{ name: "send", reason: "policy_violation" }]); + }); + + test("a workspace or credit send block is preserved even though its reason mentions a limit", () => { + const preserved: PreservedSendBlock[] = []; + const blocks = [ + { name: "send", block_reason: "workspace_owner_usage_limit_reached" }, + { name: "send", block_reason: "workspace_member_credits_depleted" }, + { name: "send", block_reason: "usage_limit" }, + ]; + const result = stripSendBlocks({ blocked_features: blocks }, preserved); + expect((result.value as { blocked_features: unknown[] }).blocked_features).toEqual(blocks.slice(0, 2)); + expect(preserved).toEqual([ + { name: "send", reason: "workspace_owner_usage_limit_reached" }, + { name: "send", reason: "workspace_member_credits_depleted" }, + ]); + }); + + test("keeps malformed entries and recurses into nested payloads", () => { + const nested = { conversation: { blocked_features: [{ name: "send" }, "junk", 7] } }; + const result = stripSendBlocks(nested); + expect(result.changed).toBe(true); + expect((result.value as typeof nested).conversation.blocked_features).toEqual(["junk", 7]); + }); +}); + +describe("stripSendBlocksFromJson", () => { + test("rewrites a conversation-init style body", () => { + const rewritten = stripSendBlocksFromJson(JSON.stringify(blockedPayload), "conversation"); + expect(rewritten).not.toBeNull(); + const parsed = JSON.parse(rewritten!) as typeof blockedPayload; + expect(parsed.blocked_features.map(entry => entry.name)).toEqual(["tpp_send", "image_gen"]); + expect(parsed.banner_info).toEqual(blockedPayload.banner_info); + }); + + test("each surface applies only its own rewrite", () => { + const window = { used_percent: 100 }; + const mixed = JSON.stringify({ + rate_limit: { allowed: false, limit_reached: true, primary_window: window }, + blocked_features: [{ name: "send", block_reason: "usage_limit" }], + }); + const usage = JSON.parse(stripSendBlocksFromJson(mixed, "usage")!); + expect(usage.rate_limit).toEqual({ allowed: true, limit_reached: false, primary_window: window }); + expect(usage.blocked_features).toHaveLength(1); + const conversation = JSON.parse(stripSendBlocksFromJson(mixed, "conversation")!); + expect(conversation.rate_limit).toEqual({ allowed: false, limit_reached: true, primary_window: window }); + expect(conversation.blocked_features).toEqual([]); + }); + + test("returns null for invalid JSON and clean payloads", () => { + expect(stripSendBlocksFromJson("not json")).toBeNull(); + expect(stripSendBlocksFromJson(JSON.stringify({ banner_info: null }))).toBeNull(); + }); +}); + +describe("unlockRateLimitGate", () => { + // Shape captured from a real /backend-api/wham/usage/stream snapshot event. + const usageSnapshot = { + version: 1, + stream_id: "d485cc87-f9f6-431c-9908-8b99a854e252", + sequence: 1, + usage: { + plan_type: "pro", + rate_limit: { + allowed: false, + limit_reached: true, + primary_window: { used_percent: 100, limit_window_seconds: 604800, reset_after_seconds: 205162, reset_at: 1790423160 }, + secondary_window: null, + }, + model_usage: { "gpt-6-astra": { available: false, available_at: "2026-09-26T11:46:01Z", credits_would_enable: true } }, + spend_control: { reached: false, individual_limit: null }, + rate_limit_upsell: { banner_type: "pro_rate_limit_reached", title: "Codex 和工作使用额度已用完", reset_at: 1790423160 }, + rate_limit_reset_credits: { available_count: 1, applicable_available_count: 1 }, + }, + generated_at_ms: 1790217999865, + }; + + test("flips the gate flags and keeps every display field", () => { + const value = structuredClone(usageSnapshot); + expect(unlockRateLimitGate(value)).toBe(true); + const gate = (value.usage as typeof usageSnapshot.usage).rate_limit; + expect(gate.allowed).toBe(true); + expect(gate.limit_reached).toBe(false); + // Display data is untouched. + expect(gate.primary_window).toEqual(usageSnapshot.usage.rate_limit.primary_window); + expect((value.usage as typeof usageSnapshot.usage).rate_limit_upsell).toEqual(usageSnapshot.usage.rate_limit_upsell); + expect((value.usage as typeof usageSnapshot.usage).model_usage).toEqual(usageSnapshot.usage.model_usage); + }); + + test("reports no change for open gates and unrelated payloads", () => { + const open = structuredClone(usageSnapshot); + (open.usage.rate_limit as Record).allowed = true; + (open.usage.rate_limit as Record).limit_reached = false; + expect(unlockRateLimitGate(open)).toBe(false); + expect(unlockRateLimitGate({ usage: { plan_type: "pro" } })).toBe(false); + expect(unlockRateLimitGate("text")).toBe(false); + }); + + test("handles snapshot endpoints with a top-level rate_limit", () => { + const snapshot = { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } }; + expect(unlockRateLimitGate(snapshot)).toBe(true); + expect(snapshot.rate_limit.allowed).toBe(true); + expect(snapshot.rate_limit.primary_window.used_percent).toBe(100); + }); + + test("a web snapshot gate closed without plain-quota evidence stays closed", () => { + // The web snapshot spells the window `used_percent`; below 100% the payload does not show the + // subscription quota as the reason, so the flags stay as the server sent them. + const snapshot = { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 42 } } }; + expect(unlockRateLimitGate(snapshot)).toBe(false); + expect(snapshot.rate_limit).toEqual({ allowed: false, limit_reached: true, primary_window: { used_percent: 42 } }); + }); + + test("drops a plain-quota rate_limit_reached_type, which the bundled app-server reads", () => { + // `codex app-server` derives its own limit-reached state from this sibling object, so the + // allowed/limit_reached flip alone left it reporting `rate_limit_reached` (#6196). + const snapshot = { + rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } }, + rate_limit_reached_type: { type: "rate_limit_reached" }, + }; + expect(unlockRateLimitGate(snapshot)).toBe(true); + expect(snapshot).toEqual({ rate_limit: { allowed: true, limit_reached: false, primary_window: { used_percent: 100 } } }); + }); + + test("leaves the gate flags closed when a workspace, credit or spend-control reason stands beside them", () => { + for (const type of ["workspace_owner_usage_limit_reached", "workspace_member_credits_depleted"]) { + const snapshot = { rate_limit: { allowed: false, limit_reached: true }, rate_limit_reached_type: { type } }; + expect(unlockRateLimitGate(snapshot)).toBe(false); + expect(snapshot).toEqual({ rate_limit: { allowed: false, limit_reached: true }, rate_limit_reached_type: { type } }); + } + // The reason may sit in another branch than the flags; the whole payload counts. + const apart = { usage: { rate_limit: { allowed: false, limit_reached: true } }, rate_limit_reached_type: { type: "workspace_owner_usage_limit_reached" } }; + expect(unlockRateLimitGate(apart)).toBe(false); + expect(apart.usage.rate_limit).toEqual({ allowed: false, limit_reached: true }); + const spend = structuredClone(usageSnapshot); + (spend.usage.spend_control as Record).reached = true; + expect(unlockRateLimitGate(spend)).toBe(false); + expect(spend.usage.rate_limit.allowed).toBe(false); + expect(spend.usage.rate_limit.limit_reached).toBe(true); + }); + + test("keeps workspace and credit reached types, which are not the subscription quota", () => { + for (const type of ["workspace_owner_usage_limit_reached", "workspace_member_credits_depleted", "workspace_owner_credits_depleted"]) { + const snapshot = { rate_limit_reached_type: { type } }; + expect(unlockRateLimitGate(snapshot)).toBe(false); + expect(snapshot).toEqual({ rate_limit_reached_type: { type } }); + } + }); +}); + +describe("stripSendBlocksFromSseLine", () => { + test("rewrites data lines carrying send locks", () => { + const event = { type: "conversation.limit", blocked_features: [{ name: "send", block_reason: "usage_limit" }] }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(rewritten).toBe("data: " + JSON.stringify({ type: "conversation.limit", blocked_features: [] })); + }); + + test("rewrites usage-stream events carrying a closed rate limit gate", () => { + const event = { + version: 1, + sequence: 1, + usage: { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } }, + }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(rewritten).not.toBeNull(); + const parsed = JSON.parse(rewritten!.slice("data: ".length)) as typeof event; + expect(parsed.usage.rate_limit.allowed).toBe(true); + expect(parsed.usage.rate_limit.limit_reached).toBe(false); + expect(parsed.usage.rate_limit.primary_window.used_percent).toBe(100); + }); + + test("CRLF-framed data lines are rewritten and keep their carriage return", () => { + const event = { blocked_features: [{ name: "send", block_reason: "usage_limit" }] }; + expect(stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}\r`)).toBe('data: {"blocked_features":[]}\r'); + }); + + test("a JSON string holding U+2028 still matches the data line", () => { + const event = { note: "a\u2028b", blocked_features: [{ name: "send" }] }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(JSON.parse(rewritten!.slice("data: ".length))).toEqual({ note: "a\u2028b", blocked_features: [] }); + }); + + test("passes through non-data lines, clean data and malformed JSON", () => { + expect(stripSendBlocksFromSseLine("event: conversation.limit")).toBeNull(); + expect(stripSendBlocksFromSseLine('data: {"type":"delta"}')).toBeNull(); + expect(stripSendBlocksFromSseLine("data: [partial")).toBeNull(); + expect(stripSendBlocksFromSseLine(": keep-alive")).toBeNull(); + }); +}); + +describe("rewriteSurfaceFor", () => { + test("only the composer's conversation and usage endpoints are rewritten", () => { + expect(rewriteSurfaceFor("/backend-api/conversation/init")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/f/conversation")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/f/conversation/prepare")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/conversation")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/wham/usage")).toBe("usage"); + expect(rewriteSurfaceFor("/backend-api/wham/usage/stream")).toBe("usage"); + }); + + test("lookalike and unrelated paths pass through", () => { + for (const path of [ + "/backend-api/conversations", + "/backend-api/conversation-history", + "/backend-api/wham/usage/thread_usage/query", + "/backend-api/wham/usage/plan_limit_history", + "/backend-api/wham/tasks/list", + "/review-fixture/not-a-composer-endpoint", + "/", + ]) expect(rewriteSurfaceFor(path)).toBeNull(); + }); +}); diff --git a/tests/clients/desktop-unblock-ca-trust.test.ts b/tests/clients/desktop-unblock-ca-trust.test.ts new file mode 100644 index 00000000000..2a0c6c464c5 --- /dev/null +++ b/tests/clients/desktop-unblock-ca-trust.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { certificateSha1, chatgptCaTrustCommand, inspectChatgptCaTrust } from "../../src/chatgpt/desktop-unblock/ca-trust"; +import { createLocalInterceptCa } from "../../src/claude/intercept/local-ca"; +import type { SecurityRunner } from "../../src/claude/intercept/picker-trust"; + +let dir: string; +let caPath: string; +let sha1: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-ca-trust-")); + caPath = join(dir, "ca.pem"); + const ca = createLocalInterceptCa(); + writeFileSync(caPath, ca.certPem); + sha1 = certificateSha1(ca.certPem); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** `security trust-settings-export ` stand-in that writes the given trusted fingerprints. */ +function exporting(fingerprints: string[]): SecurityRunner { + return async args => { + expect(args[0]).toBe("trust-settings-export"); + const entries = fingerprints.map(f => `${f}trustSettings`).join(""); + writeFileSync(args[1]!, `trustList${entries}`); + return { code: 0, stdout: "", stderr: "" }; + }; +} + +test("a CA whose fingerprint has user trust settings is trusted", async () => { + expect(await inspectChatgptCaTrust(caPath, exporting(["00".repeat(20), sha1]), "darwin")).toBe("trusted"); +}); + +test("trust for a different certificate does not count", async () => { + expect(await inspectChatgptCaTrust(caPath, exporting(["AB".repeat(20)]), "darwin")).toBe("untrusted"); +}); + +test("a user domain with no trust settings at all is untrusted, not unknown", async () => { + const none: SecurityRunner = async () => ({ code: 1, stdout: "", stderr: "SecTrustSettingsCreateExternalRepresentation: No Trust Settings were found." }); + expect(await inspectChatgptCaTrust(caPath, none, "darwin")).toBe("untrusted"); +}); + +test("any other export failure is reported as unknown, never as trusted", async () => { + const failing: SecurityRunner = async () => ({ code: 1, stdout: "", stderr: "User interaction is not allowed." }); + expect(await inspectChatgptCaTrust(caPath, failing, "darwin")).toBe("unknown"); + const throwing: SecurityRunner = async () => { throw new Error("spawn failed"); }; + expect(await inspectChatgptCaTrust(caPath, throwing, "darwin")).toBe("unknown"); +}); + +test("a CA that was never created is reported as missing", async () => { + expect(await inspectChatgptCaTrust(join(dir, "absent.pem"), exporting([]), "darwin")).toBe("missing"); +}); + +test("other platforms are not applicable", async () => { + expect(await inspectChatgptCaTrust(caPath, exporting([sha1]), "linux")).toBe("unsupported"); +}); + +test("the restore command trusts this CA for TLS as a root in the login keychain", () => { + const command = chatgptCaTrustCommand(caPath); + expect(command).toStartWith("security add-trusted-cert -r trustRoot -p ssl -k "); + expect(command).toContain("login.keychain-db"); + expect(command).toEndWith(`"${caPath}"`); +}); diff --git a/tests/clients/desktop-unblock-config-boundary.test.ts b/tests/clients/desktop-unblock-config-boundary.test.ts new file mode 100644 index 00000000000..e9e77336f01 --- /dev/null +++ b/tests/clients/desktop-unblock-config-boundary.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getConfigPath, + getDefaultConfig, + loadConfig, + validateConfigCandidate, +} from "../../src/config"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * The write-boundary regression for the chatgptDesktop block. The read path degrades a + * malformed block to absent (`.catch(undefined)` in the schema), which is right for a + * hand-edited file — but the same silence at the write boundary would accept + * `{ unblockSend: true, port: 65536 }`, report success, and persist a config whose + * integration is silently gone. The boundary must reject instead, naming the field. + */ + +let home = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-chatgpt-desktop-config-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function candidate(chatgptDesktop: unknown) { + return { + ...getDefaultConfig(), + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + note: "keep me", + }, + }, + ...(chatgptDesktop === undefined ? {} : { chatgptDesktop }), + }; +} + +test("validateConfigCandidate rejects an out-of-range port naming the field", () => { + const result = validateConfigCandidate(candidate({ unblockSend: true, port: 65536 })); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("schema_invalid: chatgptDesktop.port"); +}); + +test("validateConfigCandidate rejects a typo'd key and a wrong-typed flag", () => { + // .strict(): a typo'd key must surface as a rejected write rather than a silently + // ignored key that leaves the operator believing they enabled something. + const typo = validateConfigCandidate(candidate({ unblockSend: true, unblocksend: true })); + expect(typo.ok).toBe(false); + + const wrongType = validateConfigCandidate(candidate({ unblockSend: "yes" })); + expect(wrongType.ok).toBe(false); +}); + +test("validateConfigCandidate accepts a well-formed block and its absence", () => { + expect(validateConfigCandidate(candidate({ unblockSend: true, appServerShim: true, port: 10300 })).ok).toBe(true); + expect(validateConfigCandidate(candidate({ unblockSend: true, appServerShim: "yes" })).ok).toBe(false); + expect(validateConfigCandidate(candidate({})).ok).toBe(true); + expect(validateConfigCandidate(candidate(undefined)).ok).toBe(true); +}); + +test("a rejected write leaves the persisted config unchanged", () => { + // Seed the on-disk config with a valid block, attempt the save a caller would make + // with an invalid one, and prove the boundary verdict is what stands between them: + // the file still carries the previous valid block afterwards. + writeFileSync(getConfigPath(), JSON.stringify(candidate({ unblockSend: true })), "utf8"); + + const rejected = validateConfigCandidate(candidate({ unblockSend: true, port: 65536 })); + expect(rejected.ok).toBe(false); + // A caller that persists only on ok never rewrote the file. + const loaded = loadConfig(); + expect(loaded.chatgptDesktop).toEqual({ unblockSend: true }); + expect(loaded.providers.xai.note).toBe("keep me"); + + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as { chatgptDesktop?: unknown }; + expect(onDisk.chatgptDesktop).toEqual({ unblockSend: true }); +}); + +test("load drops only a malformed block and preserves the rest of the config", () => { + // The read path keeps its degrade-to-off behavior: a hand-edited typo costs the + // operator the desktop integration, never their providers. + writeFileSync(getConfigPath(), JSON.stringify(candidate({ unblockSend: true, port: 99999 })), "utf8"); + + const loaded = loadConfig(); + expect(loaded.chatgptDesktop).toBeUndefined(); + expect(loaded.providers.xai.note).toBe("keep me"); +}); + +test("both integrations remain independently opt-in and unknown PAC config is rejected", () => { + for (const block of [{ unblockSend: false }, { appServerShim: true }, { unblockSend: true }, { appServerShim: true, unblockSend: true, port: 10300 }]) { + expect(validateConfigCandidate(candidate(block)).ok).toBe(true); + } + expect(validateConfigCandidate(candidate({ unblockSend: true, pacFallback: true })).ok).toBe(false); + for (const port of [0, -1, 1.5, 65536, "10300"]) { + const result = validateConfigCandidate(candidate({ unblockSend: true, port })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("chatgptDesktop.port"); + } +}); diff --git a/tests/clients/desktop-unblock-launch-script.test.ts b/tests/clients/desktop-unblock-launch-script.test.ts new file mode 100644 index 00000000000..82cb3a8091b --- /dev/null +++ b/tests/clients/desktop-unblock-launch-script.test.ts @@ -0,0 +1,522 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildChatgptUnblockWatcherPlist, + buildChatgptUnblockWatcherScript, + chatgptCommandLineHasRule, + checkChatgptWatcherScriptSyntax, +} from "../../src/chatgpt/desktop-unblock/launch-watcher"; + +const PORT = 10300; +const RESOLVER = "--host-resolver-rules=MAP chatgpt.com 127.0.0.1:10300"; + +const SCUTIL_NO_PROXY = ` { + HTTPEnable : 0 + HTTPSEnable : 0 + ProxyAutoConfigEnable : 0 + SOCKSEnable : 0 +}`; + +// Shape of a VPN client in system-proxy mode (captured from Clash on macOS). +const SCUTIL_SYSTEM_PROXY = ` { + ExceptionsList : { + 0 : 127.0.0.1 + 1 : localhost + } + HTTPEnable : 1 + HTTPPort : 7892 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 1 + HTTPSPort : 7892 + HTTPSProxy : 127.0.0.1 + ProxyAutoConfigEnable : 0 + SOCKSEnable : 1 + SOCKSPort : 7892 + SOCKSProxy : 127.0.0.1 +}`; + +const SCUTIL_SOCKS_ONLY = ` { + HTTPEnable : 0 + HTTPSEnable : 0 + ProxyAutoConfigEnable : 0 + SOCKSEnable : 1 + SOCKSPort : 1080 + SOCKSProxy : 10.0.0.2 +}`; + +const SCUTIL_PAC = ` { + HTTPEnable : 0 + HTTPSEnable : 0 + ProxyAutoConfigEnable : 1 + ProxyAutoConfigURLString : http://127.0.0.1:7890/proxy.pac + SOCKSEnable : 0 +}`; + +const SHIM_PATH = () => `${dir}/chatgpt-codex-shim.sh`; + +type AppState = "none" | "plain" | "flagged" | "flagged-shim" | "shim-only"; + +const APP_BINARY = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT"; +// A shell whose command line mentions the rule, e.g. someone grepping for it. Matching on +// command lines mistook exactly this for a correctly launched app. +const DECOY = `zsh -c pgrep -f 'ChatGPT.app/Contents/MacOS/ChatGPT .*${RESOLVER}'`; + +let stubs: string; +let dir: string; + +function stub(name: string, body: string): void { + const path = join(stubs, name); + writeFileSync(path, `#!/bin/bash\n${body}\n`); + chmodSync(path, 0o755); +} + +// Stubs are written once: macOS scans every new executable on first run, which costs ~1s each. +beforeAll(() => { + stubs = mkdtempSync(join(tmpdir(), "ocx-chatgpt-launch-bin-")); + // A process table of "pid|name|command" lines; pgrep and ps answer from it like the real ones. + // Like the real one, it skips the caller's ancestors (STUB_ANCESTORS) unless -a is given. + stub("pgrep", `[ "$1" = -a ] && { ancestors=1; shift; } || ancestors=0 +[ "$1" = -x ] && { exact=1; shift; } || exact=0 +[ $# -eq 1 ] || { echo "stub pgrep needs exactly one name" >&2; exit 2; } +found=1 +while IFS='|' read -r pid name command; do + if [ "$name" = "$1" ]; then + if [ $exact = 1 ] || [ "$command" = "$1" ] || [ "\${command##*/ }" = "$1" ]; then + if [ $ancestors = 0 ]; then case " $STUB_ANCESTORS " in *" $pid "*) continue ;; esac; fi + echo "$pid"; found=0 + fi + fi +done < "$STUB_DIR/processes" +exit $found`); + stub("ps", `pid="\${@: -1}" +case "$*" in *etime=*) [ -n "$STUB_APP_ETIME" ] && { echo "$STUB_APP_ETIME"; exit 0; }; exit 1 ;; esac +while IFS='|' read -r p name command; do + [ "$p" = "$pid" ] && { echo "$command"; exit 0; } +done < "$STUB_DIR/processes" +exit 1`); + // Only the loopback identity path is probed; no external service is contacted. + stub("curl", `case "$STUB_LISTENER" in + ours) echo '{"service":"opencodex-chatgpt-unblock","preservedSendBlocks":[]}' ;; + foreign) echo 'another server' ;; + *) exit 7 ;; +esac`); + stub("scutil", `cat "$STUB_DIR/scutil.txt"`); + // Quitting removes the app's main process (helpers exit with it only in reality; irrelevant here). + stub("osascript", `echo quit >> "$STUB_DIR/calls" +if [ "$STUB_QUIT_IGNORED" != 1 ]; then + grep -v '|ChatGPT|' "$STUB_DIR/processes" > "$STUB_DIR/processes.tmp" + mv -f "$STUB_DIR/processes.tmp" "$STUB_DIR/processes" +fi`); + // Records what follows --args (what the app itself receives) and, apart, the --env pairs. The + // pairs are appended to the process line: `ps eww` shows a process's environment after its command. + stub("open", `echo open >> "$STUB_DIR/calls" +[ "$STUB_OPEN_FAILS" = 1 ] && { echo "stub open: cannot launch" >&2; exit 1; } +# What open inherits, separate from the --env pairs it is asked to pass on. +echo "\${CODEX_CLI_PATH-}" > "$STUB_DIR/open-inherited" +args=(); envs=(); after=0; prev="" +for a in "$@"; do + if [ $after = 1 ]; then args+=("$a") + elif [ "$a" = --args ]; then after=1 + elif [ "$prev" = --env ]; then envs+=("$a"); fi + prev="$a" +done +[ \${#args[@]} -gt 0 ] && printf '%s\\n' "\${args[@]}" > "$STUB_DIR/open-args" +[ \${#envs[@]} -gt 0 ] && printf '%s\\n' "\${envs[@]}" > "$STUB_DIR/open-env" +echo "500|ChatGPT|${APP_BINARY} \${args[*]} \${envs[*]}" >> "$STUB_DIR/processes"`); + stub("sleep", ":"); +}); + +afterAll(() => { + rmSync(stubs, { recursive: true, force: true }); +}); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-launch-")); + mkdirSync(join(dir, "tmp")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function run(mode: "watch" | "launch" | "native", options: { + app: AppState; + scutil?: string; + listener?: "ours" | "foreign" | "down"; + quitIgnored?: boolean; + /** `open` fails, as it does when the bundle is gone or LaunchServices refuses it. */ + openFails?: boolean; + decoy?: boolean; + configDir?: string; + /** Start the app through the app-server shim; a launcher file is written when on. */ + shim?: boolean; + /** With the shim on, whether its launcher exists (opencodex writes it once the bundle checks pass). */ + launcher?: boolean; + /** What `ps -o etime=` reports for the app; unset makes the age unreadable. */ + appEtime?: string; + /** The app is an ancestor of the caller, as for `ocx` run in a terminal inside the app. */ + appIsAncestor?: boolean; + /** A `CODEX_CLI_PATH` the script inherits, as from a terminal inside a shimmed app. */ + inheritedCliPath?: string; +}) { + const processes = [ + options.app === "plain" ? `400|ChatGPT|${APP_BINARY}` : null, + options.app === "flagged" ? `400|ChatGPT|${APP_BINARY} ${RESOLVER} --proxy-bypass-list=chatgpt.com` : null, + options.app === "flagged-shim" ? `400|ChatGPT|${APP_BINARY} ${RESOLVER} --proxy-bypass-list=chatgpt.com CODEX_CLI_PATH=${SHIM_PATH()}` : null, + options.app === "shim-only" ? `400|ChatGPT|${APP_BINARY} CODEX_CLI_PATH=${SHIM_PATH()}` : null, + // Helpers share the bundle but not the process name; they must never count as the app. + options.app !== "none" ? `401|ChatGPT Helper|/Applications/ChatGPT.app/Contents/Frameworks/ChatGPT Helper.app/Contents/MacOS/ChatGPT Helper --type=utility ${RESOLVER}` : null, + options.decoy ? `300|zsh|${DECOY}` : null, + ].filter(Boolean); + writeFileSync(join(dir, "processes"), processes.map(line => `${line}\n`).join("")); + writeFileSync(join(dir, "scutil.txt"), options.scutil ?? SCUTIL_NO_PROXY); + if (options.shim === true && options.launcher !== false) { + writeFileSync(join(options.configDir ?? dir, "chatgpt-codex-shim.sh"), "#!/bin/bash\n", { mode: 0o755 }); + } + const script = join(dir, "launch.sh"); + writeFileSync(script, buildChatgptUnblockWatcherScript(PORT, options.configDir ?? dir, options.shim === true)); + const result = spawnSync("/bin/bash", [script, mode], { + encoding: "utf8", + env: { + PATH: `${stubs}:/usr/bin:/bin`, + TMPDIR: join(dir, "tmp"), + STUB_DIR: dir, + STUB_LISTENER: options.listener ?? "ours", + STUB_QUIT_IGNORED: options.quitIgnored ? "1" : "0", + STUB_OPEN_FAILS: options.openFails ? "1" : "0", + STUB_APP_ETIME: options.appEtime ?? "", + STUB_ANCESTORS: options.appIsAncestor ? "400" : "", + ...(options.inheritedCliPath === undefined ? {} : { CODEX_CLI_PATH: options.inheritedCliPath }), + }, + }); + const read = (name: string) => (existsSync(join(dir, name)) ? readFileSync(join(dir, name), "utf8") : ""); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + calls: read("calls").split("\n").filter(Boolean), + openArgs: read("open-args").split("\n").filter(Boolean), + openEnv: read("open-env").split("\n").filter(Boolean), + openInherited: read("open-inherited").trim(), + log: read("chatgpt-unblock-watcher.log"), + }; +} + +describe.skipIf(process.platform === "win32")("chatgpt launch arguments per network mode", () => { + test("no system proxy: the resolver switch alone", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_NO_PROXY }); + expect(r.status).toBe(0); + expect(r.openArgs).toEqual([RESOLVER]); + }); + + test("system proxy: explicit proxy with direct fallback, apex host bypassed", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_SYSTEM_PROXY }); + expect(r.openArgs).toEqual([ + RESOLVER, + "--proxy-server=http://127.0.0.1:7892,direct://", + "--proxy-bypass-list=chatgpt.com", + ]); + }); + + test("SOCKS-only system proxy is passed as socks5", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_SOCKS_ONLY }); + expect(r.openArgs).toEqual([ + RESOLVER, + "--proxy-server=socks5://10.0.0.2:1080,direct://", + "--proxy-bypass-list=chatgpt.com", + ]); + }); + + test("PAC proxy: the resolver switch alone, PAC left in charge", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_PAC }); + expect(r.openArgs).toEqual([RESOLVER]); + }); + + test("unreadable scutil output degrades to the resolver switch alone", () => { + const r = run("launch", { app: "none", scutil: "" }); + expect(r.openArgs).toEqual([RESOLVER]); + }); +}); + +describe.skipIf(process.platform === "win32")("chatgpt launch watcher", () => { + test("a Dock launch without the rule is quit, then relaunched with it", () => { + const r = run("watch", { app: "plain", scutil: SCUTIL_SYSTEM_PROXY }); + expect(r.status).toBe(0); + // `open` on a still-running app would only activate it, so quit must come first. + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs[0]).toBe(RESOLVER); + expect(r.log).toContain("restarting it"); + }); + + test("an app already carrying the rule is left alone", () => { + const r = run("watch", { app: "flagged" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + }); + + test("the watcher does not start an app that is not running", () => { + const r = run("watch", { app: "none" }); + expect(r.calls).toEqual([]); + }); + + test("with the intercept not answering the app is left native", () => { + expect(run("watch", { app: "plain", listener: "down" }).calls).toEqual([]); + const launch = run("launch", { app: "none", listener: "down" }); + expect(launch.status).toBe(1); + expect(launch.calls).toEqual([]); + expect(launch.stderr).toContain("not answering on port 10300"); + }); + + test("another process holding the port is never treated as the intercept", () => { + // Routing the app there would break every chatgpt.com request it makes. + expect(run("watch", { app: "plain", listener: "foreign" }).calls).toEqual([]); + expect(run("launch", { app: "none", listener: "foreign" }).status).toBe(1); + }); + + test("a config dir with quotes and ampersands still yields a working script", () => { + const odd = join(dir, "it's & co"); + mkdirSync(odd); + const r = run("watch", { app: "plain", configDir: odd }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(readFileSync(join(odd, "chatgpt-unblock-watcher.log"), "utf8")).toContain("restarting it"); + }); + + test("an app that refuses to quit is never re-opened (no activation-only restart loop)", () => { + const r = run("watch", { app: "plain", quitIgnored: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "quit", "quit"]); + expect(r.log).toContain("did not quit"); + }); + + test("a shell mentioning the rule is not mistaken for a correctly launched app", () => { + const r = run("watch", { app: "plain", decoy: true }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs[0]).toBe(RESOLVER); + }); + + test("a concurrent run holding the lock makes this one a no-op", () => { + mkdirSync(join(dir, "tmp", "opencodex-chatgpt-launch.lock")); + const r = run("watch", { app: "plain" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + }); + + test("an explicit launch that finds another run holding the lock says so and fails", () => { + mkdirSync(join(dir, "tmp", "opencodex-chatgpt-launch.lock")); + const r = run("launch", { app: "plain" }); + expect(r.status).toBe(1); + expect(r.stderr).toContain("another ChatGPT launch is in progress"); + expect(r.calls).toEqual([]); + }); + + test("a launch whose open fails after the quit reports the failure instead of success", () => { + const r = run("launch", { app: "plain", openFails: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.stdout).toContain("could not launch ChatGPT"); + expect(r.stdout).not.toContain("launched ChatGPT with"); + }); + + test("a restore whose open fails reports the failure instead of success", () => { + const r = run("native", { app: "flagged", openFails: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.stdout).toContain("could not relaunch ChatGPT with native networking"); + expect(r.stdout).not.toContain("relaunched ChatGPT with native networking"); + }); + + test("the lock is released after a run", () => { + run("watch", { app: "plain" }); + expect(existsSync(join(dir, "tmp", "opencodex-chatgpt-launch.lock"))).toBe(false); + }); + + test("launch mode reports an already correct app without restarting it", () => { + const r = run("launch", { app: "flagged" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.stdout).toContain("already running with the launch switches"); + }); +}); + +describe.skipIf(process.platform === "win32")("chatgpt restore (native networking)", () => { + test("a mapped app is quit and reopened without any arguments", () => { + const r = run("native", { app: "flagged" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([]); + expect(r.stdout).toContain("native networking"); + }); + + test("restore works while the listener is gone, which is when it is needed", () => { + const r = run("native", { app: "flagged", listener: "down" }); + expect(r.calls).toEqual(["quit", "open"]); + }); + + test("an app already on native networking, or not running, is left alone", () => { + expect(run("native", { app: "plain" }).calls).toEqual([]); + expect(run("native", { app: "none" }).calls).toEqual([]); + }); + + test("an app that refuses to quit is reported, not reopened", () => { + const r = run("native", { app: "flagged", quitIgnored: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "quit", "quit"]); + }); +}); + +describe.skipIf(process.platform === "win32")("chatgpt launch helpers", () => { + test("the launchd agent runs the script in watch mode", () => { + const plist = buildChatgptUnblockWatcherPlist("/x/launch.sh", "/x/SingletonLock", "/x/err"); + expect(plist).toContain("/x/launch.sh\n watch"); + }); + + test("the agent wakes on every watched path: the app's lock and the readiness marker", () => { + const plist = buildChatgptUnblockWatcherPlist("/x/launch.sh", ["/x/SingletonLock", "/x/chatgpt-unblock.ready"], "/x/err"); + expect(plist).toContain("/x/SingletonLock"); + expect(plist).toContain("/x/chatgpt-unblock.ready"); + }); + + test("plist paths are XML-escaped", () => { + const plist = buildChatgptUnblockWatcherPlist("/a&b/.sh", "/x/SingletonLock", "/x/\"err\""); + expect(plist).toContain("/a&b/<launch>.sh"); + expect(plist).toContain("/x/"err""); + expect(plist).not.toContain("/a&b"); + }); + + test("status counts only the switch form of the rule on the app's command line", () => { + expect(chatgptCommandLineHasRule(`${APP_BINARY} ${RESOLVER} --proxy-bypass-list=chatgpt.com`, PORT)).toBe(true); + // The bare rule the original launcher passed is ignored by Chromium; it must not count. + expect(chatgptCommandLineHasRule(`${APP_BINARY} MAP chatgpt.com 127.0.0.1:10300`, PORT)).toBe(false); + expect(chatgptCommandLineHasRule(APP_BINARY, PORT)).toBe(false); + expect(chatgptCommandLineHasRule(`${APP_BINARY} ${RESOLVER.replace("10300", "10301")}`, PORT)).toBe(false); + }); +}); + + +describe.skipIf(process.platform === "win32")("chatgpt launch with the app-server shim", () => { + test("launch starts the app with CODEX_CLI_PATH pointing at the launcher, next to the usual switch", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_NO_PROXY, shim: true }); + expect(r.status).toBe(0); + expect(r.openArgs).toEqual([RESOLVER]); + expect(r.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + }); + + test("without the shim the app gets no extra environment", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_NO_PROXY }); + expect(r.openEnv).toEqual([]); + }); + + test("watch corrects an app that has the switch but was started without the shim", () => { + const r = run("watch", { app: "flagged", shim: true }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + }); + + test("an app that already carries the switch and the shim is left alone", () => { + const r = run("watch", { app: "flagged-shim", shim: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + }); + + test("with the shim off, an app that has the switch is not restarted for lacking it", () => { + const r = run("watch", { app: "flagged" }); + expect(r.calls).toEqual([]); + }); + + test("restore hands back an app that only carries the shim, even after the shim was switched off", () => { + const r = run("native", { app: "shim-only" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openEnv).toEqual([]); + }); +}); + +describe.skipIf(process.platform === "win32")("the watcher's five-minute guard and readiness wake", () => { + test("watch restarts an app that started within the last five minutes", () => { + const r = run("watch", { app: "plain", appEtime: "04:59" }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([RESOLVER]); + }); + + test("watch leaves an app the user has been working in, whatever the elapsed-time format", () => { + for (const appEtime of ["05:01", "02:03:04", "1-02:03:04"]) { + const r = run("watch", { app: "plain", appEtime }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.log).toContain("without the launch switches; leaving it"); + } + }); + + test("a missing age counts as a fresh launch", () => { + expect(run("watch", { app: "plain" }).calls).toEqual(["quit", "open"]); + }); + + test("an unparseable age counts as a fresh launch", () => { + expect(run("watch", { app: "plain", appEtime: "garbage" }).calls).toEqual(["quit", "open"]); + }); + + test("the explicit launch command restarts an old app too", () => { + const r = run("launch", { app: "plain", appEtime: "1-00:00:00" }); + expect(r.calls).toEqual(["quit", "open"]); + }); + + test("launch sees the app that is its own ancestor and leaves a correctly launched one alone", () => { + const r = run("launch", { app: "flagged", appIsAncestor: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.stdout).toContain("already running with the launch switches"); + }); + + test("restore finds the ancestor app and hands it back native", () => { + const r = run("native", { app: "flagged", appIsAncestor: true }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([]); + }); + + test("every mode combination parses as bash", () => { + const configDirs = [dir, join(dir, "it's \"quoted\" $dir")]; + for (const configDir of configDirs) { + mkdirSync(configDir, { recursive: true }); + for (const shim of [false, true]) { + const script = join(dir, `syntax-${shim}.sh`); + writeFileSync(script, buildChatgptUnblockWatcherScript(PORT, configDir, shim)); + const check = checkChatgptWatcherScriptSyntax(script); + expect({ shim, configDir, ok: check.ok, output: check.output }).toEqual({ shim, configDir, ok: true, output: "" }); + } + } + }); +}); + +test.skipIf(process.platform === "win32")("the shim's CODEX_CLI_PATH rides the explicit relaunch after a quit", () => { + const result = run("launch", { app: "flagged", shim: true }); + expect(result.status).toBe(0); + expect(result.calls).toEqual(["quit", "open"]); + expect(result.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + expect(result.openArgs).toEqual([RESOLVER]); +}); + +describe("an inherited CODEX_CLI_PATH never reaches a relaunch", () => { + const INHERITED = "/tmp/inherited-codex-cli"; + test("native restore drops it and passes no override", () => { + const r = run("native", { app: "flagged", inheritedCliPath: INHERITED }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openInherited).toBe(""); + expect(r.openEnv).toEqual([]); + }); + test("a launch without the shim drops it and passes no override", () => { + const r = run("launch", { app: "plain", inheritedCliPath: INHERITED }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openInherited).toBe(""); + expect(r.openEnv).toEqual([]); + }); + test("a shim launch drops it and passes only the shim launcher", () => { + const r = run("launch", { app: "plain", shim: true, inheritedCliPath: INHERITED }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openInherited).toBe(""); + expect(r.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + }); +}); diff --git a/tests/clients/desktop-unblock-listener.test.ts b/tests/clients/desktop-unblock-listener.test.ts new file mode 100644 index 00000000000..6abfcee1f5e --- /dev/null +++ b/tests/clients/desktop-unblock-listener.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { + CHATGPT_UNBLOCK_IDENTITY_PATH, + MAX_REWRITE_BODY_BYTES, + CHATGPT_UNBLOCK_SERVICE_ID, + ChatgptUnblockDiagnostics, + relayWithSendUnblock, + sseRewriteStream, +} from "../../src/chatgpt/desktop-unblock/listener"; + +const UPSTREAM = "https://chatgpt.example"; + +/** A fetch stand-in that records the target and answers with `response`. */ +function upstreamReturning(response: () => Response): { fetchImpl: typeof fetch; targets: string[] } { + const targets: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + targets.push(String(input)); + return response(); + }) as typeof fetch; + return { fetchImpl, targets }; +} + +function json(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { ...init, headers: { "content-type": "application/json", ...init.headers } }); +} + +async function collect(stream: ReadableStream): Promise { + return new Response(stream).text(); +} + +describe("chatgpt unblock relay scope", () => { + test("an unrelated endpoint carrying a closed rate_limit passes through byte-identical", async () => { + // Reproduction from review: the whole hostname is mapped, so unrelated JSON must not change. + const body = '{"result":{"rate_limit":{"allowed":false,"limit_reached":true}}}'; + const { fetchImpl } = upstreamReturning(() => new Response(body, { headers: { "content-type": "application/json" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/review-fixture/not-a-composer-endpoint"), UPSTREAM, fetchImpl); + expect(await res.text()).toBe(body); + }); + + test("the usage endpoint's closed gate is opened", async () => { + const { fetchImpl, targets } = upstreamReturning(() => json({ rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage?x=1"), UPSTREAM, fetchImpl); + expect(targets).toEqual([`${UPSTREAM}/backend-api/wham/usage?x=1`]); + expect(await res.json()).toEqual({ rate_limit: { allowed: true, limit_reached: false, primary_window: { used_percent: 100 } } }); + }); + + test("conversation init loses quota send blocks and reports preserved eligibility blocks", async () => { + const diagnostics = new ChatgptUnblockDiagnostics(); + const { fetchImpl } = upstreamReturning(() => json({ + blocked_features: [ + { name: "send", block_reason: "usage_limit" }, + { name: "tpp_send", block_reason: "work_subscription_required" }, + ], + })); + const res = await relayWithSendUnblock( + new Request("https://chatgpt.com/backend-api/conversation/init", { method: "POST", body: "{}" }), + UPSTREAM, fetchImpl, diagnostics, + ); + expect(await res.json()).toEqual({ blocked_features: [{ name: "tpp_send", block_reason: "work_subscription_required" }] }); + const identity = await relayWithSendUnblock(new Request(`https://chatgpt.com${CHATGPT_UNBLOCK_IDENTITY_PATH}`), UPSTREAM, fetchImpl, diagnostics); + const snapshot = await identity.json() as { service: string; preservedSendBlocks: { name: string; reason: string }[] }; + expect(snapshot.service).toBe(CHATGPT_UNBLOCK_SERVICE_ID); + expect(snapshot.preservedSendBlocks.map(({ name, reason }) => ({ name, reason }))).toEqual([ + { name: "tpp_send", reason: "work_subscription_required" }, + ]); + }); + + test("the identity path is answered locally and never relayed", async () => { + const { fetchImpl, targets } = upstreamReturning(() => json({})); + const res = await relayWithSendUnblock(new Request(`https://chatgpt.com${CHATGPT_UNBLOCK_IDENTITY_PATH}`), UPSTREAM, fetchImpl); + expect(targets).toEqual([]); + expect(((await res.json()) as { service: string }).service).toBe(CHATGPT_UNBLOCK_SERVICE_ID); + }); +}); + +describe("chatgpt unblock relay responses", () => { + for (const status of [204, 205, 304]) { + test(`a ${status} with a JSON content type is relayed without a body`, async () => { + const { fetchImpl } = upstreamReturning(() => new Response(null, { status, headers: { "content-type": "application/json", etag: "\"v1\"" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, fetchImpl); + expect(res.status).toBe(status); + expect(res.body).toBeNull(); + expect(res.headers.get("etag")).toBe("\"v1\""); + }); + } + + test("a HEAD request is answered without a body", async () => { + const { fetchImpl } = upstreamReturning(() => json({ rate_limit: { allowed: false } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage", { method: "HEAD" }), UPSTREAM, fetchImpl); + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + test("an upstream failure becomes a 502 error envelope", async () => { + const fetchImpl = (async () => { throw new Error("connect ECONNREFUSED"); }) as unknown as typeof fetch; + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, fetchImpl); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: { message: "chatgpt unblock relay failed: connect ECONNREFUSED" } }); + }); + + test("response headers keep cookies and drop encoding, length and HTTP/3 advertisements", async () => { + const { fetchImpl } = upstreamReturning(() => { + const headers = new Headers({ + "content-type": "text/plain", + "content-encoding": "br", + "content-length": "999", + "alt-svc": "h3=\":443\"; ma=86400", + }); + headers.append("set-cookie", "a=1; Path=/"); + headers.append("set-cookie", "b=2; Path=/"); + return new Response("ok", { headers }); + }); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/"), UPSTREAM, fetchImpl); + expect(res.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(res.headers.get("content-length")).not.toBe("999"); + expect(res.headers.get("alt-svc")).toBeNull(); + }); +}); + +describe("chatgpt unblock SSE rewriting", () => { + // A window at 100% is the plain-quota evidence the gate rewrite requires before it opens the flags. + const locked = JSON.stringify({ usage: { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } } }); + const opened = JSON.stringify({ usage: { rate_limit: { allowed: true, limit_reached: false, primary_window: { used_percent: 100 } } } }); + + test("a data line split across chunks is rewritten once and every other byte survives", async () => { + const frame = `event: snapshot\ndata: ${locked}\n\n: keep-alive\n\n`; + const cut = frame.indexOf("limit_reached"); + const encoder = new TextEncoder(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(frame.slice(0, cut))); + controller.enqueue(encoder.encode(frame.slice(cut))); + controller.close(); + }, + }); + const text = await collect(source.pipeThrough(sseRewriteStream({ surface: "usage" }))); + expect(text.split("\n\n")).toEqual(["event: snapshot\ndata: " + opened, ": keep-alive", ""]); + }); + + test("CRLF-framed events are rewritten and keep their line endings", async () => { + const frame = `event: snapshot\r\ndata: ${locked}\r\n\r\n`; + const source = new Response(frame).body!; + const text = await collect(source.pipeThrough(sseRewriteStream({ surface: "usage" }))); + expect(text).toBe(`event: snapshot\r\ndata: ${opened}\r\n\r\n`); + }); + + test("a stream on the conversation surface leaves usage gates alone", async () => { + const source = new Response(`data: ${locked}\n\n`).body!; + const text = await collect(source.pipeThrough(sseRewriteStream({ surface: "conversation" }))); + expect(text).toBe(`data: ${locked}\n\n`); + }); +}); + +describe("chatgpt unblock relay body cap", () => { + const closedUsage = (padding: string) => JSON.stringify({ padding, rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } }); + + test("a JSON body over the cap streams through unchanged, whatever content-length says", async () => { + const body = closedUsage("x".repeat(MAX_REWRITE_BODY_BYTES + 1024)); + // Chunked: no content-length, so the cap has to be enforced while reading. + const chunked = upstreamReturning(() => new Response(new ReadableStream({ + start(controller) { + const bytes = new TextEncoder().encode(body); + for (let i = 0; i < bytes.length; i += 1 << 20) controller.enqueue(bytes.slice(i, i + (1 << 20))); + controller.close(); + }, + }), { headers: { "content-type": "application/json" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, chunked.fetchImpl); + expect(await res.text()).toBe(body); + + const declared = upstreamReturning(() => new Response(body, { headers: { "content-type": "application/json", "content-length": String(body.length) } })); + const res2 = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, declared.fetchImpl); + expect(await res2.text()).toBe(body); + }); + + test("a JSON body under the cap is still rewritten", async () => { + const { fetchImpl } = upstreamReturning(() => new Response(closedUsage("y".repeat(1024)), { headers: { "content-type": "application/json" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, fetchImpl); + expect((await res.json() as { rate_limit: { allowed: boolean } }).rate_limit.allowed).toBe(true); + }); +}); diff --git a/tests/clients/desktop-unblock-runtime.test.ts b/tests/clients/desktop-unblock-runtime.test.ts new file mode 100644 index 00000000000..10e5ab70bc4 --- /dev/null +++ b/tests/clients/desktop-unblock-runtime.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + chatgptUnblockEnabled, chatgptUnblockPort, chatgptUnblockResolverArg, startChatgptUnblock, +} from "../../src/chatgpt/desktop-unblock/runtime"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function config(chatgptDesktop: OcxConfig["chatgptDesktop"] = { unblockSend: true }): OcxConfig { + return { chatgptDesktop } as OcxConfig; +} + +describe("ChatGPT intercept without PAC or a duplicate shim", () => { + test("stable offset, explicit port and resolver switch", () => { + expect(chatgptUnblockPort(config(), 10100)).toBe(10300); + expect(chatgptUnblockPort(config({ port: 65535 }), 10100)).toBe(65535); + expect(() => chatgptUnblockPort(config(), 65336)).toThrow("out of range"); + expect(chatgptUnblockResolverArg(10300)).toBe("--host-resolver-rules=MAP chatgpt.com 127.0.0.1:10300"); + }); + test("shim alone never starts the intercept; client role disables it", async () => { + expect(chatgptUnblockEnabled(config({ appServerShim: true }))).toBe(false); + expect(chatgptUnblockEnabled({ ...config(), runtimeRole: "client" })).toBe(false); + expect(chatgptUnblockEnabled(config())).toBe(process.platform === "darwin"); + expect(await startChatgptUnblock({ config: config({ appServerShim: true }), publicPort: 10100 })).toBeNull(); + }); + test.skipIf(process.platform !== "darwin")("resolver mode binds only its TLS listener and stops it", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-runtime-")); + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = probe.port; + probe.stop(true); + let handle: Awaited> = null; + try { + handle = await startChatgptUnblock({ config: config({ unblockSend: true, port }), publicPort: 10100, configDir: dir }); + expect(handle).not.toBeNull(); + expect(handle!.listener.port).toBe(port); + expect(existsSync(handle!.caCertPath)).toBe(true); + expect(existsSync(join(dir, "chatgpt-unblock.pac"))).toBe(false); + expect(existsSync(join(dir, "chatgpt-codex-shim.sh"))).toBe(false); + await handle!.stop(); + handle = null; + const rebound = Bun.listen({ hostname: "127.0.0.1", port, socket: { data() {} } }); + rebound.stop(true); + } finally { + await handle?.stop(); + removeTreeWithRetry(dir); + } + }); +}); diff --git a/tests/clients/desktop-unblock-watcher-install.test.ts b/tests/clients/desktop-unblock-watcher-install.test.ts new file mode 100644 index 00000000000..f5dca767ca1 --- /dev/null +++ b/tests/clients/desktop-unblock-watcher-install.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + chatgptUnblockWatcherStatus, + installChatgptUnblockWatcher, + probeChatgptUnblockListener, + uninstallChatgptUnblockWatcher, + type LaunchctlRunner, +} from "../../src/chatgpt/desktop-unblock/launch-watcher"; +import { CHATGPT_UNBLOCK_SERVICE_ID, startChatgptUnblockListener } from "../../src/chatgpt/desktop-unblock/listener"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; + +let dir: string; +let plistPath: string; +let scriptPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-watcher-")); + plistPath = join(dir, "agent.plist"); + scriptPath = join(dir, "chatgpt-unblock-watcher.sh"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** launchctl stand-in answering each verb with a fixed status, recording every call. */ +function launchctl(statuses: { bootout?: number; bootstrap?: number }): { run: LaunchctlRunner; calls: string[] } { + const calls: string[] = []; + const run: LaunchctlRunner = args => { + calls.push(args[0]!); + const status = (args[0] === "bootout" ? statuses.bootout : statuses.bootstrap) ?? 0; + return { ok: status === 0, status, output: status === 0 ? "" : `${args[0]} failed: ${status}: Input/output error` }; + }; + return { run, calls }; +} + +const install = (run: LaunchctlRunner) => + installChatgptUnblockWatcher({ port: 10300, configDir: dir, plistPath, assumeSupported: true, launchctl: run }); + +describe("chatgpt launch watcher install", () => { + test("a fresh install unloads nothing, writes both files and loads the agent", () => { + // 3 = "No such process": nothing of ours was loaded, the normal fresh-install answer. + const fake = launchctl({ bootout: 3 }); + install(fake.run); + expect(fake.calls).toEqual(["bootout", "bootstrap"]); + expect(existsSync(plistPath)).toBe(true); + expect(existsSync(scriptPath)).toBe(true); + }); + + test("a failed bootstrap is reported with its diagnostic and leaves nothing behind", () => { + const fake = launchctl({ bootout: 3, bootstrap: 5 }); + expect(() => install(fake.run)).toThrow("launchctl bootstrap exited 5: bootstrap failed: 5: Input/output error"); + expect(existsSync(plistPath)).toBe(false); + expect(existsSync(scriptPath)).toBe(false); + }); + + test("a previous agent that cannot be unloaded stops the install before anything is written", () => { + const fake = launchctl({ bootout: 5 }); + expect(() => install(fake.run)).toThrow("could not unload the previous watcher"); + expect(fake.calls).toEqual(["bootout"]); + expect(existsSync(plistPath)).toBe(false); + }); +}); + +describe("chatgpt launch watcher uninstall", () => { + const uninstall = (run: LaunchctlRunner) => uninstallChatgptUnblockWatcher({ configDir: dir, plistPath, launchctl: run }); + + test("unloading removes both files", () => { + install(launchctl({ bootout: 3 }).run); + uninstall(launchctl({ bootout: 0 }).run); + expect(existsSync(plistPath)).toBe(false); + expect(existsSync(scriptPath)).toBe(false); + }); + + test("an agent that was not loaded still has its files removed", () => { + install(launchctl({ bootout: 3 }).run); + uninstall(launchctl({ bootout: 113 }).run); + expect(existsSync(plistPath)).toBe(false); + }); + + test("a failed unload keeps the files so disk and launchd never disagree", () => { + install(launchctl({ bootout: 3 }).run); + expect(() => uninstall(launchctl({ bootout: 5 }).run)).toThrow("watcher files kept"); + expect(existsSync(plistPath)).toBe(true); + expect(existsSync(scriptPath)).toBe(true); + }); +}); + +describe("chatgpt listener probe", () => { + const answering = (body: unknown) => async () => JSON.stringify(body); + const open = async () => true; + + test("opencodex's listener is recognised and its preserved send blocks reported", async () => { + const blocks = [{ name: "tpp_send", reason: "work_subscription_required", lastSeen: "2026-09-26T00:00:00.000Z" }]; + const probe = await probeChatgptUnblockListener(10300, answering({ service: CHATGPT_UNBLOCK_SERVICE_ID, preservedSendBlocks: blocks }), open); + expect(probe).toEqual({ state: "ours", preservedSendBlocks: blocks }); + }); + + test("any other answer on the port is a foreign process", async () => { + expect((await probeChatgptUnblockListener(10300, answering({ service: "something-else" }), open)).state).toBe("foreign"); + expect((await probeChatgptUnblockListener(10300, async () => "", open)).state).toBe("foreign"); + // Open port, but no usable HTTP answer: still not ours. + expect((await probeChatgptUnblockListener(10300, async () => null, open)).state).toBe("foreign"); + }); + + test("a closed port means nothing is listening, without asking for identity", async () => { + let asked = false; + const request = async () => { asked = true; return "{}"; }; + expect((await probeChatgptUnblockListener(10300, request, async () => false)).state).toBe("down"); + expect(asked).toBe(false); + }); + + test("the default TCP check reports a really closed loopback port as down", async () => { + const socket = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = socket.port; + socket.stop(true); + expect((await probeChatgptUnblockListener(port)).state).toBe("down"); + }); + + test("a real listener is recognised even with a proxy in the environment", async () => { + // Bun's fetch would send this loopback request to HTTPS_PROXY; the probe must not. + const listener = startChatgptUnblockListener({ leaf: issueLocalInterceptLeaf(createLocalInterceptCa(), ["chatgpt.com"]) }); + const saved = { HTTPS_PROXY: process.env.HTTPS_PROXY, HTTP_PROXY: process.env.HTTP_PROXY }; + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + process.env.HTTP_PROXY = "http://127.0.0.1:9"; + try { + const probe = await probeChatgptUnblockListener(listener.port!); + expect(probe).toEqual({ state: "ours", preservedSendBlocks: [] }); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await listener.stop(true); + } + }); +}); + diff --git a/tests/clients/desktop-unblock-ws-frame.test.ts b/tests/clients/desktop-unblock-ws-frame.test.ts new file mode 100644 index 00000000000..43dec2d678e --- /dev/null +++ b/tests/clients/desktop-unblock-ws-frame.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { encodeWsFrame, parseWsFrames, WEBSOCKET_MAX_FRAME_BYTES, WsOpcode } from "../../src/chatgpt/desktop-unblock/ws-frame"; + +describe("ws-frame", () => { + test("encode+parse roundtrip preserves text frame opcode, FIN and payload", () => { + const encoded = encodeWsFrame(WsOpcode.TEXT, Buffer.from("hello")); + // masked client frame: FIN+text, mask bit set, length 5 + expect(encoded[0]).toBe(0x81); + expect(encoded[1] & 0x80).toBe(0x80); + const { frames, violation, rest } = parseWsFrames(encoded, Buffer.alloc(0)); + expect(violation).toBeNull(); + expect(rest.length).toBe(0); + expect(frames).toHaveLength(1); + expect(frames[0]!.opcode).toBe(WsOpcode.TEXT); + expect(frames[0]!.fin).toBe(true); + expect(frames[0]!.payload.toString()).toBe("hello"); + }); + + test("binary frame with an unmasked server shape parses identically", () => { + const payload = Buffer.from([1, 2, 3, 254]); + const encoded = encodeWsFrame(WsOpcode.BINARY, payload, false); + expect(encoded[1] & 0x80).toBe(0); + const { frames, violation } = parseWsFrames(encoded, Buffer.alloc(0)); + expect(violation).toBeNull(); + expect(frames).toHaveLength(1); + expect(frames[0]!.opcode).toBe(WsOpcode.BINARY); + expect(Buffer.from(frames[0]!.payload)).toEqual(payload); + }); + + test("split and coalesced chunks parse through the pending buffer", () => { + const a = encodeWsFrame(WsOpcode.TEXT, Buffer.from("part-a")); + const b = encodeWsFrame(WsOpcode.BINARY, Buffer.from([9, 8, 7]), false); + const joined = Buffer.concat([a, b]); + // feed it in two awkward cuts + const cut1 = joined.subarray(0, 3); + const first = parseWsFrames(cut1, Buffer.alloc(0)); + expect(first.frames).toHaveLength(0); + expect(first.violation).toBeNull(); + const cut2 = joined.subarray(3, 9); + // 3+6=9 bytes < 12-byte frame: still incomplete, nothing parsed yet + const second = parseWsFrames(cut2, first.rest); + expect(second.frames).toHaveLength(0); + expect(second.violation).toBeNull(); + const third = parseWsFrames(joined.subarray(9), second.rest); + expect(third.frames).toHaveLength(2); + expect(third.violation).toBeNull(); + expect(third.rest.length).toBe(0); + expect(third.frames[0]!.payload.toString()).toBe("part-a"); + expect(Array.from(third.frames[1]!.payload)).toEqual([9, 8, 7]); + }); + + test("extended length forms parse", () => { + const medium = Buffer.alloc(300); + medium.fill(0xab); + const encoded = encodeWsFrame(WsOpcode.BINARY, medium, false); + expect(encoded[1] & 0x7f).toBe(126); + const { frames, violation } = parseWsFrames(encoded, Buffer.alloc(0)); + expect(violation).toBeNull(); + expect(frames[0]!.payload.length).toBe(300); + + const huge = Buffer.alloc(70_000); + const encodedHuge = encodeWsFrame(WsOpcode.BINARY, huge, false); + expect(encodedHuge[1] & 0x7f).toBe(127); + const parsed = parseWsFrames(encodedHuge, Buffer.alloc(0)); + expect(parsed.violation).toBeNull(); + expect(parsed.frames[0]!.payload.length).toBe(70_000); + }); + + test("control frames parse and are capped at 125 bytes", () => { + const ping = encodeWsFrame(WsOpcode.PING, Buffer.from("hb"), false); + const { frames } = parseWsFrames(ping, Buffer.alloc(0)); + expect(frames[0]!.opcode).toBe(WsOpcode.PING); + + const oversizedControl = Buffer.from([0x89, 0x7e, 0x00, 0x80]); + const { violation } = parseWsFrames(oversizedControl, Buffer.alloc(0)); + expect(violation).toBe("control frame payload over 125 bytes"); + }); + + test("protocol violations surface as reasons", () => { + expect(parseWsFrames(Buffer.from([0x81, 0x05]), Buffer.alloc(0)).frames).toHaveLength(0); + // RSV bit set + expect(parseWsFrames(Buffer.from([0xc1, 0x00]), Buffer.alloc(0)).violation).toBe( + "RSV bits set without a negotiated extension", + ); + // unknown opcode 0x3 + expect(parseWsFrames(Buffer.from([0x83, 0x00]), Buffer.alloc(0)).violation).toBe("unknown opcode"); + // close frame without FIN + expect(parseWsFrames(Buffer.from([0x08, 0x00]), Buffer.alloc(0)).violation).toBe("control frame without FIN"); + // 64-bit length above the ceiling + // 64-bit length 0x1_0000_0000 (4 GiB) — beyond the 16 MiB ceiling + const oversize64 = Buffer.from([0x82, 0xff, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]); + expect(parseWsFrames(oversize64, Buffer.alloc(0)).violation).toBe("frame exceeds the relay's size ceiling"); + }); + + test("the size ceiling is a constant tests can reference", () => { + expect(WEBSOCKET_MAX_FRAME_BYTES).toBe(16 * 1024 * 1024); + }); +}); diff --git a/tests/clients/desktop-unblock-ws-relay.test.ts b/tests/clients/desktop-unblock-ws-relay.test.ts new file mode 100644 index 00000000000..5da6037c89f --- /dev/null +++ b/tests/clients/desktop-unblock-ws-relay.test.ts @@ -0,0 +1,501 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { createServer as createNetServer, connect as connectNet } from "node:net"; +import type { AddressInfo, Server as NetServer } from "node:net"; +import { connect as connectTls, createServer as createTlsServer } from "node:tls"; +import type { Server as TlsServer, TLSSocket } from "node:tls"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; +import { startChatgptUnblockListener } from "../../src/chatgpt/desktop-unblock/listener"; +import { isRelayableUpgrade, readResponseHead, sendableCloseCode } from "../../src/chatgpt/desktop-unblock/ws-relay"; +import { encodeWsFrame, parseWsFrames, WEBSOCKET_GUID, WsOpcode } from "../../src/chatgpt/desktop-unblock/ws-frame"; +import type { DialUpstreamOptions } from "../../src/chatgpt/desktop-unblock/ws-upstream"; + +const ca = createLocalInterceptCa(); +const leaf = issueLocalInterceptLeaf(ca, ["chatgpt.com"]); + +/** One unmasked server frame; `b0` carries FIN and the opcode. */ +function serverFrame(b0: number, payload: Buffer | string): Buffer { + const body = typeof payload === "string" ? Buffer.from(payload) : payload; + return Buffer.concat([Buffer.from([b0, body.length]), body]); +} + +interface UpstreamLog { + heads: string[]; + pongs: string[]; + closes: { code: number; reason: string }[]; +} + +/** + * Scripted chatgpt.com stand-in speaking raw RFC 6455, so the test can send what a real + * server sends but Bun's server API cannot: fragments, interleaved pings, early frames. + */ +function startFakeUpstream(): { server: TlsServer; log: UpstreamLog; port: () => number } { + const log: UpstreamLog = { heads: [], pongs: [], closes: [] }; + const server = createTlsServer({ cert: leaf.certPem, key: leaf.keyPem }, socket => { + let buffer = Buffer.alloc(0); + let upgraded = false; + socket.on("error", () => {}); + socket.on("data", (chunk: Buffer) => { + if (!upgraded) { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + const head = buffer.subarray(0, end).toString("latin1"); + buffer = Buffer.alloc(0); + log.heads.push(head); + if (head.startsWith("GET /refuse")) { + socket.end("HTTP/1.1 403 Forbidden\r\ncf-ray: test-ray\r\ncontent-encoding: gzip\r\ncontent-length: 0\r\n\r\n"); + return; + } + upgraded = true; + const key = /^sec-websocket-key: *([^\r\n]+)/im.exec(head)![1]!.trim(); + const accept = createHash("sha1").update(key + WEBSOCKET_GUID).digest("base64"); + const offered = /^sec-websocket-protocol: *([^\r\n]+)/im.exec(head)?.[1]?.split(",").map(p => p.trim()) ?? []; + const chosen = offered.at(-1); + // The 101 and the first frame share one write: the relay must replay the early bytes. + socket.write(Buffer.concat([ + Buffer.from( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n${chosen ? `Sec-WebSocket-Protocol: ${chosen}\r\n` : ""}\r\n`, + ), + serverFrame(0x81, "hello-early"), + ])); + return; + } + const parsed = parseWsFrames(chunk, buffer); + buffer = parsed.rest; + for (const frame of parsed.frames) { + if (frame.opcode === WsOpcode.PONG) log.pongs.push(frame.payload.toString()); + else if (frame.opcode === WsOpcode.CLOSE) { + log.closes.push({ code: frame.payload.readUInt16BE(0), reason: frame.payload.subarray(2).toString() }); + socket.end(serverFrame(0x88, frame.payload)); + } else if (frame.opcode === WsOpcode.BINARY) socket.write(serverFrame(0x82, frame.payload)); + else if (frame.opcode === WsOpcode.TEXT) { + const text = frame.payload.toString(); + if (text === "frag") { + // Binary message in three fragments with a ping between them (legal per RFC 6455 §5.4). + socket.write(Buffer.concat([ + serverFrame(0x02, "ab"), + serverFrame(0x89, "hb"), + serverFrame(0x00, "cd"), + serverFrame(0x80, "ef"), + ])); + } else if (text === "close") { + const payload = Buffer.concat([Buffer.from([0x0f, 0xa1]), Buffer.from("bye")]); + socket.end(serverFrame(0x88, payload)); + } else if (text === "bigfrag") { + // A message whose fragment payloads pass the 16 MiB byte ceiling (chunk count stays small). + // FIN is cleared on both fragments, so the relay must buffer rather than flush. + const piece = Buffer.alloc(12 * 1024 * 1024, 0x61); + const unfin = (frame: Buffer): Buffer => { const copy = Buffer.from(frame); copy[0]! &= 0x7f; return copy; }; + socket.write(Buffer.concat([ + unfin(encodeWsFrame(WsOpcode.BINARY, piece, false)), + unfin(encodeWsFrame(WsOpcode.CONTINUATION, piece, false)), + ])); + } else socket.write(serverFrame(0x81, `up:${text}`)); + } + } + }); + }); + server.listen(0, "127.0.0.1"); + return { server, log, port: () => (server.address() as AddressInfo).port }; +} + +/** HTTP CONNECT proxy that sends every tunnel to the fake upstream, recording the request line. */ +function startConnectProxy(upstreamPort: () => number): { server: NetServer; requests: string[]; heads: string[] } { + const requests: string[] = []; + const heads: string[] = []; + const server = createNetServer(client => { + let buffer = Buffer.alloc(0); + client.on("error", () => {}); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + client.removeListener("data", onData); + heads.push(buffer.subarray(0, end).toString()); + requests.push(buffer.subarray(0, buffer.indexOf("\r\n")).toString()); + const upstream = connectNet(upstreamPort(), "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + }; + client.on("data", onData); + }); + server.listen(0, "127.0.0.1"); + return { server, requests, heads }; +} + +/** TLS-speaking CONNECT proxy: the client must wrap the proxy port before its CONNECT. */ +function startTlsConnectProxy(upstreamPort: () => number): { server: TlsServer; requests: string[] } { + const requests: string[] = []; + // A leaf for 127.0.0.1 (IP SAN), so the client's TLS wrap of the proxy port validates. + const proxyLeaf = issueLocalInterceptLeaf(ca, ["127.0.0.1"]); + const server = createTlsServer({ ca: [ca.certPem], cert: proxyLeaf.certPem, key: proxyLeaf.keyPem }, client => { + let buffer = Buffer.alloc(0); + client.on("error", () => {}); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + client.removeListener("data", onData); + requests.push(buffer.subarray(0, buffer.indexOf("\r\n")).toString()); + const upstream = connectNet(upstreamPort(), "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + }; + client.on("data", onData); + }); + server.listen(0, "127.0.0.1"); + return { server, requests }; +} + +/** SOCKS5 proxy that sends every CONNECT to the fake upstream, recording the target. Optional RFC 1929 credentials. */ +function startSocks5Proxy(upstreamPort: () => number, credentials?: { username: string; password: string }): { server: NetServer; targets: string[]; authAttempts: string[] } { + const targets: string[] = []; + const authAttempts: string[] = []; + const server = createNetServer(client => { + let buffer = Buffer.alloc(0); + let stage: "greeting" | "auth" | "connect" = "greeting"; + client.on("error", () => {}); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + if (stage === "greeting") { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]!) return; + const methods = buffer.subarray(2, 2 + buffer[1]!); + buffer = buffer.subarray(2 + methods.length); + const needsAuth = credentials !== undefined; + if (needsAuth && !methods.includes(0x02)) { + client.end(Buffer.from([0x05, 0xff])); + return; + } + client.write(Buffer.from([0x05, needsAuth ? 0x02 : 0x00])); + stage = needsAuth ? "auth" : "connect"; + } + if (stage === "auth") { + if (buffer.length < 2) return; + const usernameLength = buffer[1]!; + if (buffer.length < 2 + usernameLength + 1) return; + const passwordLength = buffer[2 + usernameLength]!; + if (buffer.length < 2 + usernameLength + 1 + passwordLength) return; + const username = buffer.subarray(2, 2 + usernameLength).toString(); + const password = buffer.subarray(3 + usernameLength, 3 + usernameLength + passwordLength).toString(); + buffer = buffer.subarray(2 + usernameLength + 1 + passwordLength); + const valid = username === credentials?.username && password === credentials?.password; + authAttempts.push(`${username}:${password}`); + client.write(Buffer.from([0x01, valid ? 0x00 : 0xff])); + if (!valid) return; + stage = "connect"; + } + if (stage === "connect") { + if (buffer.length < 5) return; + const hostLength = buffer[4]!; + if (buffer.length < 5 + hostLength + 2) return; + client.removeListener("data", onData); + const host = buffer.subarray(5, 5 + hostLength).toString(); + targets.push(`${host}:${buffer.readUInt16BE(5 + hostLength)}`); + const upstream = connectNet(upstreamPort(), "127.0.0.1", () => { + client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + } + }; + client.on("data", onData); + }); + server.listen(0, "127.0.0.1"); + return { server, targets, authAttempts }; +} + +/** A WebSocket client standing in for the desktop app, with an awaitable message queue. */ +async function openApp(port: number, path: string): Promise<{ + ws: WebSocket; + next: () => Promise; + closed: Promise<{ code: number; reason: string }>; +}> { + const ws = new WebSocket(`wss://127.0.0.1:${port}${path}`, { + protocols: ["realtime-v1", "realtime-v2"], + headers: { Cookie: "session=abc123", Origin: "https://chatgpt.com" }, + tls: { rejectUnauthorized: false }, + } as unknown as string[]); + ws.binaryType = "arraybuffer"; + const queue: (string | Uint8Array)[] = []; + const waiters: ((message: string | Uint8Array) => void)[] = []; + ws.onmessage = event => { + const message = typeof event.data === "string" ? event.data : new Uint8Array(event.data as ArrayBuffer); + const waiter = waiters.shift(); + if (waiter) waiter(message); + else queue.push(message); + }; + const closed = new Promise<{ code: number; reason: string }>(resolve => { + ws.onclose = event => resolve({ code: event.code, reason: event.reason }); + }); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("app websocket failed to open")); + }); + const next = () => { + const queued = queue.shift(); + if (queued !== undefined) return Promise.resolve(queued); + return new Promise(resolve => waiters.push(resolve)); + }; + return { ws, next, closed }; +} + +/** Send a bare upgrade over raw TLS and return the listener's response head. */ +function rawUpgrade(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const socket: TLSSocket = connectTls({ host: "127.0.0.1", port, rejectUnauthorized: false }, () => { + socket.write( + `GET ${path} HTTP/1.1\r\nHost: chatgpt.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + }); + let buffer = ""; + socket.on("data", (chunk: Buffer) => { + buffer += chunk.toString("latin1"); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + socket.destroy(); + resolve(buffer.slice(0, end)); + }); + socket.on("error", reject); + }); +} + +async function waitFor(check: () => boolean, label: string): Promise { + const deadline = Date.now() + 3000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${label}`); + await Bun.sleep(10); + } +} + +function listenerFor(wsUpstream: DialUpstreamOptions) { + return startChatgptUnblockListener({ leaf, wsUpstream: { ca: ca.certPem, connectTimeoutMs: 2000, ...wsUpstream } }); +} + +describe("chatgpt unblock websocket relay", () => { + const upstream = startFakeUpstream(); + const direct = () => ({ proxy: null, target: { host: "127.0.0.1", port: upstream.port() } }); + const cleanups: (() => void)[] = []; + + beforeAll(async () => { + await waitFor(() => upstream.server.listening, "fake upstream"); + }); + + afterAll(() => { + for (const cleanup of cleanups) cleanup(); + upstream.server.close(); + }); + + test("relays an upgrade end to end: handshake, early frames, text, binary, fragments, pings, upstream close", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream?x=1"); + + // Upstream picked the last offered subprotocol; the app must see that choice. + expect(app.ws.protocol).toBe("realtime-v2"); + const head = upstream.log.heads.at(-1)!; + expect(head.split("\r\n")[0]).toBe("GET /dictation/stream?x=1 HTTP/1.1"); + expect(head).toMatch(/^host: chatgpt\.com$/im); + expect(head).toMatch(/^cookie: session=abc123$/im); + expect(head).toMatch(/^origin: https:\/\/chatgpt\.com$/im); + expect(head).toMatch(/^sec-websocket-protocol: realtime-v1, realtime-v2$/im); + expect(head).toMatch(/^sec-websocket-version: 13$/im); + // The relay cannot inflate, so the app's permessage-deflate offer must not reach upstream. + expect(head).not.toMatch(/sec-websocket-extensions/i); + + expect(await app.next()).toBe("hello-early"); + + app.ws.send("hi"); + expect(await app.next()).toBe("up:hi"); + + // Binary stays binary and arrives byte-exact. + app.ws.send(new Uint8Array([1, 2, 3, 250])); + expect(Array.from(await app.next() as Uint8Array)).toEqual([1, 2, 3, 250]); + + app.ws.send("frag"); + expect(Buffer.from(await app.next() as Uint8Array).toString()).toBe("abcdef"); + await waitFor(() => upstream.log.pongs.includes("hb"), "pong for the interleaved ping"); + + app.ws.send("close"); + expect(await app.closed).toEqual({ code: 4001, reason: "bye" }); + }); + + test("forwards the app's close to upstream with its code and reason", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + const before = upstream.log.closes.length; + app.ws.close(4000, "cya"); + await waitFor(() => upstream.log.closes.length > before, "upstream close frame"); + expect(upstream.log.closes.at(-1)).toEqual({ code: 4000, reason: "cya" }); + }); + + test("a refused upstream upgrade reaches the app as the upstream's status", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const head = await rawUpgrade(listener.port!, "/refuse"); + expect(head.split("\r\n")[0]).toMatch(/^HTTP\/1\.1 403/); + expect(head).toMatch(/^cf-ray: test-ray$/im); + // The relay replaces the body, so the upstream's encoding must not describe it. + expect(head).not.toMatch(/content-encoding/i); + }); + + test("an unreachable upstream fails the upgrade with 502", async () => { + const closed = createNetServer(); + await new Promise(resolve => closed.listen(0, "127.0.0.1", resolve)); + const deadPort = (closed.address() as AddressInfo).port; + await new Promise(resolve => closed.close(() => resolve())); + const listener = listenerFor({ proxy: null, target: { host: "127.0.0.1", port: deadPort } }); + cleanups.push(() => listener.stop(true)); + const head = await rawUpgrade(listener.port!, "/dictation/stream"); + expect(head.split("\r\n")[0]).toMatch(/^HTTP\/1\.1 502/); + }); + + test("dials chatgpt.com through an HTTP CONNECT proxy", async () => { + const proxy = startConnectProxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "connect proxy"); + const listener = listenerFor({ proxy: `http://127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-connect"); + expect(await app.next()).toBe("up:via-connect"); + expect(proxy.requests).toEqual(["CONNECT chatgpt.com:443 HTTP/1.1"]); + app.ws.close(); + }); + + test("sends Proxy-Authorization when the CONNECT proxy URL carries credentials", async () => { + const proxy = startConnectProxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "authed connect proxy"); + const listener = listenerFor({ proxy: `http://user:p%40ss@127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-auth"); + expect(await app.next()).toBe("up:via-auth"); + expect(proxy.heads[0]).toContain("Proxy-Authorization: Basic " + Buffer.from("user:p@ss").toString("base64")); + app.ws.close(); + }); + + test("TLS-wraps an https:// proxy before the CONNECT handshake", async () => { + const proxy = startTlsConnectProxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "tls connect proxy"); + const listener = listenerFor({ proxy: `https://127.0.0.1:${(proxy.server.address() as AddressInfo).port}`, ca: `${ca.certPem}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-tls"); + expect(await app.next()).toBe("up:via-tls"); + expect(proxy.requests).toEqual(["CONNECT chatgpt.com:443 HTTP/1.1"]); + app.ws.close(); + }); + + test("dials chatgpt.com through a SOCKS5 proxy", async () => { + const proxy = startSocks5Proxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "socks5 proxy"); + const listener = listenerFor({ proxy: `socks5://127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-socks"); + expect(await app.next()).toBe("up:via-socks"); + expect(proxy.targets).toEqual(["chatgpt.com:443"]); + app.ws.close(); + }); + + test("a credentialed SOCKS5 proxy authenticates over RFC 1929 before CONNECT", async () => { + const proxy = startSocks5Proxy(upstream.port, { username: "user", password: "p@ss w0rd" }); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "authenticated socks5 proxy"); + const listener = listenerFor({ proxy: `socks5://user:p%40ss%20w0rd@127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-auth-socks"); + expect(await app.next()).toBe("up:via-auth-socks"); + // Percent-encoded credentials decoded, subnegotiated exactly once, CONNECT after. + expect(proxy.authAttempts).toEqual(["user:p@ss w0rd"]); + expect(proxy.targets).toEqual(["chatgpt.com:443"]); + app.ws.close(); + }); + + test("rejected RFC 1929 credentials fail the upgrade with 502", async () => { + const proxy = startSocks5Proxy(upstream.port, { username: "user", password: "real-secret" }); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "authenticated socks5 proxy"); + const listener = listenerFor({ proxy: `socks5://user:wrong@127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const head = await rawUpgrade(listener.port!, "/dictation/stream"); + expect(head.split("\r\n")[0]).toMatch(/^HTTP\/1\.1 502/); + // The proxy saw the failed attempt and no CONNECT followed it. + expect(proxy.authAttempts).toEqual(["user:wrong"]); + expect(proxy.targets).toEqual([]); + }); + + test("a fragmented message whose payload passes the byte ceiling fails with 1009", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("bigfrag"); + const closed = await app.closed; + expect(closed.code).toBe(1009); + }); +}); + +describe("chatgpt unblock websocket relay helpers", () => { + const upgrade = (headers: Record) => new Request("https://chatgpt.com/x", { headers }); + + test("only version-13 websocket upgrades are taken by the relay", () => { + expect(isRelayableUpgrade(upgrade({ upgrade: "websocket", "sec-websocket-version": "13" }))).toBe(true); + expect(isRelayableUpgrade(upgrade({ upgrade: "WebSocket", "sec-websocket-version": "13" }))).toBe(true); + expect(isRelayableUpgrade(upgrade({ upgrade: "websocket", "sec-websocket-version": "8" }))).toBe(false); + expect(isRelayableUpgrade(upgrade({ upgrade: "h2c" }))).toBe(false); + expect(isRelayableUpgrade(upgrade({}))).toBe(false); + }); + + test("close codes that may not appear on the wire are replaced", () => { + expect(sendableCloseCode(1000)).toBe(1000); + expect(sendableCloseCode(1011)).toBe(1011); + expect(sendableCloseCode(4000)).toBe(4000); + for (const reserved of [1004, 1005, 1006, 1015, 999, 2000, 5000]) expect(sendableCloseCode(reserved)).toBe(1000); + }); +}); + +describe("chatgpt unblock upstream handshake reader", () => { + /** An emitter that stands in for the tunnel socket; an unhandled 'error' on it throws. */ + function fakeTunnel(): TLSSocket { + const emitter = new EventEmitter() as EventEmitter & { pause(): void; setTimeout(ms: number, cb?: () => void): void }; + emitter.pause = () => {}; + emitter.setTimeout = () => {}; + return emitter as unknown as TLSSocket; + } + + test("a tunnel error after the 101 head, before attach(), does not throw", async () => { + const socket = fakeTunnel(); + const read = readResponseHead(socket, 1000); + socket.emit("data", Buffer.from("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n")); + expect((await read)?.head).toStartWith("HTTP/1.1 101"); + expect(() => socket.emit("error", new Error("ECONNRESET"))).not.toThrow(); + }); + + test("a tunnel error after a failed read does not throw either", async () => { + const socket = fakeTunnel(); + const read = readResponseHead(socket, 1000); + socket.emit("close"); + expect(await read).toBeNull(); + expect(() => socket.emit("error", new Error("ECONNRESET"))).not.toThrow(); + }); +}); diff --git a/tests/clients/desktop-unblock-ws-upstream.test.ts b/tests/clients/desktop-unblock-ws-upstream.test.ts new file mode 100644 index 00000000000..88e0404bc1d --- /dev/null +++ b/tests/clients/desktop-unblock-ws-upstream.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; +import type { AddressInfo, Server } from "node:net"; +import { dialUpstreamTunnel, proxyDialPort } from "../../src/chatgpt/desktop-unblock/ws-upstream"; + +describe("ChatGPT intercept upstream proxy dial", () => { + test("an omitted or scheme-default proxy port dials the scheme default", () => { + expect(proxyDialPort(new URL("http://proxy.example"), "http-connect")).toBe(80); + expect(proxyDialPort(new URL("http://proxy.example:80"), "http-connect")).toBe(80); + expect(proxyDialPort(new URL("http://proxy.example:3128"), "http-connect")).toBe(3128); + expect(proxyDialPort(new URL("https://proxy.example"), "http-connect")).toBe(443); + expect(proxyDialPort(new URL("socks5://proxy.example"), "socks5")).toBe(1080); + expect(proxyDialPort(new URL("socks5h://proxy.example:9050"), "socks5")).toBe(9050); + }); + + /** A proxy that reads the first handshake bytes and then closes cleanly without replying. */ + async function closingProxy(): Promise<{ server: Server; port: number }> { + const server = createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => socket.end()); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { server, port: (server.address() as AddressInfo).port }; + } + + for (const scheme of ["http", "socks5"] as const) { + test(`a ${scheme} proxy closing mid-handshake fails the dial before the timeout`, async () => { + const { server, port } = await closingProxy(); + try { + const started = Date.now(); + const tunnel = await dialUpstreamTunnel({ proxy: `${scheme}://127.0.0.1:${port}`, connectTimeoutMs: 8_000 }); + expect(tunnel).toBeNull(); + expect(Date.now() - started).toBeLessThan(4_000); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + }); + } +}); + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 50e7c781a4f..38c60337d28 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -19,7 +19,7 @@ "main-account-hard-lock-thresholds.test.ts": "codex-integration", "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", - "cli-combo-partial-update.test.ts": "cli", + "cli-combo-partial-update.test.ts": "cli", "desktop-unblock-ws-frame.test.ts": "clients", "desktop-unblock-watcher-install.test.ts": "clients", "desktop-unblock-ws-relay.test.ts": "clients", "desktop-unblock-ws-upstream.test.ts": "clients", "desktop-unblock-runtime.test.ts": "clients", "desktop-unblock-listener.test.ts": "clients", "desktop-unblock-launch-script.test.ts": "clients", "desktop-unblock-config-boundary.test.ts": "clients", "desktop-unblock-ca-trust.test.ts": "clients", "desktop-rewrite.test.ts": "clients", "socks5-handshake.test.ts": "lib", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", diff --git a/tests/helpers/desktop-app-server-shim-command-child.ts b/tests/helpers/desktop-app-server-shim-command-child.ts index 816fb3acc56..745aef81f7c 100644 --- a/tests/helpers/desktop-app-server-shim-command-child.ts +++ b/tests/helpers/desktop-app-server-shim-command-child.ts @@ -64,6 +64,26 @@ mock.module("../../src/chatgpt/app-server-shim/launcher", () => ({ resolveChatgptCodexBinary: () => scenario.missingBinary ? null : binary, writeChatgptShimLauncher: () => { fs.writeFileSync(launcher, "new launcher"); }, })); +// The send-unblock intercept is out of scope here, and its live probes (listening port, launchd agent, +// keychain trust, a running opencodex) must never reach this machine. Its watcher is absent unless +// the scenario loads one. +mock.module("../../src/chatgpt/desktop-unblock/launch-watcher", () => ({ + chatgptAppCommandLine: () => null, + chatgptCommandLineHasRule: () => false, + chatgptUnblockWatcherStatus: () => ({ + scriptInstalled: false, plistInstalled: false, agentLoaded: scenario.watcherLoaded === true, + scriptUpToDate: false, plistUpToDate: false, + }), + installChatgptUnblockWatcher: () => { throw new Error("fixture: the watcher is out of scope"); }, + launchChatgptWithRule: () => ({ ok: false, output: "fixture: the intercept launch is out of scope" }), + probeChatgptUnblockListener: async () => ({ state: "down" }), + uninstallChatgptUnblockWatcher: () => undefined, +})); +mock.module("../../src/server/proxy-liveness", () => ({ findLiveProxy: async () => null })); +mock.module("../../src/chatgpt/desktop-unblock/ca-trust", () => ({ + inspectChatgptCaTrust: async () => "missing", + chatgptCaTrustCommand: () => "", +})); const { handleChatgptCommand } = await import("../../src/cli/chatgpt-command"); const code = await handleChatgptCommand([scenario.sub ?? "restore"], "darwin"); console.log(JSON.stringify({ code, calls, launcherExists: fs.existsSync(launcher), root, shell, binary })); diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index 7cbbc315b5c..f8a84a5ff79 100644 --- a/tests/lab/core-lab-boundary.test.ts +++ b/tests/lab/core-lab-boundary.test.ts @@ -1045,7 +1045,7 @@ describe("activation window stays synchronous", () => { "(...).then()": "Promise.then on the fire-and-forget `import('../codex/plan-from-token')` chain. then() registers a callback and returns immediately; the callback is a nested function this scan skips. Awaiting the import would already fail Guard 3.", "(...).catch()": "Promise.catch on that same dynamic-import chain. Same fire-and-forget: it cannot suspend startServer.", "backgroundLifecycle.scheduleStartupRun()": "src/server/background-lifecycle.ts owns this object method. The call site cannot resolve the declaration statically; scheduleStartupRun is declared `(): void` and is documented as never blocking listen.", - "optionalListeners.start()": "Instance method on OptionalListenerSet. Declared `(ctx): void`; it binds the optional link listener synchronously and starts the existing Claude intercept fire-and-forget lifecycle. Its stop() runs inside the async stop wrapper, which this scan skips.", + "optionalListeners.start()": "Instance method on OptionalListenerSet. Declared `(ctx): void`; it binds the optional link listener synchronously and starts the existing Claude intercept and opt-in ChatGPT send-unblock lifecycles fire-and-forget; both retain their start promise and degrade failures to warnings. Its stop() runs inside the async stop wrapper, which this scan skips.", "spendLedgerLifecycle.track()": "Instance method on the lifecycle from acquireSpendLedgerServerLifecycle in src/server/index/spend-ledger-lifecycle.ts, called on each listener as it is created. It binds the listener's stop, records a rollback closure and returns the same server; it is declared `(server: T): T` and contains no await. An `await spendLedgerLifecycle.track(...)` would already fail Guard 3. The lifecycle's release() is not here because it is called inside the async stop wrapper, which this scan skips as a nested function.", }; diff --git a/tests/lib/socks5-handshake.test.ts b/tests/lib/socks5-handshake.test.ts new file mode 100644 index 00000000000..a01f6941397 --- /dev/null +++ b/tests/lib/socks5-handshake.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { socks5Credentials, socks5Handshake, Socks5HandshakeError } from "../../src/lib/socks5-handshake"; + +/** + * Byte-level contract of the SOCKS5 handshake both raw transports ride: method + * negotiation, RFC 1929 subnegotiation, CONNECT framing, and reply parsing. + * `tests/lib/socks5-fetch.test.ts` and `tests/chatgpt-unblock/unblock-ws-relay.test.ts` + * pin each transport end to end; these cases drive the handshake alone so every reply + * shape is reachable without a socket. + */ + +/** A reader fed pre-scripted proxy replies; records what the handshake writes. */ +class ScriptedReader { + private sent: Buffer = Buffer.alloc(0); + private replies: Buffer[] = []; + private failure: Error | undefined; + private waiter: { bytes: number; resolve: (value: Buffer) => void; reject: (error: Error) => void } | null = null; + + takeWritten(): Buffer { + const value = this.sent; + this.sent = Buffer.alloc(0); + return value; + } + + reply(...chunks: number[][]): void { + for (const chunk of chunks) this.replies.push(Buffer.from(chunk)); + this.flush(); + } + + fail(error: Error): void { + this.failure = error; + this.flush(); + } + + private flush(): void { + if (!this.waiter) return; + if (this.failure) { + const waiter = this.waiter; + this.waiter = null; + waiter.reject(this.failure); + return; + } + const available = this.replies.reduce((total, reply) => total + reply.byteLength, 0); + if (available >= this.waiter.bytes) { + const waiter = this.waiter; + this.waiter = null; + waiter.resolve(this.consume(waiter.bytes)); + } + } + + private consume(bytes: number): Buffer { + const out: Buffer[] = []; + let remaining = bytes; + while (remaining > 0) { + const next = this.replies[0]!; + if (next.byteLength <= remaining) { + this.replies.shift(); + out.push(next); + remaining -= next.byteLength; + } else { + out.push(next.subarray(0, remaining)); + this.replies[0] = next.subarray(remaining); + remaining = 0; + } + } + return Buffer.concat(out); + } + + write(bytes: Uint8Array): void { + this.sent = Buffer.concat([this.sent, Buffer.from(bytes)]); + } + + readExact(bytes: number): Promise { + if (this.failure) return Promise.reject(this.failure); + const available = this.replies.reduce((total, reply) => total + reply.byteLength, 0); + if (available >= bytes) return Promise.resolve(this.consume(bytes)); + return new Promise((resolve, reject) => { + this.waiter = { bytes, resolve, reject }; + }); + } +} + +const AUTH = socks5Credentials(new URL("socks5://user:pass@proxy.example.test:1080")); +const NO_AUTH = socks5Credentials(new URL("socks5://proxy.example.test:1080")); +const TARGET = { host: "chatgpt.com", port: 443 }; +const CONNECT_REPLY = [0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]; + +function connectRequest(host: string, port: number): Buffer { + const hostBytes = Buffer.from(host, "utf8"); + return Buffer.from([0x05, 0x01, 0x00, 0x03, hostBytes.byteLength, ...hostBytes, port >> 8, port & 0xff]); +} + +describe("shared socks5 handshake", () => { + test("offers no-auth only without credentials, user-pass alongside them with credentials", async () => { + const plain = new ScriptedReader(); + plain.reply([0x05, 0x00], CONNECT_REPLY); + await socks5Handshake(plain, TARGET, NO_AUTH); + expect([...plain.takeWritten()]).toEqual([0x05, 0x01, 0x00, ...connectRequest(TARGET.host, TARGET.port)]); + + const credentialed = new ScriptedReader(); + credentialed.reply([0x05, 0x00], CONNECT_REPLY); + await socks5Handshake(credentialed, TARGET, AUTH); + // Two methods offered: NO-AUTH keeps a proxy that ignores credentials usable. + // NO-AUTH picked: no credential bytes are ever sent unasked. + expect([...credentialed.takeWritten()]).toEqual([ + 0x05, 0x02, 0x00, 0x02, ...connectRequest(TARGET.host, TARGET.port), + ]); + }); + + test("performs the RFC 1929 subnegotiation when the proxy picks user-pass", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x02], [0x01, 0x00], CONNECT_REPLY); + await socks5Handshake(reader, TARGET, AUTH); + const written = reader.takeWritten(); + // After the 4-byte greeting: version-1 subnegotiation, ulen uname plen passwd. + const subnegotiation = written.subarray(4, 4 + 1 + 1 + "user".length + 1 + "pass".length); + expect([...subnegotiation]).toEqual([ + 0x01, 4, ...Buffer.from("user"), 4, ...Buffer.from("pass"), + ]); + }); + + test("a failure reply to the subnegotiation is an authentication failure", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x02], [0x01, 0xff]); + await expect(socks5Handshake(reader, TARGET, AUTH)) + .rejects.toThrow("SOCKS5 proxy authentication failed"); + }); + + test("a proxy picking a method we did not offer is refused before CONNECT", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x80]); + await expect(socks5Handshake(reader, TARGET, NO_AUTH)) + .rejects.toThrow("does not accept an offered authentication method"); + // Refusal must not be followed by a CONNECT request into a dead conversation. + expect(reader.takeWritten()).toEqual(Buffer.from([0x05, 0x01, 0x00])); + }); + + test("connect failures carry the proxy's reply code", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x00], [0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + await expect(socks5Handshake(reader, TARGET, NO_AUTH)) + .rejects.toThrow("refused the connection (code 1)"); + }); + + test("malformed replies are named errors: bad version, reserved byte, unknown address type", async () => { + const badVersion = new ScriptedReader(); + badVersion.reply([0x04, 0x00]); + await expect(socks5Handshake(badVersion, TARGET, NO_AUTH)) + .rejects.toThrow("invalid greeting"); + + const badReserved = new ScriptedReader(); + badReserved.reply([0x05, 0x00], [0x05, 0x00, 0x01, 0x01, 0, 0, 0, 0, 0, 0]); + await expect(socks5Handshake(badReserved, TARGET, NO_AUTH)) + .rejects.toThrow("invalid address type or reserved byte"); + + const badAddress = new ScriptedReader(); + badAddress.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x07, 0, 0, 0, 0, 0, 0]); + await expect(socks5Handshake(badAddress, TARGET, NO_AUTH)) + .rejects.toThrow("invalid address type or reserved byte"); + }); + + test("consumes the bound-address tail for every address type", async () => { + // IPv4: 4 address bytes + 2 port bytes. A shorter reply left unread would corrupt + // the stream the TLS layer inherits. + const ipv4 = new ScriptedReader(); + ipv4.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x01, 10, 1, 2, 3, 0x01, 0xbb]); + await socks5Handshake(ipv4, TARGET, NO_AUTH); + + const domain = new ScriptedReader(); + domain.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x03, 9, ...Buffer.from("proxy.example"), 0x01, 0xbb]); + await socks5Handshake(domain, TARGET, NO_AUTH); + + const ipv6 = new ScriptedReader(); + ipv6.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x04, ...Array.from({ length: 16 }, (_, i) => i), 0x01, 0xbb]); + await socks5Handshake(ipv6, TARGET, NO_AUTH); + }); + + test("a reader that stops answering rejects the pending step", async () => { + const reader = new ScriptedReader(); + const pending = socks5Handshake(reader, TARGET, NO_AUTH); + reader.fail(new Error("handshake timeout")); + await expect(pending).rejects.toThrow("handshake timeout"); + }); + + test("a domain target longer than one length byte is refused before anything is written", async () => { + const reader = new ScriptedReader(); + await expect(socks5Handshake(reader, { host: "a".repeat(256), port: 443 }, NO_AUTH)) + .rejects.toThrow("target hostname is too long"); + expect(reader.takeWritten().byteLength).toBe(0); + }); + + test("credentials with invalid percent encoding or oversized parts are refused", () => { + expect(() => socks5Credentials(new URL("socks5://%zz@proxy.example.test:1080"))) + .toThrow("invalid percent encoding"); + expect(() => socks5Credentials(new URL(`socks5://${"u".repeat(256)}@proxy.example.test:1080`))) + .toThrow("must each fit in 255 UTF-8 bytes"); + }); + + test("errors are the named handshake type", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x80]); + await expect(socks5Handshake(reader, TARGET, NO_AUTH)).rejects.toBeInstanceOf(Socks5HandshakeError); + }); + + test("writes the CONNECT request in the expected domain framing", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x00], CONNECT_REPLY); + await socks5Handshake(reader, TARGET, NO_AUTH); + const written = reader.takeWritten(); + // 3-byte greeting (05, nmethods, one method) precedes the CONNECT request. + expect([...written.subarray(3)]).toEqual([...connectRequest("chatgpt.com", 443)]); + }); +});