From fbda967fc60ad5ba955117874ea607b018f39d53 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:32:02 +0900 Subject: [PATCH 1/6] docs: document native codex queue fallback for the desktop composer usage gate --- .../guides/composer-usage-gate-fallback.md | 59 ++++++++++++ scripts/codex-queue.ps1 | 48 ++++++++++ scripts/codex-queue.sh | 96 +++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 docs-site/src/content/docs/guides/composer-usage-gate-fallback.md create mode 100644 scripts/codex-queue.ps1 create mode 100755 scripts/codex-queue.sh diff --git a/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md b/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md new file mode 100644 index 00000000000..1ffe2c4301a --- /dev/null +++ b/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md @@ -0,0 +1,59 @@ +--- +title: Composer Usage-Gate Fallback +description: How to keep sending messages when the desktop app's composer is disabled by the client-side usage gate, using only Codex's native CLI paths. +--- + +When a ChatGPT account reaches its usage limit, the Codex desktop app disables the composer input +field through a client-side gate. The rest of the app keeps working: existing threads still run, +approvals still arrive, remote control still works, and queued follow-ups still execute. Only the +local input box is blocked. + +Codex already ships a native path for this case. The `codex queue` command delivers a message to an +existing thread through the local app-server daemon — the same transport used by the app's own +follow-up queue and by remote control. It does not touch the composer UI, so the client-side gate +does not apply to it. + +## Sending to an existing thread + +```powershell +codex queue --thread --message "continue with the next step" +``` + +The thread id is the UUID embedded in the rollout filename under +`~/.codex/sessions///
/rollout-*.jsonl`. Filenames can contain two +UUIDs (`rollout--_.jsonl`); the thread id is the +first one. The helper script `scripts/codex-queue.ps1` resolves the most recent +thread automatically: + +```powershell +scripts\codex-queue.ps1 "continue with the next step" +scripts\codex-queue.ps1 -Thread 019f644b-a10a-73c2-8c3f-f3c7713a2928 "status?" +``` + +On macOS/Linux the equivalent helper is `scripts/codex-queue.sh`. + +## Starting or resuming work without the composer + +`codex exec ""` runs a complete non-interactive task, and +`codex resume --last ""` continues the most recent session. Both bypass the composer for +the same reason: they never load the gated input surface. + +## Why this is the safe fallback + +- **No interception.** Nothing is proxied, patched, or injected; there is no custom CA to install + and no TLS to terminate. +- **No app modification.** The desktop app, its asar bundle, and its update flow are untouched, so + updates cannot break it and it cannot break updates. +- **Native surface only.** `codex queue`, `codex exec`, and `codex resume` are documented CLI + commands that use the same app-server daemon and thread store as the app itself. +- **Quota is still enforced server-side.** This fallback only bypasses the client-side input gate. + A thread bound to a provider whose quota is actually exhausted still fails upstream; the benefit + is for threads routed to providers with remaining capacity (for example an opencodex pool or a + non-OpenAI provider), which the local gate would otherwise block incorrectly. +- **Remote-friendly.** The same command works over the app's remote-control channel, so it also + replaces the "send from another device" workaround for single-machine use. + +## When the gate lifts + +Nothing needs to be undone. The next composer message goes through the normal UI path again, and +queued messages already sent appear in the same thread history. diff --git a/scripts/codex-queue.ps1 b/scripts/codex-queue.ps1 new file mode 100644 index 00000000000..b22b4a8aa5a --- /dev/null +++ b/scripts/codex-queue.ps1 @@ -0,0 +1,48 @@ +<# +.SYNOPSIS + Send a message to an existing Codex thread via the native codex queue + command, bypassing the desktop composer's client-side usage gate. + +.DESCRIPTION + Uses only the app's own app-server daemon and thread store. No proxy, no + certificate, no app modification. When -Thread is omitted, the newest + rollout under ~/.codex/sessions is used. + +.EXAMPLE + .\codex-queue.ps1 "continue with the next step" + .\codex-queue.ps1 -Thread 019f644b-a10a-73c2-8c3f-f3c7713a2928 "status?" +#> +param( + [Parameter(Mandatory=$true, Position=0)] + [string]$Message, + [string]$Thread +) + +function Resolve-CodexExe { + $binRoot = Join-Path $env:LOCALAPPDATA "OpenAI\\Codex\\bin" + $candidates = @(Get-ChildItem $binRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + ForEach-Object { Join-Path $_.FullName "codex.exe" } | + Where-Object { Test-Path $_ }) + if ($candidates) { return $candidates[0] } + $cmd = Get-Command codex -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + throw "codex.exe not found on PATH or under $binRoot" +} + +function Resolve-LatestThread { + $sessions = Join-Path $env:USERPROFILE ".codex\\sessions" + $latest = Get-ChildItem $sessions -Recurse -Filter "rollout-*.jsonl" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if (-not $latest) { throw "no rollout sessions found under $sessions" } + if ($latest.Name -match "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})") { + return $Matches[1] + } + throw "could not parse thread id from $($latest.Name)" +} + +if (-not $Thread) { $Thread = Resolve-LatestThread } +$exe = Resolve-CodexExe +& $exe queue --thread $Thread --message $Message +exit $LASTEXITCODE diff --git a/scripts/codex-queue.sh b/scripts/codex-queue.sh new file mode 100755 index 00000000000..e4db5a392c2 --- /dev/null +++ b/scripts/codex-queue.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Send a message to an existing Codex thread via the native codex queue +# command, bypassing the desktop composer's client-side usage gate. +# +# Uses only the app's own app-server daemon and thread store. No proxy, no +# certificate, no app modification. Without --thread, the newest rollout +# under ~/.codex/sessions is used. +# +# Usage: +# ./codex-queue.sh "continue with the next step" +# ./codex-queue.sh --thread 019f644b-a10a-73c2-8c3f-f3c7713a2928 "status?" +set -euo pipefail + +THREAD="" +MESSAGE="" + +while [ $# -gt 0 ]; do + case "$1" in + --thread) + THREAD="${2:?--thread requires a value}" + shift 2 + ;; + --thread=*) + THREAD="${1#--thread=}" + shift + ;; + --message) + MESSAGE="${2:?--message requires a value}" + shift 2 + ;; + --message=*) + MESSAGE="${1#--message=}" + shift + ;; + -*) + echo "unknown option: $1" >&2 + exit 2 + ;; + *) + if [ -z "$MESSAGE" ]; then + MESSAGE="$1" + else + echo "unexpected extra argument: $1" >&2 + exit 2 + fi + shift + ;; + esac +done + +if [ -z "$MESSAGE" ]; then + echo "usage: codex-queue.sh [--thread ] " >&2 + exit 2 +fi + +resolve_codex() { + if command -v codex >/dev/null 2>&1; then + command -v codex + return + fi + local candidate + for candidate in \ + "$HOME/.codex/bin/codex" \ + "$HOME/.codex/bin"/*/codex \ + "$HOME/Applications/Codex.app/Contents/Resources/codex" \ + "/Applications/Codex.app/Contents/Resources/codex"; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return + fi + done + echo "codex not found on PATH or in the usual install locations" >&2 + return 1 +} + +resolve_latest_thread() { + local sessions="$HOME/.codex/sessions" + local latest + latest=$(find "$sessions" -type f -name 'rollout-*.jsonl' -exec ls -t {} + 2>/dev/null | head -n 1) + if [ -z "$latest" ]; then + echo "no rollout sessions found under $sessions" >&2 + return 1 + fi + basename "$latest" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -n 1 +} + +if [ -z "$THREAD" ]; then + THREAD=$(resolve_latest_thread) +fi +if [ -z "$THREAD" ]; then + echo "could not resolve a thread id" >&2 + exit 1 +fi + +CODEX_EXE=$(resolve_codex) +exec "$CODEX_EXE" queue --thread "$THREAD" --message "$MESSAGE" From d7375b0f159366649db194c6a5a0c5eacb2f83b9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:09:33 +0000 Subject: [PATCH 2/6] ci: retrigger macos 1/2 after 20-minute stall mid-shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 45ff4f9330453ffb947fab0b26c5cf68be4ed9ad Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 24 Sep 2026 04:20:00 +0000 Subject: [PATCH 3/6] fix: harden native Codex queue fallback and document dispatch limits Require deliberate thread targeting, honor CODEX_HOME, probe queue-capable native CLIs, preserve literal arguments, and avoid retries or auth changes. Document that queue acceptance is not execution and add offline wrapper tests. Validation: 24 Bash regression tests, bash -n, node --check, and diff check. Windows/PowerShell, macOS, live Desktop dispatch, and repository-wide Bun/docs checks were not executed in this environment; no cross-platform pass claimed. --- .../guides/composer-usage-gate-fallback.md | 160 +++++++--- scripts/codex-queue.ps1 | 172 ++++++++--- scripts/codex-queue.sh | 181 +++++++----- scripts/codex-queue.test.mjs | 275 ++++++++++++++++++ 4 files changed, 640 insertions(+), 148 deletions(-) create mode 100644 scripts/codex-queue.test.mjs diff --git a/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md b/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md index 1ffe2c4301a..cc4bd4ddd64 100644 --- a/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md +++ b/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md @@ -1,59 +1,137 @@ --- title: Composer Usage-Gate Fallback -description: How to keep sending messages when the desktop app's composer is disabled by the client-side usage gate, using only Codex's native CLI paths. +description: Queue text to an existing Codex thread without changing desktop authentication, while preserving server-side quotas and making delivery limits explicit. --- -When a ChatGPT account reaches its usage limit, the Codex desktop app disables the composer input -field through a client-side gate. The rest of the app keeps working: existing threads still run, -approvals still arrive, remote control still works, and queued follow-ups still execute. Only the -local input box is blocked. +A desktop **composer-only** usage gate can prevent new input even when a thread's configured +OpenCodex route has available capacity. On a compatible installation, `codex queue` is a +low-impact fallback: it submits through Codex's native app-server queue without using that input +box. It does not patch the app, intercept TLS, install a certificate, or change authentication. -Codex already ships a native path for this case. The `codex queue` command delivers a message to an -existing thread through the local app-server daemon — the same transport used by the app's own -follow-up queue and by remote control. It does not touch the composer UI, so the client-side gate -does not apply to it. +This is **not a fix for every usage-limit state**. It neither restores exhausted quota nor +unlocks the model picker. A thread already using `gpt-reserve` keeps that model; queueing text +does not switch it to another provider. Server-side authorization, provider quotas, approvals, +and the thread's execution settings still apply. + +## Check the target and compatibility first + +Use the **same `CODEX_HOME` and a compatible CLI/app-server** as the target desktop installation. +`CODEX_HOME` defaults to `~/.codex`; a different home can discover a different daemon and thread +store. Do not change it just to bypass an error. Prefer the app-bundled CLI; a separately installed +`codex` on PATH may be older. Check `codex queue --help` for `--thread` and `--message`. + +The helpers below probe this CLI capability, but only the actual request can verify the daemon's +`thread/queue/add` support. If Codex reports an unsupported queue method, save ongoing work before +updating/restarting the matching installation. The helpers do not restart a daemon, change +settings, or retry with another server. Do not add `--no-daemon` to a queue command. ## Sending to an existing thread +First open the intended conversation in the desktop app and confirm its project, model and +provider. Prefer its **explicit UUID**; the native CLI also accepts an exact session name: + ```powershell -codex queue --thread --message "continue with the next step" +codex queue --thread 'my-project-review' --message 'continue with the next step' ``` -The thread id is the UUID embedded in the rollout filename under -`~/.codex/sessions///
/rollout-*.jsonl`. Filenames can contain two -UUIDs (`rollout--_.jsonl`); the thread id is the -first one. The helper script `scripts/codex-queue.ps1` resolves the most recent -thread automatically: +From a repository checkout, the Windows helper can discover the bundled native `codex.exe`: + +```powershell +.\scripts\codex-queue.ps1 -Thread 'my-project-review' -Message 'continue with the next step' +``` + +On macOS/Linux, use Bash (including macOS's Bash 3.2): + +```bash +bash scripts/codex-queue.sh --thread 'my-project-review' --message 'continue with the next step' +``` + +To pin the matching trusted binary, pass `-CodexExe 'C:\path\to\codex.exe'` or +`--codex '/path/to/codex'`. Both helpers also accept `CODEX_EXE` as a path override. +An invalid explicit selection fails instead of silently picking another executable. On Windows, +use a native `.exe`, not an npm `.cmd` or PowerShell shim; this keeps message quoting out of +`cmd.exe`. Bundled and standalone package layouts are tried before PATH, and a candidate that +lacks the queue flags is skipped. Discovery is best-effort, not proof of a matching app version. + +### Optional latest-thread discovery + +Neither helper silently selects a thread when the target is omitted. For legacy rollout-based +stores, inspect an explicit **latest-file heuristic** with: ```powershell -scripts\codex-queue.ps1 "continue with the next step" -scripts\codex-queue.ps1 -Thread 019f644b-a10a-73c2-8c3f-f3c7713a2928 "status?" +.\scripts\codex-queue.ps1 -Latest -DryRun ``` -On macOS/Linux the equivalent helper is `scripts/codex-queue.sh`. +```bash +bash scripts/codex-queue.sh --latest --dry-run +``` + +This searches `CODEX_HOME/sessions` (or `~/.codex/sessions` by default) by modification time; +filename order breaks equal-time ties. It is **not the current desktop conversation** and can +select a different project or a subagent. Verify the preview, then use `-Thread` / `--thread` +with the chosen UUID. `-Latest` / `--latest` can also send when a message is provided, but that +remains an explicit opt-in to this heuristic. + +Recognized `rollout-*.jsonl` filenames contain a thread UUID, sometimes followed by +`_`; the helper uses the first UUID. Malformed names are skipped, and missing or +unreadable stores fail rather than falling back to another home. Migrated/paginated-only stores +and remote-only conversations may have no matching local rollout: use the explicit UUID/name +instead. `-DryRun` / `--dry-run` only probes CLI help and prints the chosen executable and target; +it never queues or prints the message body. + +Keep the entire message in one argument. Bash accepts `--message '- start with this'` or +`-- '- start with this'`; PowerShell accepts `-Message '- start with this'`. Shell history and +local process listings can expose command-line text, so do not include credentials in prompts. + +## Queued is not the same as executed + +`Queued message ... for thread ...` confirms **queue acceptance**, not model execution or +completion. A busy thread may wait for its current turn or approval. In the inspected upstream +implementation, an unloaded saved thread can retain the message without dispatching it until +another client resumes that thread. + +Check the queue and activity in the **same conversation**. If it is not loaded, open it in the +app or use `codex resume ` without adding the prompt again. Review pending approvals +and the thread's queue state. Do not repeatedly re-send a message just because no response has +appeared: each queue invocation can create another queued item. The helpers propagate the CLI +exit status and never retry automatically; after an ambiguous failure, inspect before retrying. + +The helpers target local discovery; they do not identify a conversation on another machine from +local filenames. The native CLI has explicit `--remote` options (see `codex queue --help`), but +that is distinct from assuming the desktop's remote-control connection is automatically reused. +This workaround leaves desktop authentication configuration alone; it does not guarantee that an +unrelated authentication/network fault or unsupported server will be repaired. ## Starting or resuming work without the composer -`codex exec ""` runs a complete non-interactive task, and -`codex resume --last ""` continues the most recent session. Both bypass the composer for -the same reason: they never load the gated input surface. - -## Why this is the safe fallback - -- **No interception.** Nothing is proxied, patched, or injected; there is no custom CA to install - and no TLS to terminate. -- **No app modification.** The desktop app, its asar bundle, and its update flow are untouched, so - updates cannot break it and it cannot break updates. -- **Native surface only.** `codex queue`, `codex exec`, and `codex resume` are documented CLI - commands that use the same app-server daemon and thread store as the app itself. -- **Quota is still enforced server-side.** This fallback only bypasses the client-side input gate. - A thread bound to a provider whose quota is actually exhausted still fails upstream; the benefit - is for threads routed to providers with remaining capacity (for example an opencodex pool or a - non-OpenAI provider), which the local gate would otherwise block incorrectly. -- **Remote-friendly.** The same command works over the app's remote-control channel, so it also - replaces the "send from another device" workaround for single-machine use. - -## When the gate lifts - -Nothing needs to be undone. The next composer message goes through the normal UI path again, and -queued messages already sent appear in the same thread history. +`codex exec ''` starts a non-interactive task; it is **not** delivery into the currently +open desktop thread. Check its working directory, provider, permissions and configuration. +`codex resume ` resumes an explicit existing session. `codex resume --last` normally +filters selection by the current working directory; `--all` disables that filter, while other +session eligibility filters can still apply. A global selection is not necessarily the visible +or newest filesystem session. Prefer an explicit ID for ongoing desktop work. + +## Scope and verification + +This fallback is preferable to changing feature-gate responses or authentication solely to get +text into an otherwise usable thread: it changes neither account entitlement nor app files. +It is a workaround, not a provider-aware repair of the desktop composer/model picker. App/CLI +updates can still change compatibility. When the composer becomes usable, no helper-specific +configuration needs reverting; these scripts do not undo unrelated earlier proxy/certificate +changes. + +The original Windows probe (desktop `26.917.9434.0`) reported queue acceptance for a live thread +and an expected error for a nonexistent thread. That is not a general end-to-end inference, +unloaded-thread, remote-control or cross-platform guarantee. The current upstream source was +also checked at commit `7dae8c53d97e61cd774e4d6bcca5243c29ca615c`: + +- [CLI queue options](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/cli/src/queue_cmd.rs) + and [app-server submission](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/tui/src/session_queue_commands.rs). +- [Loaded-thread queue dispatch](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/ext/queue/src/service.rs) + and [resume selection options](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/cli/src/main.rs). + +Maintainers can run the offline wrapper regressions with +`node --test scripts/codex-queue.test.mjs` (Node 20+). They use a fake native CLI and temporary +homes, never a real account or model. Windows runs Windows PowerShell and, when installed, pwsh; +POSIX runs Bash and, when installed, pwsh. These tests do not establish live queue dispatch or +Desktop compatibility; validate those separately on the supported installations. diff --git a/scripts/codex-queue.ps1 b/scripts/codex-queue.ps1 index b22b4a8aa5a..8c1170dc4da 100644 --- a/scripts/codex-queue.ps1 +++ b/scripts/codex-queue.ps1 @@ -1,48 +1,152 @@ +#Requires -Version 5.1 <# .SYNOPSIS - Send a message to an existing Codex thread via the native codex queue - command, bypassing the desktop composer's client-side usage gate. - + Queue one text message with the native Codex CLI; no auth/config/app changes. .DESCRIPTION - Uses only the app's own app-server daemon and thread store. No proxy, no - certificate, no app modification. When -Thread is omitted, the newest - rollout under ~/.codex/sessions is used. - + Requires an explicit -Thread or opt-in -Latest. CODEX_HOME is honored. + Latest means filesystem activity, not the foreground chat. Inspect -DryRun + first, then prefer -Thread. DryRun probes queue help but never sends a message. + Windows requires a native codex.exe, not a .cmd/.ps1 shim. +.EXAMPLE + .\codex-queue.ps1 -Thread -Message 'continue' .EXAMPLE - .\codex-queue.ps1 "continue with the next step" - .\codex-queue.ps1 -Thread 019f644b-a10a-73c2-8c3f-f3c7713a2928 "status?" + .\codex-queue.ps1 -Latest -DryRun #> +[CmdletBinding()] param( - [Parameter(Mandatory=$true, Position=0)] - [string]$Message, - [string]$Thread + [Parameter(Position=0)][string]$Message = '', + [string]$Thread = '', + [switch]$Latest, + [switch]$DryRun, + [string]$CodexExe = $env:CODEX_EXE ) +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' -function Resolve-CodexExe { - $binRoot = Join-Path $env:LOCALAPPDATA "OpenAI\\Codex\\bin" - $candidates = @(Get-ChildItem $binRoot -Directory -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | - ForEach-Object { Join-Path $_.FullName "codex.exe" } | - Where-Object { Test-Path $_ }) - if ($candidates) { return $candidates[0] } - $cmd = Get-Command codex -ErrorAction SilentlyContinue - if ($cmd) { return $cmd.Source } - throw "codex.exe not found on PATH or under $binRoot" +function ConvertTo-NativeArgument([string]$Value) { + # Windows CRT quoting for .NET Framework / Windows PowerShell 5.1: + # double backslashes before a quote and before the closing quote. + $escaped = [regex]::Replace($Value, '(\\*)"', '$1$1\"') + $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1') + return '"' + $escaped + '"' } -function Resolve-LatestThread { - $sessions = Join-Path $env:USERPROFILE ".codex\\sessions" - $latest = Get-ChildItem $sessions -Recurse -Filter "rollout-*.jsonl" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | +function Invoke-CodexNative([string]$Exe, [string[]]$Arguments, [switch]$Probe) { + # Avoid cmd.exe and PowerShell's legacy native-argument serialization: quotes, + # Unicode, newlines, trailing slashes and shell metacharacters must stay data. + $info = New-Object System.Diagnostics.ProcessStartInfo + $info.FileName = $Exe + $info.UseShellExecute = $false + if ($null -ne $info.PSObject.Properties['ArgumentList']) { + foreach ($argument in $Arguments) { $info.ArgumentList.Add($argument) } + } else { + $info.Arguments = (($Arguments | ForEach-Object { ConvertTo-NativeArgument $_ }) -join ' ') + } + $info.RedirectStandardOutput = [bool]$Probe + $info.RedirectStandardError = [bool]$Probe + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $info + try { + [void]$process.Start() + if ($Probe) { + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(10000)) { + $process.Kill() + throw 'Codex queue help timed out; select the matching app-bundled CLI with -CodexExe.' + } + $process.WaitForExit() + return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = $stdout.Result } + } + $process.WaitForExit() + return $process.ExitCode + } finally { + $process.Dispose() + } +} + +function Test-CodexQueue([string]$Path) { + # A successful generic help response is not enough: check queue-specific flags. + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } + if ($env:OS -eq 'Windows_NT' -and [IO.Path]::GetExtension($Path) -ine '.exe') { return $false } + try { + $nativePath = (Get-Item -LiteralPath $Path).FullName + $probe = Invoke-CodexNative $nativePath @('queue', '--help') -Probe + return $probe.ExitCode -eq 0 -and $probe.Output.Contains('--thread') -and $probe.Output.Contains('--message') + } catch { return $false } +} + +function Resolve-CodexExe([string]$Explicit, [string]$CodexHomeDir) { + # Pinning is authoritative: do not fall back after an invalid explicit choice. + if (-not [string]::IsNullOrEmpty($Explicit)) { + if (-not (Test-CodexQueue $Explicit)) { + throw 'Selected CLI does not support queue --thread/--message. On Windows, -CodexExe must name a native .exe, not a command shim.' + } + return (Get-Item -LiteralPath $Explicit).FullName + } + $candidates = New-Object 'System.Collections.Generic.List[string]' + if ($env:LOCALAPPDATA) { + $binRoot = Join-Path $env:LOCALAPPDATA 'OpenAI\Codex\bin' + $bundled = @(Get-ChildItem -LiteralPath $binRoot -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'codex.exe') -ErrorAction SilentlyContinue } | + Sort-Object LastWriteTimeUtc, FullName -Descending) + foreach ($file in $bundled) { $candidates.Add($file.FullName) } + } + foreach ($root in @($CodexHomeDir, (Join-Path $HOME '.codex'))) { + $candidates.Add((Join-Path $root 'packages\standalone\current\bin\codex.exe')) + $candidates.Add((Join-Path $root 'packages\standalone\current\codex.exe')) + } + foreach ($name in @('codex.exe', 'codex')) { + foreach ($command in @(Get-Command $name -CommandType Application -All -ErrorAction SilentlyContinue)) { + $candidates.Add($command.Source) + } + } + foreach ($candidate in $candidates) { + if (Test-CodexQueue $candidate) { return (Get-Item -LiteralPath $candidate).FullName } + } + throw 'No queue-capable native Codex CLI found. Install/update Codex or supply -CodexExe.' +} + +function Resolve-LatestThread([string]$CodexHomeDir) { + # Scan only the effective store. Fail on incomplete reads instead of silently + # selecting from another account/home. Deterministic filename order breaks ties. + $sessions = Join-Path $CodexHomeDir 'sessions' + if (-not (Test-Path -LiteralPath $sessions -PathType Container)) { + throw 'No sessions directory under the effective CODEX_HOME.' + } + $uuid = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + $pattern = '^rollout-.+-(' + $uuid + ')(_' + $uuid + ')?\.jsonl$' + $latestFile = Get-ChildItem -LiteralPath $sessions -Recurse -File -Filter 'rollout-*.jsonl' | + Where-Object { $_.Name -match $pattern } | + Sort-Object LastWriteTimeUtc, FullName -Descending | Select-Object -First 1 - if (-not $latest) { throw "no rollout sessions found under $sessions" } - if ($latest.Name -match "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})") { - return $Matches[1] + if ($null -eq $latestFile -or $latestFile.Name -notmatch $pattern) { + throw 'No recognized rollout thread found; specify -Thread explicitly.' } - throw "could not parse thread id from $($latest.Name)" + return $Matches[1] } -if (-not $Thread) { $Thread = Resolve-LatestThread } -$exe = Resolve-CodexExe -& $exe queue --thread $Thread --message $Message -exit $LASTEXITCODE +try { + if ([string]::IsNullOrEmpty($Thread) -and -not $Latest) { + throw 'Choose -Thread or explicitly opt in with -Latest.' + } + if (-not [string]::IsNullOrEmpty($Thread) -and $Latest) { throw '-Thread and -Latest are mutually exclusive.' } + if (-not $DryRun -and [string]::IsNullOrEmpty($Message)) { throw 'A nonempty message is required.' } + if ($Message.IndexOf([char]0) -ge 0 -or $Thread.IndexOf([char]0) -ge 0) { throw 'NUL characters cannot be passed to the native CLI.' } + $codexHomeDir = if ([string]::IsNullOrEmpty($env:CODEX_HOME)) { Join-Path $HOME '.codex' } else { $env:CODEX_HOME } + if ($Latest) { + $Thread = Resolve-LatestThread $codexHomeDir + Write-Warning '-Latest may select a different project or a subagent, not the foreground chat.' + } + $exe = Resolve-CodexExe $CodexExe $codexHomeDir + if ($DryRun) { + Write-Output "Codex: $exe" "Thread: $Thread" 'Dry run only; no message was queued.' + exit 0 + } + # Preserve the CLI's exit code. Never retry an ambiguous queue result automatically. + $code = Invoke-CodexNative $exe @('queue', "--thread=$Thread", "--message=$Message") + exit $code +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} diff --git a/scripts/codex-queue.sh b/scripts/codex-queue.sh index e4db5a392c2..6d031ada83a 100755 --- a/scripts/codex-queue.sh +++ b/scripts/codex-queue.sh @@ -1,96 +1,131 @@ #!/bin/bash -# Send a message to an existing Codex thread via the native codex queue -# command, bypassing the desktop composer's client-side usage gate. -# -# Uses only the app's own app-server daemon and thread store. No proxy, no -# certificate, no app modification. Without --thread, the newest rollout -# under ~/.codex/sessions is used. -# -# Usage: -# ./codex-queue.sh "continue with the next step" -# ./codex-queue.sh --thread 019f644b-a10a-73c2-8c3f-f3c7713a2928 "status?" +# Queue one text message using Codex's native CLI. No auth/config/app changes. +# A target is required: --thread is preferred; --latest is an explicit heuristic. +# Run with --help for usage. Requires Bash 3.2+ and standard POSIX utilities. set -euo pipefail -THREAD="" -MESSAGE="" +usage() { + cat <<'USAGE' +Usage: codex-queue.sh (--thread | --latest) [options] [] + --message Explicit message (also accepts text beginning with a dash) + --codex Pin a trusted native CLI; otherwise discover a queue-capable CLI + --dry-run Show the executable and target without queueing (message optional) + -- End options; the next argument is the entire message + --help Show this help +CODEX_HOME is honored. --latest is global filesystem activity, NOT the active UI chat; +prefer --latest --dry-run, verify the target, then send with --thread . +USAGE +} -while [ $# -gt 0 ]; do +fail() { printf '%s\n' "$1" >&2; exit 2; } +THREAD=""; LATEST=0; MESSAGE=""; MESSAGE_SET=0; DRY_RUN=0 +CODEX_EXE="${CODEX_EXE:-}" +while [ "$#" -gt 0 ]; do case "$1" in - --thread) - THREAD="${2:?--thread requires a value}" - shift 2 - ;; - --thread=*) - THREAD="${1#--thread=}" - shift - ;; - --message) - MESSAGE="${2:?--message requires a value}" - shift 2 - ;; - --message=*) - MESSAGE="${1#--message=}" + --thread|--message|--codex) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail "$1 requires a nonempty value" + case "$1" in + --thread) [ -z "$THREAD" ] || fail "--thread was supplied twice"; THREAD="$2" ;; + --message) [ "$MESSAGE_SET" -eq 0 ] || fail "message was supplied twice"; MESSAGE="$2"; MESSAGE_SET=1 ;; + --codex) CODEX_EXE="$2" ;; + esac + shift 2 ;; + --thread=*|--message=*|--codex=*) + value="${1#*=}" + [ -n "$value" ] || fail "option requires a nonempty value" + case "$1" in + --thread=*) [ -z "$THREAD" ] || fail "--thread was supplied twice"; THREAD="$value" ;; + --message=*) [ "$MESSAGE_SET" -eq 0 ] || fail "message was supplied twice"; MESSAGE="$value"; MESSAGE_SET=1 ;; + --codex=*) CODEX_EXE="$value" ;; + esac + shift ;; + --latest) LATEST=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --help|-h) usage; exit 0 ;; + --) shift - ;; - -*) - echo "unknown option: $1" >&2 - exit 2 - ;; + [ "$#" -eq 1 ] && [ "$MESSAGE_SET" -eq 0 ] || fail "supply exactly one message after --" + MESSAGE="$1"; MESSAGE_SET=1; shift ;; + -*) fail "unknown option (use --message or -- before a dash-prefixed message)" ;; *) - if [ -z "$MESSAGE" ]; then - MESSAGE="$1" - else - echo "unexpected extra argument: $1" >&2 - exit 2 - fi - shift - ;; + [ "$MESSAGE_SET" -eq 0 ] || fail "message was supplied twice" + MESSAGE="$1"; MESSAGE_SET=1; shift ;; esac done +[ -n "$THREAD" ] || [ "$LATEST" -eq 1 ] || fail "choose --thread or explicitly opt in with --latest" +[ -z "$THREAD" ] || [ "$LATEST" -eq 0 ] || fail "--thread and --latest are mutually exclusive" +[ "$DRY_RUN" -eq 1 ] || [ -n "$MESSAGE" ] || fail "a nonempty message is required" +CODEX_HOME_DIR="${CODEX_HOME:-${HOME:?HOME is required}/.codex}" -if [ -z "$MESSAGE" ]; then - echo "usage: codex-queue.sh [--thread ] " >&2 - exit 2 -fi +# Read every NUL-delimited path before selecting: no ls batches, SIGPIPE, or +# partial result on a failed scan. Do not follow symlinked session directories. +resolve_latest_thread() ( + local sessions="$CODEX_HOME_DIR/sessions" paths candidate latest="" latest_id="" name + local uuid='[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + local pattern="^rollout-.+-($uuid)(_$uuid)?\\.jsonl$" + [ -d "$sessions" ] || fail "no sessions directory under the effective CODEX_HOME" + paths=$(mktemp) || fail "could not create temporary session listing" + trap 'rm -f -- "$paths"' EXIT + find "$sessions" -type f -name 'rollout-*.jsonl' -print0 > "$paths" || fail "session scan failed; refusing a partial selection" + while IFS= read -r -d '' candidate; do + name="${candidate##*/}" + [[ "$name" =~ $pattern ]] || continue + if [[ -z "$latest" || "$candidate" -nt "$latest" ]] || + { [[ ! "$latest" -nt "$candidate" ]] && [[ "$candidate" > "$latest" ]]; }; then + latest="$candidate" + latest_id="${BASH_REMATCH[1]}" + fi + done < "$paths" + [ -n "$latest_id" ] || fail "no recognized rollout thread found; specify --thread explicitly" + printf '%s\n' "$latest_id" +) + +# Probe help only: an older CLI can print top-level help with exit 0, so require +# both queue-specific flags. This does not prove the running daemon is compatible. +supports_queue() { + local help + [ -f "$1" ] && [ -x "$1" ] || return 1 + help=$("$1" queue --help 2>/dev/null) || return 1 + [[ "$help" == *--thread* && "$help" == *--message* ]] +} +# Prefer app bundles over a stale PATH CLI; cover both standalone package layouts. +# Explicit selection is authoritative and never silently falls back to another CLI. resolve_codex() { - if command -v codex >/dev/null 2>&1; then - command -v codex + local candidate + if [ -n "$CODEX_EXE" ]; then + case "$CODEX_EXE" in /*) ;; *) CODEX_EXE="$PWD/$CODEX_EXE" ;; esac + supports_queue "$CODEX_EXE" || fail "selected CLI does not support queue --thread/--message; check --codex/CODEX_EXE" + printf '%s\n' "$CODEX_EXE" return fi - local candidate for candidate in \ - "$HOME/.codex/bin/codex" \ - "$HOME/.codex/bin"/*/codex \ "$HOME/Applications/Codex.app/Contents/Resources/codex" \ - "/Applications/Codex.app/Contents/Resources/codex"; do - if [ -x "$candidate" ]; then - printf '%s\n' "$candidate" - return - fi + "/Applications/Codex.app/Contents/Resources/codex" \ + "$CODEX_HOME_DIR/packages/standalone/current/bin/codex" \ + "$CODEX_HOME_DIR/packages/standalone/current/codex" \ + "$HOME/.codex/packages/standalone/current/bin/codex" \ + "$HOME/.codex/packages/standalone/current/codex" \ + "$HOME/.codex/bin/codex" "$HOME/.codex/bin"/*/codex; do + if supports_queue "$candidate"; then printf '%s\n' "$candidate"; return; fi done - echo "codex not found on PATH or in the usual install locations" >&2 - return 1 -} - -resolve_latest_thread() { - local sessions="$HOME/.codex/sessions" - local latest - latest=$(find "$sessions" -type f -name 'rollout-*.jsonl' -exec ls -t {} + 2>/dev/null | head -n 1) - if [ -z "$latest" ]; then - echo "no rollout sessions found under $sessions" >&2 - return 1 + candidate=$(type -P codex || true) + if [ -n "$candidate" ] && supports_queue "$candidate"; then + printf '%s\n' "$candidate" + return fi - basename "$latest" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -n 1 + fail "no queue-capable Codex CLI found; install/update Codex or specify --codex /path/to/codex" } -if [ -z "$THREAD" ]; then +if [ "$LATEST" -eq 1 ]; then THREAD=$(resolve_latest_thread) + printf '%s\n' 'Warning: --latest may select a different project or a subagent, not the foreground chat.' >&2 fi -if [ -z "$THREAD" ]; then - echo "could not resolve a thread id" >&2 - exit 1 -fi - CODEX_EXE=$(resolve_codex) -exec "$CODEX_EXE" queue --thread "$THREAD" --message "$MESSAGE" +if [ "$DRY_RUN" -eq 1 ]; then + printf 'Codex: %s\nThread: %s\nDry run only; no message was queued.\n' "$CODEX_EXE" "$THREAD" + exit 0 +fi +# Equals-form flags keep dash-prefixed names/text as values. No eval, no retry: +# a failure/timeout can be ambiguous, and a second invocation could duplicate work. +exec "$CODEX_EXE" queue "--thread=$THREAD" "--message=$MESSAGE" diff --git a/scripts/codex-queue.test.mjs b/scripts/codex-queue.test.mjs new file mode 100644 index 00000000000..448464494bb --- /dev/null +++ b/scripts/codex-queue.test.mjs @@ -0,0 +1,275 @@ +// Standalone, offline regression tests: node --test scripts/codex-queue.test.mjs +// No real Codex process, credentials, daemon, or model requests are used. +import { after, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const scripts = dirname(fileURLToPath(import.meta.url)); +const windows = process.platform === 'win32'; +const roots = []; +after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); +const threadA = '00000000-0000-4000-8000-000000000001'; +const threadB = '00000000-0000-4000-8000-000000000002'; +const rolloutId = '00000000-0000-4000-8000-000000000099'; +function scratch() { + const root = mkdtempSync(join(tmpdir(), 'ocx queue test ')); + roots.push(root); + return root; +} + +// The fake native CLI logs base64 fields, avoiding an additional JSON library +// on Windows. Only actual queue submissions are logged; --help is read-only. +const stubJs = `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +if (args[0] === 'queue' && args[1] === '--help') { + console.log(path.basename(process.argv[1]).startsWith('old') ? 'old CLI help' : 'queue --thread THREAD --message TEXT'); + process.exit(process.env.STUB_HELP_FAIL ? 1 : 0); +} +const fields = [process.env.CODEX_HOME || '', process.argv[1], ...args]; +fs.appendFileSync(process.env.STUB_LOG, fields.map(s => Buffer.from(s).toString('base64')).join('|') + '\\n'); +process.exit(Number(process.env.STUB_EXIT || 0)); +`; +const stubCs = `using System; +using System.IO; +using System.Linq; +using System.Text; +class QueueStub { + static int Main(string[] args) { + string exe = Environment.GetCommandLineArgs()[0]; + if (args.Length == 2 && args[0] == "queue" && args[1] == "--help") { + Console.WriteLine(Path.GetFileName(exe).StartsWith("old") ? "old CLI help" : "queue --thread THREAD --message TEXT"); + return Environment.GetEnvironmentVariable("STUB_HELP_FAIL") == null ? 0 : 1; + } + string[] fields = new string[] { Environment.GetEnvironmentVariable("CODEX_HOME") ?? "", exe }.Concat(args).ToArray(); + File.AppendAllText(Environment.GetEnvironmentVariable("STUB_LOG"), String.Join("|", fields.Select(s => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)))) + "\\n"); + return Int32.Parse(Environment.GetEnvironmentVariable("STUB_EXIT") ?? "0"); + } +}`; +let nativeStub; +if (windows) { + const build = scratch(); + const compiler = join(process.env.WINDIR || 'C:\\Windows', 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe'); + nativeStub = join(build, 'queue-stub.exe'); + const source = join(build, 'QueueStub.cs'); + writeFileSync(source, stubCs); + assert.ok(existsSync(compiler), 'Windows tests require the built-in .NET Framework C# compiler'); + const result = spawnSync(compiler, ['/nologo', '/target:exe', `/out:${nativeStub}`, source], { encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.stderr + result.stdout); +} +function makeStub(path) { + mkdirSync(dirname(path), { recursive: true }); + if (windows) copyFileSync(nativeStub, path); + else { writeFileSync(path, stubJs); chmodSync(path, 0o755); } + return path; +} +function fixture() { + const root = scratch(); + const home = join(root, 'home with spaces'); + const store = join(root, 'configured [store]'); + mkdirSync(home); mkdirSync(store); + const exe = makeStub(join(root, 'bin with spaces', windows ? 'codex.exe' : 'codex')); + const log = join(root, 'submissions.log'); + const env = { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: store, + LOCALAPPDATA: join(root, 'local app data'), STUB_LOG: log, + PATH: dirname(process.execPath) + (windows ? ';' : ':') + process.env.PATH }; + delete env.CODEX_EXE; delete env.STUB_HELP_FAIL; delete env.STUB_EXIT; + return { root, home, store, exe, log, env }; +} +function rollout(store, id, timestamp, suffix = '', subdir = '2026/01/01') { + const path = join(store, 'sessions', subdir, `rollout-2026-01-01T00-00-00-${id}${suffix}.jsonl`); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '{}\n'); + utimesSync(path, timestamp, timestamp); + return path; +} +function records(f) { + if (!existsSync(f.log)) return []; + return readFileSync(f.log, 'utf8').trim().split('\n').map(line => { + const [home, exe, ...args] = line.split('|').map(field => Buffer.from(field, 'base64').toString()); + return { home, exe, args }; + }); +} +const shells = windows + ? ['powershell.exe', 'pwsh.exe'] + : ['bash', ...(spawnSync('pwsh', ['-NoProfile', '-Command', 'exit 0']).status === 0 ? ['pwsh'] : [])]; +for (const shell of shells) { + const ps = shell !== 'bash'; + const available = spawnSync(shell, ps ? ['-NoProfile', '-Command', 'exit 0'] : ['--version']).status === 0; + describe(`${shell} native queue helper`, { skip: !available }, () => { + function run(f, options = {}) { + const args = [join(scripts, ps ? 'codex-queue.ps1' : 'codex-queue.sh')]; + const flagPositions = new Set(); + const flag = (name, value) => { flagPositions.add(args.length); args.push(ps ? '-' + name : '--' + ({ CodexExe: 'codex', DryRun: 'dry-run' }[name] || name.toLowerCase())); if (value !== undefined) args.push(value); }; + if (options.pin !== false) flag('CodexExe', options.exe || f.exe); + if (options.thread !== undefined) flag('Thread', options.thread); + if (options.latest) flag('Latest'); + if (options.dryRun) flag('DryRun'); + if (options.message !== undefined) flag('Message', options.message); + if (options.extra) args.push(...options.extra); + // Enter through PowerShell literals, not -File's external string binder. + // This isolates the helper's native argv handling, even for leading dashes. + const psLiteral = value => "'" + value.replaceAll("'", "''") + "'"; + const command = '& ' + args.map((arg, index) => + flagPositions.has(index) + ? arg : psLiteral(arg)).join(' '); + const launchArgs = ps + ? ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')] + : args; + const result = spawnSync(shell, launchArgs, { env: { ...f.env, ...options.env }, cwd: f.root, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.error, undefined, result.error?.message); + return { ...result, output: result.stdout + result.stderr }; + } + it('requires a deliberate target without submitting', () => { + const f = fixture(); rollout(f.store, threadA, 100); + assert.notEqual(run(f, { message: 'do not send' }).status, 0); + assert.deepEqual(records(f), []); + }); + it('refuses mutually exclusive targets', () => { + const f = fixture(); + assert.notEqual(run(f, { thread: threadA, latest: true, message: 'do not send' }).status, 0); + assert.deepEqual(records(f), []); + }); + it('accepts an explicit thread without a local rollout scan', () => { + const f = fixture(); + assert.equal(run(f, { thread: threadA, message: 'continue' }).status, 0); + assert.deepEqual(records(f)[0].args, ['queue', `--thread=${threadA}`, '--message=continue']); + }); + it('passes an exact name unchanged, including spaces and a leading dash', () => { + const f = fixture(); const name = '-my exact project name'; + assert.equal(run(f, { thread: name, message: 'continue' }).status, 0); + assert.equal(records(f)[0].args[1], `--thread=${name}`); + }); + it('preserves quotes, Unicode, multiline text, metacharacters and trailing backslashes', () => { + const f = fixture(); const message = '- "한글"\n$(do-not-execute) & | ; %PATH% `quote` \\path\\'; + assert.equal(run(f, { thread: threadA, message }).status, 0); + assert.equal(records(f)[0].args[2], `--message=${message}`); + }); + it('keeps an option-looking message as text', () => { + const f = fixture(); const message = '-Thread'; + assert.equal(run(f, { thread: threadA, message }).status, 0); + assert.equal(records(f)[0].args[2], `--message=${message}`); + }); + it('rejects empty messages without submitting', () => { + const f = fixture(); + assert.notEqual(run(f, { thread: threadA, message: '' }).status, 0); + assert.deepEqual(records(f), []); + }); + it('dry run can omit a message and never submits', () => { + const f = fixture(); const result = run(f, { thread: threadA, dryRun: true }); + assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(threadA)); + assert.deepEqual(records(f), []); + }); + it('uses CODEX_HOME rather than a newer unrelated default-home session', () => { + const f = fixture(); rollout(f.store, threadA, 100); rollout(join(f.home, '.codex'), threadB, 200); + const result = run(f, { latest: true, message: 'continue' }); + assert.equal(result.status, 0, result.output); + assert.equal(records(f)[0].args[1], `--thread=${threadA}`); assert.equal(records(f)[0].home, f.store); + }); + it('does not fall back to default-home sessions when the effective store is empty', () => { + const f = fixture(); rollout(join(f.home, '.codex'), threadB, 100); + assert.notEqual(run(f, { latest: true, message: 'do not send' }).status, 0); + assert.deepEqual(records(f), []); + }); + it('extracts the first UUID from two-UUID rollout names', () => { + const f = fixture(); rollout(f.store, threadA, 100, '_' + rolloutId); + assert.equal(run(f, { latest: true, message: 'continue' }).status, 0); + assert.equal(records(f)[0].args[1], `--thread=${threadA}`); + }); + it('skips malformed newest filenames instead of masking a valid session', () => { + const f = fixture(); rollout(f.store, threadA, 100); rollout(f.store, 'not-a-thread', 200); + assert.equal(run(f, { latest: true, message: 'continue' }).status, 0); + assert.equal(records(f)[0].args[1], `--thread=${threadA}`); + }); + it('handles a large tree and selects the globally newest file', () => { + const f = fixture(); + for (let i = 0; i < 2200; i++) rollout(f.store, threadA, 100, '', `many/${String(i).padStart(5, '0')}`); + rollout(f.store, threadB, 200, '', 'last'); + const result = run(f, { latest: true, message: 'continue' }); + assert.equal(result.status, 0, result.output); assert.equal(records(f)[0].args[1], `--thread=${threadB}`); + }); + it('propagates a queue failure once, without retrying', () => { + const f = fixture(); + assert.equal(run(f, { thread: threadA, message: 'continue', env: { STUB_EXIT: '23' } }).status, 23); + assert.equal(records(f).length, 1); + }); + it('fails closed when pinned CLI only prints generic help', () => { + const f = fixture(); const old = makeStub(join(f.root, windows ? 'old.exe' : 'old')); + assert.notEqual(run(f, { exe: old, thread: threadA, message: 'do not send' }).status, 0); + assert.deepEqual(records(f), []); + }); + it('fails closed when pinned CLI help fails', () => { + const f = fixture(); + assert.notEqual(run(f, { thread: threadA, message: 'do not send', env: { STUB_HELP_FAIL: '1' } }).status, 0); + assert.deepEqual(records(f), []); + }); + it('does not silently replace a missing pinned executable', () => { + const f = fixture(); + assert.notEqual(run(f, { exe: join(f.root, 'missing'), thread: threadA, message: 'do not send' }).status, 0); + assert.deepEqual(records(f), []); + }); + it('accepts an explicit relative executable path without a PATH lookup', () => { + const f = fixture(); const name = windows ? 'relative.exe' : 'relative'; makeStub(join(f.root, name)); + assert.equal(run(f, { exe: name, thread: threadA, message: 'continue' }).status, 0); + assert.equal(records(f).length, 1); + }); + if (windows) { + it('rejects command shims before starting them', () => { + const f = fixture(); const shim = join(f.root, 'codex.cmd'); + writeFileSync(shim, '@echo off\r\nexit /b 0\r\n'); + assert.notEqual(run(f, { exe: shim, thread: threadA, message: 'do not send' }).status, 0); + assert.deepEqual(records(f), []); + }); + it('discovers the Windows app bundle before standalone/PATH (dry run only)', () => { + const f = fixture(); + const bundled = makeStub(join(f.env.LOCALAPPDATA, 'OpenAI/Codex/bin/build-a/codex.exe')); + const result = run(f, { pin: false, thread: threadA, dryRun: true }); + assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(bundled)); + assert.deepEqual(records(f), []); + }); + } + if (!ps) { + it('supports -- before dash-prefixed positional text', () => { + const f = fixture(); const message = '- quoted "message"'; + assert.equal(run(f, { thread: threadA, extra: ['--', message] }).status, 0); + assert.equal(records(f)[0].args[2], `--message=${message}`); + }); + it('rejects duplicate messages and missing option values', () => { + const f = fixture(); + for (const extra of [['--message'], ['--thread='], ['--message=a', '--message=b']]) { + assert.notEqual(run(f, { thread: threadA, extra }).status, 0); + } + assert.deepEqual(records(f), []); + }); + it('handles newline characters in a session directory name', () => { + const f = fixture(); rollout(f.store, threadA, 100, '', 'folder\nwith newline'); + assert.equal(run(f, { latest: true, message: 'continue' }).status, 0); + assert.equal(records(f)[0].args[1], `--thread=${threadA}`); + }); + it('uses default HOME only when CODEX_HOME is unset or empty', () => { + const f = fixture(); rollout(join(f.home, '.codex'), threadA, 100); + assert.equal(run(f, { latest: true, message: 'continue', env: { CODEX_HOME: '' } }).status, 0); + assert.equal(records(f)[0].args[1], `--thread=${threadA}`); + }); + it('prefers a bundled CLI to an old PATH CLI (discovery only)', () => { + const f = fixture(); + const bundled = makeStub(join(f.home, 'Applications/Codex.app/Contents/Resources/codex')); + const old = makeStub(join(f.root, 'old-path/codex')); + const result = run(f, { pin: false, thread: threadA, dryRun: true, env: { PATH: dirname(old) + ':' + f.env.PATH } }); + assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(bundled)); + assert.deepEqual(records(f), []); + }); + it('finds the standalone bin layout (discovery only)', { skip: existsSync('/Applications/Codex.app/Contents/Resources/codex') }, () => { + const f = fixture(); const standalone = makeStub(join(f.store, 'packages/standalone/current/bin/codex')); + const result = run(f, { pin: false, thread: threadA, dryRun: true }); + assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(standalone)); + assert.deepEqual(records(f), []); + }); + } + }); +} From c938967ad997a2a38cb2d3c49b2eebbf7761f20d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 24 Sep 2026 04:43:39 +0000 Subject: [PATCH 4/6] fix: preserve queue integration context and private diagnostics Document the on-demand lifecycle, ordinary-usage behavior, existing routing ownership, and repository-only distribution. Preserve PowerShell's current filesystem directory for native CLI children and redact dry-run selection unless explicitly requested in a local terminal. Add sidebar discovery and an offline Windows/macOS/Linux wrapper workflow with read-only permissions. Validation: 32 Linux Bash offline tests, bash -n, Node syntax checks, YAML static security checks, and diff whitespace checks. Windows/macOS, live Desktop dispatch, and full Bun/docs validation remain separate checks. --- .github/workflows/codex-queue-helpers.yml | 53 +++++ docs-site/astro.config.mjs | 1 + .../guides/composer-usage-gate-fallback.md | 187 +++++++++++------- scripts/codex-queue.ps1 | 58 ++++-- scripts/codex-queue.sh | 26 ++- scripts/codex-queue.test.mjs | 112 +++++++++-- 6 files changed, 334 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/codex-queue-helpers.yml mode change 100755 => 100644 scripts/codex-queue.sh diff --git a/.github/workflows/codex-queue-helpers.yml b/.github/workflows/codex-queue-helpers.yml new file mode 100644 index 00000000000..380d27ba93d --- /dev/null +++ b/.github/workflows/codex-queue-helpers.yml @@ -0,0 +1,53 @@ +name: Codex queue helpers + +on: + pull_request: + paths: + - 'scripts/codex-queue.*' + - 'docs-site/astro.config.mjs' + - 'docs-site/src/content/docs/guides/composer-usage-gate-fallback.md' + - '.github/workflows/codex-queue-helpers.yml' + push: + branches: [dev, main, preview] + paths: + - 'scripts/codex-queue.*' + - 'docs-site/astro.config.mjs' + - 'docs-site/src/content/docs/guides/composer-usage-gate-fallback.md' + - '.github/workflows/codex-queue-helpers.yml' + +permissions: + contents: read + +concurrency: + group: codex-queue-helpers-${{ github.ref }} + cancel-in-progress: true + +jobs: + offline-helpers: + name: queue helpers (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + shells: bash + - os: macos-latest + shells: /bin/bash + - os: windows-latest + shells: powershell.exe,pwsh.exe + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + # Hosted runners supply Node 20+; no dependencies, secrets, real Codex, + # accounts, services, downloads or model requests are used by this harness. + - name: Offline native wrapper regressions + shell: bash + env: + CODEX_QUEUE_TEST_SHELLS: ${{ matrix.shells }} + run: | + node --version + node --test scripts/codex-queue.test.mjs diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 2b68b5d457b..64c9b8082dc 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -93,6 +93,7 @@ export default defineConfig({ { label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, { label: "Model Routing", translations: { fr: "Routage des modèles", ko: "모델 라우팅", "zh-CN": "模型路由", "zh-TW": "模型路由", ru: "Маршрутизация моделей", ja: "モデルルーティング", tr: "Model Yönlendirme" }, slug: "guides/model-routing" }, { label: "Codex Integration", translations: { fr: "Intégration de Codex", ko: "Codex 통합", "zh-CN": "Codex 集成", "zh-TW": "Codex 整合", ru: "Интеграция с Codex", ja: "Codex 連携", tr: "Codex Entegrasyonu" }, slug: "guides/codex-integration" }, + { label: "Composer Usage-Gate Fallback", translations: { ko: "Codex 입력 제한 대체 경로" }, slug: "guides/composer-usage-gate-fallback" }, { label: "Codex App Model Picker", translations: { fr: "Sélecteur de modèles de Codex App", ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", "zh-TW": "Codex App 模型選擇器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー", tr: "Codex App Model Seçici" }, slug: "guides/codex-app-models" }, { label: "Codex Prompt Layers", translations: { fr: "Couches d'invite Codex", ko: "Codex 프롬프트 레이어", "zh-CN": "Codex 提示词层", "zh-TW": "Codex 提示詞層", ru: "Слои промпта Codex", ja: "Codex プロンプトレイヤー", tr: "Codex İstem Katmanları" }, slug: "guides/codex-prompt" }, { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성" }, slug: "guides/codex-native-context" }, diff --git a/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md b/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md index cc4bd4ddd64..e1abb1cd413 100644 --- a/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md +++ b/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md @@ -1,34 +1,83 @@ --- title: Composer Usage-Gate Fallback -description: Queue text to an existing Codex thread without changing desktop authentication, while preserving server-side quotas and making delivery limits explicit. +description: Queue text to an existing Codex thread on demand, without changing OpenCodex routing or desktop authentication, while preserving server-side quotas. --- A desktop **composer-only** usage gate can prevent new input even when a thread's configured -OpenCodex route has available capacity. On a compatible installation, `codex queue` is a -low-impact fallback: it submits through Codex's native app-server queue without using that input -box. It does not patch the app, intercept TLS, install a certificate, or change authentication. +OpenCodex route has available capacity. On a compatible installation, `codex queue` submits +through Codex's native app-server queue without using that input box. It does not patch the app, +intercept TLS, install a certificate, or change authentication. This is **not a fix for every usage-limit state**. It neither restores exhausted quota nor unlocks the model picker. A thread already using `gpt-reserve` keeps that model; queueing text does not switch it to another provider. Server-side authorization, provider quotas, approvals, and the thread's execution settings still apply. -## Check the target and compatibility first +## OpenCodex integration and ownership -Use the **same `CODEX_HOME` and a compatible CLI/app-server** as the target desktop installation. -`CODEX_HOME` defaults to `~/.codex`; a different home can discover a different daemon and thread -store. Do not change it just to bypass an error. Prefer the app-bundled CLI; a separately installed -`codex` on PATH may be older. Check `codex queue --help` for `--thread` and `--message`. +Keep the existing [Codex integration](/guides/codex-integration) and +[model routing](/guides/model-routing) configuration. The path is: -The helpers below probe this CLI capability, but only the actual request can verify the daemon's -`thread/queue/add` support. If Codex reports an unsupported queue method, save ongoing work before -updating/restarting the matching installation. The helpers do not restart a daemon, change -settings, or retry with another server. Do not add `--no-daemon` to a queue command. - -## Sending to an existing thread +```text +helper -> native Codex queue -> the selected thread's app-server + -> the thread's configured provider -> OpenCodex, when already routed there +``` -First open the intended conversation in the desktop app and confirm its project, model and -provider. Prefer its **explicit UUID**; the native CLI also accepts an exact session name: +The helpers do not implement another provider router. They send only the target and text, not +model, account, service-tier, approval, sandbox or base-URL overrides. A built-in `openai` +provider routed to OpenCodex and a custom `opencodex` provider keep their existing configuration. +Pool/Direct selection, account bindings, provider credentials and admission checks remain owned +by OpenCodex and the serving daemon. Queue acceptance alone does not prove a provider was called. + +Use the **same `CODEX_HOME` and compatible CLI/app-server** as the desktop installation. +`CODEX_HOME` defaults to `~/.codex`; another home can discover another daemon and thread store. +`OPENCODEX_HOME` is the proxy's home, not a substitute for `CODEX_HOME`. The helpers leave both +variables and both applications' configuration files unchanged. On PowerShell, native children +inherit the shell's filesystem location, including when `CODEX_HOME` is relative. + +The proxy must already be running and the target thread must already use the intended route. +The helper does not start/sync/reconfigure OpenCodex, re-enable a disabled integration, or +silently replace a direct OpenAI route. It does not require toggling `codexDesktopAuthless`. +A remote OpenCodex provider URL is not the same thing as a remote Codex app-server: these helpers +use local daemon discovery, not automatic routing to a conversation on another computer. + +These are **on-demand repository helpers**, not an installed `ocx queue` command or a dashboard +control. The npm package's file allowlist does not include `scripts/`; use a matching repository +checkout or the native `codex queue` command. This narrow fallback avoids adding a second +transport, persistent listener, account-state cache or usage-limit monitor to OpenCodex. + +## On/off and normal-usage behavior + +There is **no persistent enable/disable switch**: running the helper is the opt-in for one +submission. Merely installing/updating OpenCodex or keeping the scripts on disk does not run them. + +| Situation | What this fallback does | +| --- | --- | +| Not invoked, whether quota is available or exhausted | Nothing: no process, timer, polling, background listener or helper-originated request. | +| Invoked normally while usage is available | Queues one ordinary message. It does not skip it just because the desktop composer is working. Normal provider usage/billing can apply when dispatched. | +| Invoked while only the composer is blocked | Attempts the same native queue submission. The configured route must still be authorized and available. | +| The actual provider has no capacity or authorization | Does not bypass that restriction or change provider/account; upstream execution can still fail after queue acceptance. | +| `-DryRun` / `--dry-run` | Selects a target and probes CLI help, but never submits text or checks account quota, daemon health or provider availability. | +| The usage gate later lifts | Use the normal composer again. There is no helper-specific setting to revert. | + +To stop using the fallback, stop invoking it. Do **not** run `ocx restore` or disable the Codex +integration just to turn this helper off; that changes the normal OpenCodex routing too. +If you independently scheduled the command, disable that external schedule. Already accepted +queue items belong to Codex and may still execute: removing the script, exiting its process or +recovering quota does not cancel them. Inspect/remove unwanted items through the normal queue +controls in the same conversation. Do not send the same prompt in both the helper and composer. + +## Check compatibility before sending + +Prefer the app-bundled CLI; a separately installed `codex` on PATH may be older. Check +`codex queue --help` for `--thread` and `--message`. The helpers probe this CLI capability, +but only an actual request can verify the daemon's `thread/queue/add` support. If Codex reports +an unsupported queue method, save ongoing work before updating/restarting the matching +installation. The helpers never restart a daemon or retry with another server. Do not add +`--no-daemon` to a queue command. App/CLI updates can still change compatibility. + +First open the intended conversation and confirm its project, model and provider. Prefer its +**explicit UUID**; the native CLI also accepts an exact session name: ```powershell codex queue --thread 'my-project-review' --message 'continue with the next step' @@ -46,38 +95,47 @@ On macOS/Linux, use Bash (including macOS's Bash 3.2): bash scripts/codex-queue.sh --thread 'my-project-review' --message 'continue with the next step' ``` -To pin the matching trusted binary, pass `-CodexExe 'C:\path\to\codex.exe'` or -`--codex '/path/to/codex'`. Both helpers also accept `CODEX_EXE` as a path override. -An invalid explicit selection fails instead of silently picking another executable. On Windows, -use a native `.exe`, not an npm `.cmd` or PowerShell shim; this keeps message quoting out of -`cmd.exe`. Bundled and standalone package layouts are tried before PATH, and a candidate that -lacks the queue flags is skipped. Discovery is best-effort, not proof of a matching app version. +Pin a matching trusted binary with `-CodexExe 'C:\path\to\codex.exe'`, +`--codex '/path/to/codex'`, or `CODEX_EXE`. An invalid explicit selection fails instead of +silently picking another executable. On Windows use a native `.exe`, not an npm `.cmd` or +PowerShell shim, to keep message quoting out of `cmd.exe`. Queue-capable app bundles and +standalone layouts are tried before PATH. Discovery does not prove a matching app version. -### Optional latest-thread discovery +### Private diagnostics and optional latest-thread discovery -Neither helper silently selects a thread when the target is omitted. For legacy rollout-based -stores, inspect an explicit **latest-file heuristic** with: +A dry run hides executable paths, thread UUIDs/names and the message body by default: ```powershell -.\scripts\codex-queue.ps1 -Latest -DryRun +.\scripts\codex-queue.ps1 -Thread 'my-project-review' -DryRun +``` + +It reports CLI capability and target selection only, not successful server-side validation. +For legacy rollout-based stores, `-Latest` / `--latest` explicitly opts into a **latest-file +heuristic**. To see that selection, use an unredirected private terminal: + +```powershell +.\scripts\codex-queue.ps1 -Latest -DryRun -ShowTarget ``` ```bash -bash scripts/codex-queue.sh --latest --dry-run +bash scripts/codex-queue.sh --latest --dry-run --show-target ``` -This searches `CODEX_HOME/sessions` (or `~/.codex/sessions` by default) by modification time; -filename order breaks equal-time ties. It is **not the current desktop conversation** and can -select a different project or a subagent. Verify the preview, then use `-Thread` / `--thread` -with the chosen UUID. `-Latest` / `--latest` can also send when a message is provided, but that -remains an explicit opt-in to this heuristic. +`-ShowTarget` / `--show-target` requires a dry run and local terminal output. It reveals the +selected path and UUID/name only by this explicit request, escapes control characters, and +refuses redirected output such as CI capture. Do not use it in recorded/shared terminals or +post the output publicly. It never prints the message. Ordinary native Codex output during a +real submission is passed through and can contain identifiers; review it before sharing logs. + +Latest discovery searches `CODEX_HOME/sessions` by modification time, with filename order as +a tie-breaker. It is **not the current desktop conversation** and can select another project +or a subagent. Verify the local preview, then send with the chosen explicit UUID. Latest can +also send when a message is provided, but remains a deliberate opt-in to this heuristic. Recognized `rollout-*.jsonl` filenames contain a thread UUID, sometimes followed by -`_`; the helper uses the first UUID. Malformed names are skipped, and missing or -unreadable stores fail rather than falling back to another home. Migrated/paginated-only stores -and remote-only conversations may have no matching local rollout: use the explicit UUID/name -instead. `-DryRun` / `--dry-run` only probes CLI help and prints the chosen executable and target; -it never queues or prints the message body. +`_`; the helper uses the first UUID. Malformed names are skipped. Missing or +unreadable stores fail without selecting from another home. Migrated/paginated-only stores +and remote-only conversations may have no matching local rollout; use an explicit UUID/name. Keep the entire message in one argument. Bash accepts `--message '- start with this'` or `-- '- start with this'`; PowerShell accepts `-Message '- start with this'`. Shell history and @@ -87,51 +145,46 @@ local process listings can expose command-line text, so do not include credentia `Queued message ... for thread ...` confirms **queue acceptance**, not model execution or completion. A busy thread may wait for its current turn or approval. In the inspected upstream -implementation, an unloaded saved thread can retain the message without dispatching it until -another client resumes that thread. +implementation, an unloaded saved thread can retain the message until another client resumes it. Check the queue and activity in the **same conversation**. If it is not loaded, open it in the app or use `codex resume ` without adding the prompt again. Review pending approvals -and the thread's queue state. Do not repeatedly re-send a message just because no response has -appeared: each queue invocation can create another queued item. The helpers propagate the CLI -exit status and never retry automatically; after an ambiguous failure, inspect before retrying. +and the queue state. Each queue invocation can create another item; after an ambiguous failure, +inspect before retrying. The helpers preserve the CLI exit status and never resend automatically. -The helpers target local discovery; they do not identify a conversation on another machine from -local filenames. The native CLI has explicit `--remote` options (see `codex queue --help`), but -that is distinct from assuming the desktop's remote-control connection is automatically reused. -This workaround leaves desktop authentication configuration alone; it does not guarantee that an -unrelated authentication/network fault or unsupported server will be repaired. +The native CLI has explicit `--remote` options (see `codex queue --help`), distinct from assuming +the desktop's remote-control connection is reused. Keeping authentication configuration unchanged +does not repair unrelated authentication/network faults or guarantee remote-control continuity. ## Starting or resuming work without the composer -`codex exec ''` starts a non-interactive task; it is **not** delivery into the currently -open desktop thread. Check its working directory, provider, permissions and configuration. -`codex resume ` resumes an explicit existing session. `codex resume --last` normally -filters selection by the current working directory; `--all` disables that filter, while other -session eligibility filters can still apply. A global selection is not necessarily the visible -or newest filesystem session. Prefer an explicit ID for ongoing desktop work. +`codex exec ''` starts a non-interactive task, **not** a message in the open desktop +thread. Check its working directory, provider, permissions and configuration. +`codex resume ` resumes an explicit session. `codex resume --last` normally filters +by current working directory; `--all` disables that filter, while other eligibility filters can +still apply. A global selection is not necessarily the visible or newest filesystem session. +Prefer an explicit ID for ongoing desktop work. ## Scope and verification -This fallback is preferable to changing feature-gate responses or authentication solely to get -text into an otherwise usable thread: it changes neither account entitlement nor app files. -It is a workaround, not a provider-aware repair of the desktop composer/model picker. App/CLI -updates can still change compatibility. When the composer becomes usable, no helper-specific -configuration needs reverting; these scripts do not undo unrelated earlier proxy/certificate -changes. +This fallback leaves account entitlements and app files alone. It is not a provider-aware repair +of the composer/model picker, an automatic quota-recovery feature, or cleanup of unrelated +proxy/certificate changes from earlier experiments. The original Windows probe (desktop `26.917.9434.0`) reported queue acceptance for a live thread and an expected error for a nonexistent thread. That is not a general end-to-end inference, -unloaded-thread, remote-control or cross-platform guarantee. The current upstream source was -also checked at commit `7dae8c53d97e61cd774e4d6bcca5243c29ca615c`: +unloaded-thread, remote-control or cross-platform guarantee. Upstream source was checked at +`7dae8c53d97e61cd774e4d6bcca5243c29ca615c`: - [CLI queue options](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/cli/src/queue_cmd.rs) and [app-server submission](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/tui/src/session_queue_commands.rs). - [Loaded-thread queue dispatch](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/ext/queue/src/service.rs) and [resume selection options](https://github.com/openai/codex/blob/7dae8c53d97e61cd774e4d6bcca5243c29ca615c/codex-rs/cli/src/main.rs). -Maintainers can run the offline wrapper regressions with -`node --test scripts/codex-queue.test.mjs` (Node 20+). They use a fake native CLI and temporary -homes, never a real account or model. Windows runs Windows PowerShell and, when installed, pwsh; -POSIX runs Bash and, when installed, pwsh. These tests do not establish live queue dispatch or -Desktop compatibility; validate those separately on the supported installations. +Run offline wrapper regressions with `node --test scripts/codex-queue.test.mjs` (Node 20+). +They use fake native CLIs and temporary homes, never real accounts or model requests. The +**Codex queue helpers** workflow runs these tests on Windows PowerShell 5.1 and PowerShell 7, +macOS system Bash, and Linux Bash; a missing required shell fails instead of silently skipping. +Configuration-preservation fixtures cover built-in/custom provider settings and both usage-state +values, not live OpenCodex routing. Validate real Desktop dispatch, provider success and +remote-control continuity separately on supported installations. diff --git a/scripts/codex-queue.ps1 b/scripts/codex-queue.ps1 index 8c1170dc4da..44403261856 100644 --- a/scripts/codex-queue.ps1 +++ b/scripts/codex-queue.ps1 @@ -4,8 +4,10 @@ Queue one text message with the native Codex CLI; no auth/config/app changes. .DESCRIPTION Requires an explicit -Thread or opt-in -Latest. CODEX_HOME is honored. - Latest means filesystem activity, not the foreground chat. Inspect -DryRun - first, then prefer -Thread. DryRun probes queue help but never sends a message. + Latest means filesystem activity, not the foreground chat. -DryRun probes + queue help with private values hidden. -DryRun -ShowTarget reveals the selection + only in a local terminal; then prefer -Thread. Neither sends a message. + On-demand only: no enable/disable state, quota polling, or routing changes. Windows requires a native codex.exe, not a .cmd/.ps1 shim. .EXAMPLE .\codex-queue.ps1 -Thread -Message 'continue' @@ -18,11 +20,20 @@ param( [string]$Thread = '', [switch]$Latest, [switch]$DryRun, + [switch]$ShowTarget, [string]$CodexExe = $env:CODEX_EXE ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' +function Stop-QueueHelper([string]$Message) { + # Only our fixed diagnostics are safe to print; native filesystem exceptions + # can contain private directories, session names or the command line. + $failure = New-Object System.InvalidOperationException $Message + $failure.Data['SafeQueueMessage'] = $true + throw $failure +} + function ConvertTo-NativeArgument([string]$Value) { # Windows CRT quoting for .NET Framework / Windows PowerShell 5.1: # double backslashes before a quote and before the closing quote. @@ -37,6 +48,11 @@ function Invoke-CodexNative([string]$Exe, [string[]]$Arguments, [switch]$Probe) $info = New-Object System.Diagnostics.ProcessStartInfo $info.FileName = $Exe $info.UseShellExecute = $false + # Set-Location does not update .NET's process cwd. Use the shell's filesystem + # location for both help and submission, including relative CODEX_HOME values. + $location = Get-Location + if ($location.Provider.Name -ne 'FileSystem') { Stop-QueueHelper 'Run this helper from a filesystem directory.' } + $info.WorkingDirectory = $location.ProviderPath if ($null -ne $info.PSObject.Properties['ArgumentList']) { foreach ($argument in $Arguments) { $info.ArgumentList.Add($argument) } } else { @@ -53,7 +69,7 @@ function Invoke-CodexNative([string]$Exe, [string[]]$Arguments, [switch]$Probe) $stderr = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(10000)) { $process.Kill() - throw 'Codex queue help timed out; select the matching app-bundled CLI with -CodexExe.' + Stop-QueueHelper 'Codex queue help timed out; select the matching app-bundled CLI with -CodexExe.' } $process.WaitForExit() return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = $stdout.Result } @@ -80,7 +96,7 @@ function Resolve-CodexExe([string]$Explicit, [string]$CodexHomeDir) { # Pinning is authoritative: do not fall back after an invalid explicit choice. if (-not [string]::IsNullOrEmpty($Explicit)) { if (-not (Test-CodexQueue $Explicit)) { - throw 'Selected CLI does not support queue --thread/--message. On Windows, -CodexExe must name a native .exe, not a command shim.' + Stop-QueueHelper 'Selected CLI does not support queue --thread/--message. On Windows, -CodexExe must name a native .exe, not a command shim.' } return (Get-Item -LiteralPath $Explicit).FullName } @@ -104,7 +120,7 @@ function Resolve-CodexExe([string]$Explicit, [string]$CodexHomeDir) { foreach ($candidate in $candidates) { if (Test-CodexQueue $candidate) { return (Get-Item -LiteralPath $candidate).FullName } } - throw 'No queue-capable native Codex CLI found. Install/update Codex or supply -CodexExe.' + Stop-QueueHelper 'No queue-capable native Codex CLI found. Install/update Codex or supply -CodexExe.' } function Resolve-LatestThread([string]$CodexHomeDir) { @@ -112,7 +128,7 @@ function Resolve-LatestThread([string]$CodexHomeDir) { # selecting from another account/home. Deterministic filename order breaks ties. $sessions = Join-Path $CodexHomeDir 'sessions' if (-not (Test-Path -LiteralPath $sessions -PathType Container)) { - throw 'No sessions directory under the effective CODEX_HOME.' + Stop-QueueHelper 'No sessions directory under the effective CODEX_HOME.' } $uuid = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' $pattern = '^rollout-.+-(' + $uuid + ')(_' + $uuid + ')?\.jsonl$' @@ -121,18 +137,24 @@ function Resolve-LatestThread([string]$CodexHomeDir) { Sort-Object LastWriteTimeUtc, FullName -Descending | Select-Object -First 1 if ($null -eq $latestFile -or $latestFile.Name -notmatch $pattern) { - throw 'No recognized rollout thread found; specify -Thread explicitly.' + Stop-QueueHelper 'No recognized rollout thread found; specify -Thread explicitly.' } return $Matches[1] } try { if ([string]::IsNullOrEmpty($Thread) -and -not $Latest) { - throw 'Choose -Thread or explicitly opt in with -Latest.' + Stop-QueueHelper 'Choose -Thread or explicitly opt in with -Latest.' + } + if (-not [string]::IsNullOrEmpty($Thread) -and $Latest) { Stop-QueueHelper '-Thread and -Latest are mutually exclusive.' } + if (-not $DryRun -and [string]::IsNullOrEmpty($Message)) { Stop-QueueHelper 'A nonempty message is required.' } + if ($Message.IndexOf([char]0) -ge 0 -or $Thread.IndexOf([char]0) -ge 0) { Stop-QueueHelper 'NUL characters cannot be passed to the native CLI.' } + if ($ShowTarget) { + if (-not $DryRun) { Stop-QueueHelper '-ShowTarget requires -DryRun.' } + if ([Console]::IsOutputRedirected -or [Console]::IsErrorRedirected) { + Stop-QueueHelper '-ShowTarget requires a local terminal, not redirected output.' + } } - if (-not [string]::IsNullOrEmpty($Thread) -and $Latest) { throw '-Thread and -Latest are mutually exclusive.' } - if (-not $DryRun -and [string]::IsNullOrEmpty($Message)) { throw 'A nonempty message is required.' } - if ($Message.IndexOf([char]0) -ge 0 -or $Thread.IndexOf([char]0) -ge 0) { throw 'NUL characters cannot be passed to the native CLI.' } $codexHomeDir = if ([string]::IsNullOrEmpty($env:CODEX_HOME)) { Join-Path $HOME '.codex' } else { $env:CODEX_HOME } if ($Latest) { $Thread = Resolve-LatestThread $codexHomeDir @@ -140,13 +162,23 @@ try { } $exe = Resolve-CodexExe $CodexExe $codexHomeDir if ($DryRun) { - Write-Output "Codex: $exe" "Thread: $Thread" 'Dry run only; no message was queued.' + if ($ShowTarget) { + # JSON escaping keeps control characters in session names from acting on + # the terminal. Explicit local display is separate from diagnostic logs. + [Console]::WriteLine('Codex: ' + (ConvertTo-Json -InputObject $exe -Compress)) + [Console]::WriteLine('Thread: ' + (ConvertTo-Json -InputObject $Thread -Compress)) + } else { + Write-Output 'Codex: queue-capable CLI (path hidden)' 'Thread: selected (value hidden)' + } + Write-Output 'Dry run only; no message was queued. Daemon/provider health is not checked.' exit 0 } # Preserve the CLI's exit code. Never retry an ambiguous queue result automatically. $code = Invoke-CodexNative $exe @('queue', "--thread=$Thread", "--message=$Message") exit $code } catch { - [Console]::Error.WriteLine($_.Exception.Message) + $safeMessage = 'Queue helper failed. Check CODEX_HOME, directory access and the native CLI; inspect the queue before retrying.' + if ($_.Exception.Data.Contains('SafeQueueMessage')) { $safeMessage = $_.Exception.Message } + [Console]::Error.WriteLine($safeMessage) exit 1 } diff --git a/scripts/codex-queue.sh b/scripts/codex-queue.sh old mode 100755 new mode 100644 index 6d031ada83a..d1319bc94f1 --- a/scripts/codex-queue.sh +++ b/scripts/codex-queue.sh @@ -9,16 +9,18 @@ usage() { Usage: codex-queue.sh (--thread | --latest) [options] [] --message Explicit message (also accepts text beginning with a dash) --codex Pin a trusted native CLI; otherwise discover a queue-capable CLI - --dry-run Show the executable and target without queueing (message optional) + --dry-run Check selection/help without queueing; private values stay hidden + --show-target Reveal selection in a local terminal; requires --dry-run -- End options; the next argument is the entire message --help Show this help CODEX_HOME is honored. --latest is global filesystem activity, NOT the active UI chat; -prefer --latest --dry-run, verify the target, then send with --thread . +use --latest --dry-run --show-target in a private terminal, then send with --thread . +On-demand only: no enable/disable state, quota polling, auto-send, or routing changes. USAGE } fail() { printf '%s\n' "$1" >&2; exit 2; } -THREAD=""; LATEST=0; MESSAGE=""; MESSAGE_SET=0; DRY_RUN=0 +THREAD=""; LATEST=0; MESSAGE=""; MESSAGE_SET=0; DRY_RUN=0; SHOW_TARGET=0 CODEX_EXE="${CODEX_EXE:-}" while [ "$#" -gt 0 ]; do case "$1" in @@ -41,6 +43,7 @@ while [ "$#" -gt 0 ]; do shift ;; --latest) LATEST=1; shift ;; --dry-run) DRY_RUN=1; shift ;; + --show-target) SHOW_TARGET=1; shift ;; --help|-h) usage; exit 0 ;; --) shift @@ -55,6 +58,10 @@ done [ -n "$THREAD" ] || [ "$LATEST" -eq 1 ] || fail "choose --thread or explicitly opt in with --latest" [ -z "$THREAD" ] || [ "$LATEST" -eq 0 ] || fail "--thread and --latest are mutually exclusive" [ "$DRY_RUN" -eq 1 ] || [ -n "$MESSAGE" ] || fail "a nonempty message is required" +if [ "$SHOW_TARGET" -eq 1 ]; then + [ "$DRY_RUN" -eq 1 ] || fail "--show-target requires --dry-run" + [ -t 1 ] && [ -t 2 ] || fail "--show-target requires a local terminal, not redirected output" +fi CODEX_HOME_DIR="${CODEX_HOME:-${HOME:?HOME is required}/.codex}" # Read every NUL-delimited path before selecting: no ls batches, SIGPIPE, or @@ -64,9 +71,9 @@ resolve_latest_thread() ( local uuid='[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' local pattern="^rollout-.+-($uuid)(_$uuid)?\\.jsonl$" [ -d "$sessions" ] || fail "no sessions directory under the effective CODEX_HOME" - paths=$(mktemp) || fail "could not create temporary session listing" + paths=$(mktemp 2>/dev/null) || fail "could not create temporary session listing" trap 'rm -f -- "$paths"' EXIT - find "$sessions" -type f -name 'rollout-*.jsonl' -print0 > "$paths" || fail "session scan failed; refusing a partial selection" + find "$sessions" -type f -name 'rollout-*.jsonl' -print0 > "$paths" 2>/dev/null || fail "session scan failed; refusing a partial selection" while IFS= read -r -d '' candidate; do name="${candidate##*/}" [[ "$name" =~ $pattern ]] || continue @@ -123,7 +130,14 @@ if [ "$LATEST" -eq 1 ]; then fi CODEX_EXE=$(resolve_codex) if [ "$DRY_RUN" -eq 1 ]; then - printf 'Codex: %s\nThread: %s\nDry run only; no message was queued.\n' "$CODEX_EXE" "$THREAD" + if [ "$SHOW_TARGET" -eq 1 ]; then + # Explicit local display, never the default diagnostic/captured output. + # Escape controls so an exact session name cannot inject terminal commands. + printf 'Codex: %q\nThread: %q\n' "$CODEX_EXE" "$THREAD" + else + printf '%s\n' 'Codex: queue-capable CLI (path hidden)' 'Thread: selected (value hidden)' + fi + printf '%s\n' 'Dry run only; no message was queued. Daemon/provider health is not checked.' exit 0 fi # Equals-form flags keep dash-prefixed names/text as values. No eval, no retry: diff --git a/scripts/codex-queue.test.mjs b/scripts/codex-queue.test.mjs index 448464494bb..a7fe50f9c8d 100644 --- a/scripts/codex-queue.test.mjs +++ b/scripts/codex-queue.test.mjs @@ -2,9 +2,9 @@ // No real Codex process, credentials, daemon, or model requests are used. import { after, describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; @@ -22,16 +22,17 @@ function scratch() { } // The fake native CLI logs base64 fields, avoiding an additional JSON library -// on Windows. Only actual queue submissions are logged; --help is read-only. +// on Windows. Help probes and submissions use separate fixture-only logs. const stubJs = `#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); if (args[0] === 'queue' && args[1] === '--help') { + fs.appendFileSync(process.env.STUB_PROBE_LOG, Buffer.from(process.argv[1]).toString('base64') + '\\n'); console.log(path.basename(process.argv[1]).startsWith('old') ? 'old CLI help' : 'queue --thread THREAD --message TEXT'); process.exit(process.env.STUB_HELP_FAIL ? 1 : 0); } -const fields = [process.env.CODEX_HOME || '', process.argv[1], ...args]; +const fields = [process.env.CODEX_HOME || '', process.argv[1], process.cwd(), process.env.OPENCODEX_HOME || '', ...args]; fs.appendFileSync(process.env.STUB_LOG, fields.map(s => Buffer.from(s).toString('base64')).join('|') + '\\n'); process.exit(Number(process.env.STUB_EXIT || 0)); `; @@ -43,10 +44,11 @@ class QueueStub { static int Main(string[] args) { string exe = Environment.GetCommandLineArgs()[0]; if (args.Length == 2 && args[0] == "queue" && args[1] == "--help") { + File.AppendAllText(Environment.GetEnvironmentVariable("STUB_PROBE_LOG"), Convert.ToBase64String(Encoding.UTF8.GetBytes(exe)) + "\\n"); Console.WriteLine(Path.GetFileName(exe).StartsWith("old") ? "old CLI help" : "queue --thread THREAD --message TEXT"); return Environment.GetEnvironmentVariable("STUB_HELP_FAIL") == null ? 0 : 1; } - string[] fields = new string[] { Environment.GetEnvironmentVariable("CODEX_HOME") ?? "", exe }.Concat(args).ToArray(); + string[] fields = new string[] { Environment.GetEnvironmentVariable("CODEX_HOME") ?? "", exe, Environment.CurrentDirectory, Environment.GetEnvironmentVariable("OPENCODEX_HOME") ?? "" }.Concat(args).ToArray(); File.AppendAllText(Environment.GetEnvironmentVariable("STUB_LOG"), String.Join("|", fields.Select(s => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)))) + "\\n"); return Int32.Parse(Environment.GetEnvironmentVariable("STUB_EXIT") ?? "0"); } @@ -75,11 +77,13 @@ function fixture() { mkdirSync(home); mkdirSync(store); const exe = makeStub(join(root, 'bin with spaces', windows ? 'codex.exe' : 'codex')); const log = join(root, 'submissions.log'); + const probeLog = join(root, 'probes.log'); + const ocxHome = join(root, 'opencodex home'); mkdirSync(ocxHome); const env = { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: store, - LOCALAPPDATA: join(root, 'local app data'), STUB_LOG: log, + LOCALAPPDATA: join(root, 'local app data'), STUB_LOG: log, STUB_PROBE_LOG: probeLog, OPENCODEX_HOME: ocxHome, PATH: dirname(process.execPath) + (windows ? ';' : ':') + process.env.PATH }; delete env.CODEX_EXE; delete env.STUB_HELP_FAIL; delete env.STUB_EXIT; - return { root, home, store, exe, log, env }; + return { root, home, store, exe, log, probeLog, ocxHome, env }; } function rollout(store, id, timestamp, suffix = '', subdir = '2026/01/01') { const path = join(store, 'sessions', subdir, `rollout-2026-01-01T00-00-00-${id}${suffix}.jsonl`); @@ -91,31 +95,38 @@ function rollout(store, id, timestamp, suffix = '', subdir = '2026/01/01') { function records(f) { if (!existsSync(f.log)) return []; return readFileSync(f.log, 'utf8').trim().split('\n').map(line => { - const [home, exe, ...args] = line.split('|').map(field => Buffer.from(field, 'base64').toString()); - return { home, exe, args }; + const [home, exe, cwd, ocxHome, ...args] = line.split('|').map(field => Buffer.from(field, 'base64').toString()); + return { home, exe, cwd, ocxHome, args }; }); } -const shells = windows +function probes(f) { + return existsSync(f.probeLog) + ? readFileSync(f.probeLog, 'utf8').trim().split('\n').map(s => Buffer.from(s, 'base64').toString()) : []; +} +const requiredShells = (process.env.CODEX_QUEUE_TEST_SHELLS || '').split(',').filter(Boolean); +const shells = requiredShells.length ? requiredShells : windows ? ['powershell.exe', 'pwsh.exe'] : ['bash', ...(spawnSync('pwsh', ['-NoProfile', '-Command', 'exit 0']).status === 0 ? ['pwsh'] : [])]; for (const shell of shells) { - const ps = shell !== 'bash'; + const ps = !basename(shell).startsWith('bash'); const available = spawnSync(shell, ps ? ['-NoProfile', '-Command', 'exit 0'] : ['--version']).status === 0; + if (requiredShells.length) assert.ok(available, `Required shell is unavailable: ${shell}`); describe(`${shell} native queue helper`, { skip: !available }, () => { function run(f, options = {}) { const args = [join(scripts, ps ? 'codex-queue.ps1' : 'codex-queue.sh')]; const flagPositions = new Set(); - const flag = (name, value) => { flagPositions.add(args.length); args.push(ps ? '-' + name : '--' + ({ CodexExe: 'codex', DryRun: 'dry-run' }[name] || name.toLowerCase())); if (value !== undefined) args.push(value); }; + const flag = (name, value) => { flagPositions.add(args.length); args.push(ps ? '-' + name : '--' + ({ CodexExe: 'codex', DryRun: 'dry-run', ShowTarget: 'show-target' }[name] || name.toLowerCase())); if (value !== undefined) args.push(value); }; if (options.pin !== false) flag('CodexExe', options.exe || f.exe); if (options.thread !== undefined) flag('Thread', options.thread); if (options.latest) flag('Latest'); if (options.dryRun) flag('DryRun'); + if (options.showTarget) flag('ShowTarget'); if (options.message !== undefined) flag('Message', options.message); if (options.extra) args.push(...options.extra); // Enter through PowerShell literals, not -File's external string binder. // This isolates the helper's native argv handling, even for leading dashes. const psLiteral = value => "'" + value.replaceAll("'", "''") + "'"; - const command = '& ' + args.map((arg, index) => + const command = (options.location ? 'Set-Location -LiteralPath ' + psLiteral(options.location) + '; ' : '') + '& ' + args.map((arg, index) => flagPositions.has(index) ? arg : psLiteral(arg)).join(' '); const launchArgs = ps @@ -162,9 +173,70 @@ for (const shell of shells) { }); it('dry run can omit a message and never submits', () => { const f = fixture(); const result = run(f, { thread: threadA, dryRun: true }); - assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(threadA)); + assert.equal(result.status, 0, result.output); assert.ok(!result.output.includes(threadA)); + assert.ok(!result.output.includes(f.exe)); assert.ok(!result.output.includes(f.store)); + assert.deepEqual(records(f), []); + }); + it('does not disclose discovered targets or message bodies during a dry run', () => { + const f = fixture(); rollout(f.store, threadA, 100); + const result = run(f, { latest: true, dryRun: true, message: 'private fixture prompt' }); + assert.equal(result.status, 0, result.output); + for (const value of [threadA, f.exe, f.store, f.home, 'private fixture prompt']) { + assert.ok(!result.output.includes(value), 'Private value leaked from dry run'); + } + assert.deepEqual(records(f), []); + }); + it('requires a dry run and local terminal for explicit target disclosure', () => { + const f = fixture(); + for (const dryRun of [true, false]) { + const result = run(f, { thread: threadA, message: 'not sent', showTarget: true, dryRun }); + assert.notEqual(result.status, 0); assert.ok(!result.output.includes(threadA)); + } + assert.deepEqual(probes(f), []); assert.deepEqual(records(f), []); + }); + it('redacts an exact session name in diagnostics', () => { + const f = fixture(); const name = 'private customer project'; + const result = run(f, { thread: name, dryRun: true }); + assert.equal(result.status, 0, result.output); assert.ok(!result.output.includes(name)); assert.deepEqual(records(f), []); }); + for (const allowed of [true, false]) { + for (const provider of ['openai', 'opencodex']) { + it(`preserves OpenCodex routing/configuration with allowed=${allowed}, provider=${provider}`, () => { + const f = fixture(); + // These are opaque fixture files, NOT real account credentials or backend probes. + const inputs = new Map([ + [join(f.store, 'config.toml'), `model_provider = "${provider}"\nopenai_base_url = "http://127.0.0.1:19234/v1"\n`], + [join(f.store, 'auth.json'), '{"fixture":"unchanged"}\n'], + [join(f.store, 'usage-fixture.json'), JSON.stringify({ rate_limit: { allowed } })], + [join(f.ocxHome, 'config.json'), '{"codexDesktopAuthless":false,"fixture":"unchanged"}\n'], + ]); + for (const [path, text] of inputs) writeFileSync(path, text); + for (const dryRun of [true, false]) { + const result = run(f, { thread: threadA, message: 'continue', dryRun }); + assert.equal(result.status, 0, result.output); + assert.equal(records(f).length, dryRun ? 0 : 1); + for (const [path, text] of inputs) assert.equal(readFileSync(path, 'utf8'), text); + } + const record = records(f)[0]; + assert.equal(record.home, f.store); assert.equal(record.ocxHome, f.ocxHome); + assert.equal(realpathSync(record.cwd), realpathSync(f.root)); + assert.deepEqual(record.args, ['queue', `--thread=${threadA}`, '--message=continue']); + assert.equal(probes(f).length, 2); // Only help probes, no usage/login/sync command. + }); + } + } + if (ps) { + it('uses the PowerShell location instead of the inherited process cwd', () => { + const f = fixture(); const location = join(f.root, 'project [other]'); mkdirSync(location); + const relativeStore = 'local store'; rollout(join(location, relativeStore), threadA, 100); + const result = run(f, { latest: true, message: 'continue', location, env: { CODEX_HOME: relativeStore } }); + assert.equal(result.status, 0, result.output); + assert.equal(records(f)[0].home, relativeStore); + assert.equal(realpathSync(records(f)[0].cwd), realpathSync(location)); + assert.equal(records(f)[0].args[1], `--thread=${threadA}`); + }); + } it('uses CODEX_HOME rather than a newer unrelated default-home session', () => { const f = fixture(); rollout(f.store, threadA, 100); rollout(join(f.home, '.codex'), threadB, 200); const result = run(f, { latest: true, message: 'continue' }); @@ -229,7 +301,7 @@ for (const shell of shells) { const f = fixture(); const bundled = makeStub(join(f.env.LOCALAPPDATA, 'OpenAI/Codex/bin/build-a/codex.exe')); const result = run(f, { pin: false, thread: threadA, dryRun: true }); - assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(bundled)); + assert.equal(result.status, 0, result.output); assert.equal(probes(f).at(-1), bundled); assert.deepEqual(records(f), []); }); } @@ -261,15 +333,21 @@ for (const shell of shells) { const bundled = makeStub(join(f.home, 'Applications/Codex.app/Contents/Resources/codex')); const old = makeStub(join(f.root, 'old-path/codex')); const result = run(f, { pin: false, thread: threadA, dryRun: true, env: { PATH: dirname(old) + ':' + f.env.PATH } }); - assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(bundled)); + assert.equal(result.status, 0, result.output); assert.equal(probes(f).at(-1), bundled); assert.deepEqual(records(f), []); }); it('finds the standalone bin layout (discovery only)', { skip: existsSync('/Applications/Codex.app/Contents/Resources/codex') }, () => { const f = fixture(); const standalone = makeStub(join(f.store, 'packages/standalone/current/bin/codex')); const result = run(f, { pin: false, thread: threadA, dryRun: true }); - assert.equal(result.status, 0, result.output); assert.ok(result.output.includes(standalone)); + assert.equal(result.status, 0, result.output); assert.equal(probes(f).at(-1), standalone); assert.deepEqual(records(f), []); }); } }); } + +// A sidebar contract is not a substitute for the Astro build, but prevents an orphan page. +it('exposes the fallback guide through the explicit site navigation', () => { + const config = readFileSync(join(scripts, '../docs-site/astro.config.mjs'), 'utf8'); + assert.match(config, /slug: "guides\/composer-usage-gate-fallback"/); +}); From 59bf48cc7d6f6c5182a8a39c541a4625c18a0156 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 24 Sep 2026 04:52:32 +0000 Subject: [PATCH 5/6] fix: anchor Windows queue homes and correct native test assertions The new Windows CI exposed a relative CODEX_HOME lookup failure in Windows PowerShell 5.1. Anchor filesystem discovery to the shell location while preserving the environment passed to Codex. Correct the offline harness to compare native canonical paths (8.3 aliases are not different directories) and propagate the script's LASTEXITCODE through the outer PowerShell -EncodedCommand launcher. No tests are skipped. Validation: 32 Linux offline tests and syntax checks pass. Windows and macOS will be verified by the required-shell workflow for this exact head. --- scripts/codex-queue.ps1 | 7 +++++++ scripts/codex-queue.test.mjs | 16 +++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/codex-queue.ps1 b/scripts/codex-queue.ps1 index 44403261856..7dc7914dad1 100644 --- a/scripts/codex-queue.ps1 +++ b/scripts/codex-queue.ps1 @@ -156,6 +156,13 @@ try { } } $codexHomeDir = if ([string]::IsNullOrEmpty($env:CODEX_HOME)) { Join-Path $HOME '.codex' } else { $env:CODEX_HOME } + # Anchor relative paths before filesystem cmdlets, including Windows PowerShell + # 5.1 literal paths after Set-Location. The native child's environment stays intact. + if (-not [IO.Path]::IsPathRooted($codexHomeDir)) { + $location = Get-Location + if ($location.Provider.Name -ne 'FileSystem') { Stop-QueueHelper 'Run this helper from a filesystem directory.' } + $codexHomeDir = [IO.Path]::GetFullPath((Join-Path $location.ProviderPath $codexHomeDir)) + } if ($Latest) { $Thread = Resolve-LatestThread $codexHomeDir Write-Warning '-Latest may select a different project or a subagent, not the foreground chat.' diff --git a/scripts/codex-queue.test.mjs b/scripts/codex-queue.test.mjs index a7fe50f9c8d..ac6d503b7dc 100644 --- a/scripts/codex-queue.test.mjs +++ b/scripts/codex-queue.test.mjs @@ -10,6 +10,7 @@ import { spawnSync } from 'node:child_process'; const scripts = dirname(fileURLToPath(import.meta.url)); const windows = process.platform === 'win32'; +// Native realpath canonicalizes Windows 8.3/long-path aliases in path assertions. const roots = []; after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); const threadA = '00000000-0000-4000-8000-000000000001'; @@ -124,11 +125,12 @@ for (const shell of shells) { if (options.message !== undefined) flag('Message', options.message); if (options.extra) args.push(...options.extra); // Enter through PowerShell literals, not -File's external string binder. - // This isolates the helper's native argv handling, even for leading dashes. + // This isolates native argv handling. -Command otherwise maps a script's + // nonzero status to 1; explicitly exit with its LASTEXITCODE. const psLiteral = value => "'" + value.replaceAll("'", "''") + "'"; const command = (options.location ? 'Set-Location -LiteralPath ' + psLiteral(options.location) + '; ' : '') + '& ' + args.map((arg, index) => flagPositions.has(index) - ? arg : psLiteral(arg)).join(' '); + ? arg : psLiteral(arg)).join(' ') + '; exit $LASTEXITCODE'; const launchArgs = ps ? ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')] : args; @@ -220,7 +222,7 @@ for (const shell of shells) { } const record = records(f)[0]; assert.equal(record.home, f.store); assert.equal(record.ocxHome, f.ocxHome); - assert.equal(realpathSync(record.cwd), realpathSync(f.root)); + assert.equal(realpathSync.native(record.cwd), realpathSync.native(f.root)); assert.deepEqual(record.args, ['queue', `--thread=${threadA}`, '--message=continue']); assert.equal(probes(f).length, 2); // Only help probes, no usage/login/sync command. }); @@ -233,7 +235,7 @@ for (const shell of shells) { const result = run(f, { latest: true, message: 'continue', location, env: { CODEX_HOME: relativeStore } }); assert.equal(result.status, 0, result.output); assert.equal(records(f)[0].home, relativeStore); - assert.equal(realpathSync(records(f)[0].cwd), realpathSync(location)); + assert.equal(realpathSync.native(records(f)[0].cwd), realpathSync.native(location)); assert.equal(records(f)[0].args[1], `--thread=${threadA}`); }); } @@ -301,7 +303,7 @@ for (const shell of shells) { const f = fixture(); const bundled = makeStub(join(f.env.LOCALAPPDATA, 'OpenAI/Codex/bin/build-a/codex.exe')); const result = run(f, { pin: false, thread: threadA, dryRun: true }); - assert.equal(result.status, 0, result.output); assert.equal(probes(f).at(-1), bundled); + assert.equal(result.status, 0, result.output); assert.equal(realpathSync.native(probes(f).at(-1)), realpathSync.native(bundled)); assert.deepEqual(records(f), []); }); } @@ -333,13 +335,13 @@ for (const shell of shells) { const bundled = makeStub(join(f.home, 'Applications/Codex.app/Contents/Resources/codex')); const old = makeStub(join(f.root, 'old-path/codex')); const result = run(f, { pin: false, thread: threadA, dryRun: true, env: { PATH: dirname(old) + ':' + f.env.PATH } }); - assert.equal(result.status, 0, result.output); assert.equal(probes(f).at(-1), bundled); + assert.equal(result.status, 0, result.output); assert.equal(realpathSync.native(probes(f).at(-1)), realpathSync.native(bundled)); assert.deepEqual(records(f), []); }); it('finds the standalone bin layout (discovery only)', { skip: existsSync('/Applications/Codex.app/Contents/Resources/codex') }, () => { const f = fixture(); const standalone = makeStub(join(f.store, 'packages/standalone/current/bin/codex')); const result = run(f, { pin: false, thread: threadA, dryRun: true }); - assert.equal(result.status, 0, result.output); assert.equal(probes(f).at(-1), standalone); + assert.equal(result.status, 0, result.output); assert.equal(realpathSync.native(probes(f).at(-1)), realpathSync.native(standalone)); assert.deepEqual(records(f), []); }); } From b8cfba17a0953d4fed9d5441c3adfe1ca7ba7872 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 24 Sep 2026 04:55:40 +0000 Subject: [PATCH 6/6] fix: discover queue-capable Codex across every PATH entry Address the new bot finding without changing bundle-first or explicit-pin semantics. Inspect PATH entries in order and reject an obsolete candidate before trying the next one. Preserve whitespace in paths. Add an offline regression with two competing PATH executables. Linux suite: 33 passed, 0 failed; bash and Node syntax plus whitespace checks pass. --- scripts/codex-queue.sh | 17 +++++++++++------ scripts/codex-queue.test.mjs | 11 +++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/scripts/codex-queue.sh b/scripts/codex-queue.sh index d1319bc94f1..e486f61dd23 100644 --- a/scripts/codex-queue.sh +++ b/scripts/codex-queue.sh @@ -99,7 +99,7 @@ supports_queue() { # Prefer app bundles over a stale PATH CLI; cover both standalone package layouts. # Explicit selection is authoritative and never silently falls back to another CLI. resolve_codex() { - local candidate + local candidate directory remaining_path if [ -n "$CODEX_EXE" ]; then case "$CODEX_EXE" in /*) ;; *) CODEX_EXE="$PWD/$CODEX_EXE" ;; esac supports_queue "$CODEX_EXE" || fail "selected CLI does not support queue --thread/--message; check --codex/CODEX_EXE" @@ -116,11 +116,16 @@ resolve_codex() { "$HOME/.codex/bin/codex" "$HOME/.codex/bin"/*/codex; do if supports_queue "$candidate"; then printf '%s\n' "$candidate"; return; fi done - candidate=$(type -P codex || true) - if [ -n "$candidate" ] && supports_queue "$candidate"; then - printf '%s\n' "$candidate" - return - fi + # Inspect every PATH directory, not just the first (possibly obsolete) CLI. + # Split only on colon: spaces/newlines are data; empty entries mean cwd. + remaining_path="${PATH:-}" + while :; do + directory="${remaining_path%%:*}" + case "$directory" in /*) candidate="$directory/codex" ;; *) candidate="$PWD/${directory:+$directory/}codex" ;; esac + if supports_queue "$candidate"; then printf '%s\n' "$candidate"; return; fi + [[ "$remaining_path" == *:* ]] || break + remaining_path="${remaining_path#*:}" + done fail "no queue-capable Codex CLI found; install/update Codex or specify --codex /path/to/codex" } diff --git a/scripts/codex-queue.test.mjs b/scripts/codex-queue.test.mjs index ac6d503b7dc..39e131ecda3 100644 --- a/scripts/codex-queue.test.mjs +++ b/scripts/codex-queue.test.mjs @@ -338,6 +338,17 @@ for (const shell of shells) { assert.equal(result.status, 0, result.output); assert.equal(realpathSync.native(probes(f).at(-1)), realpathSync.native(bundled)); assert.deepEqual(records(f), []); }); + it('finds a queue-capable later PATH candidate after an obsolete CLI', { skip: existsSync('/Applications/Codex.app/Contents/Resources/codex') }, () => { + const f = fixture(); + const old = makeStub(join(f.root, 'old path/codex')); + writeFileSync(old, stubJs.replace("path.basename(process.argv[1]).startsWith('old')", 'true')); + const working = makeStub(join(f.root, 'working path/codex')); + const result = run(f, { pin: false, thread: threadA, dryRun: true, + env: { PATH: dirname(old) + ':' + dirname(working) + ':' + f.env.PATH } }); + assert.equal(result.status, 0, result.output); + assert.deepEqual(probes(f).map(p => realpathSync.native(p)), [old, working].map(p => realpathSync.native(p))); + assert.deepEqual(records(f), []); + }); it('finds the standalone bin layout (discovery only)', { skip: existsSync('/Applications/Codex.app/Contents/Resources/codex') }, () => { const f = fixture(); const standalone = makeStub(join(f.store, 'packages/standalone/current/bin/codex')); const result = run(f, { pin: false, thread: threadA, dryRun: true });