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 new file mode 100644 index 00000000000..e1abb1cd413 --- /dev/null +++ b/docs-site/src/content/docs/guides/composer-usage-gate-fallback.md @@ -0,0 +1,190 @@ +--- +title: Composer Usage-Gate Fallback +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` 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. + +## OpenCodex integration and ownership + +Keep the existing [Codex integration](/guides/codex-integration) and +[model routing](/guides/model-routing) configuration. The path is: + +```text +helper -> native Codex queue -> the selected thread's app-server + -> the thread's configured provider -> OpenCodex, when already routed there +``` + +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' +``` + +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' +``` + +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. + +### Private diagnostics and optional latest-thread discovery + +A dry run hides executable paths, thread UUIDs/names and the message body by default: + +```powershell +.\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 --show-target +``` + +`-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. 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 +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 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 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 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, **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 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. 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). + +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 new file mode 100644 index 00000000000..7dc7914dad1 --- /dev/null +++ b/scripts/codex-queue.ps1 @@ -0,0 +1,191 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + 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. -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' +.EXAMPLE + .\codex-queue.ps1 -Latest -DryRun +#> +[CmdletBinding()] +param( + [Parameter(Position=0)][string]$Message = '', + [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. + $escaped = [regex]::Replace($Value, '(\\*)"', '$1$1\"') + $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1') + return '"' + $escaped + '"' +} + +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 + # 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 { + $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() + 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 } + } + $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)) { + 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 + } + $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 } + } + Stop-QueueHelper '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)) { + 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$' + $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 ($null -eq $latestFile -or $latestFile.Name -notmatch $pattern) { + Stop-QueueHelper 'No recognized rollout thread found; specify -Thread explicitly.' + } + return $Matches[1] +} + +try { + if ([string]::IsNullOrEmpty($Thread) -and -not $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.' + } + } + $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.' + } + $exe = Resolve-CodexExe $CodexExe $codexHomeDir + if ($DryRun) { + 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 { + $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 new file mode 100644 index 00000000000..e486f61dd23 --- /dev/null +++ b/scripts/codex-queue.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# 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 + +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 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; +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; SHOW_TARGET=0 +CODEX_EXE="${CODEX_EXE:-}" +while [ "$#" -gt 0 ]; do + case "$1" in + --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 ;; + --show-target) SHOW_TARGET=1; shift ;; + --help|-h) usage; exit 0 ;; + --) + shift + [ "$#" -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)" ;; + *) + [ "$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" +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 +# 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 2>/dev/null) || fail "could not create temporary session listing" + trap 'rm -f -- "$paths"' EXIT + 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 + 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() { + 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" + printf '%s\n' "$CODEX_EXE" + return + fi + for candidate in \ + "$HOME/Applications/Codex.app/Contents/Resources/codex" \ + "/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 + # 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" +} + +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 +CODEX_EXE=$(resolve_codex) +if [ "$DRY_RUN" -eq 1 ]; then + 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: +# 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..39e131ecda3 --- /dev/null +++ b/scripts/codex-queue.test.mjs @@ -0,0 +1,366 @@ +// 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, realpathSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, 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'; +// 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'; +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. 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], 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)); +`; +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") { + 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, 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"); + } +}`; +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 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, 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, 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`); + 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, cwd, ocxHome, ...args] = line.split('|').map(field => Buffer.from(field, 'base64').toString()); + return { home, exe, cwd, ocxHome, args }; + }); +} +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 = !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', 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 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(' ') + '; exit $LASTEXITCODE'; + 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.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.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. + }); + } + } + 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.native(records(f)[0].cwd), realpathSync.native(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' }); + 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.equal(realpathSync.native(probes(f).at(-1)), realpathSync.native(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.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 }); + assert.equal(result.status, 0, result.output); assert.equal(realpathSync.native(probes(f).at(-1)), realpathSync.native(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"/); +});