
Codex Qa
- 66 installs
- 67.2k repo stars
- Updated August 4, 2026
- code-yeongyu/oh-my-opencode
QA the omo Codex plugin in an isolated CODEX_HOME with a local mock model, proving hooks fired via app-server notifications without touching the real ~/.codex.
About
A skill that QAs the omo Codex Light edition (lazycodex) plugin in strict isolation, driving the real codex app-server against an isolated CODEX_HOME and a local mock model to prove plugin hooks fired. A developer uses it when changing packages/omo-codex or smoke-testing the Codex plugin, hooks, installer, or TUI.
- Isolated CODEX_HOME plus local mock model means no real API call and untouched ~/.codex
- Proves hooks fired via app-server hook/started + hook/completed notifications
Codex Qa by the numbers
- 66 all-time installs (skills.sh)
- Ranked #1,117 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/code-yeongyu/oh-my-opencode --skill codex-qaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 66 |
|---|---|
| repo stars | ★ 67.2k |
| Last updated | August 4, 2026 |
| Repository | code-yeongyu/oh-my-opencode ↗ |
What it does
QA the omo Codex plugin in an isolated CODEX_HOME with a local mock model, proving hooks fired via app-server notifications without touching the real ~/.codex.
Files
Codex QA
QA the omo Codex Light edition (packages/omo-codex/, shipped as lazycodex). We exercise OUR plugin in a REAL Codex while touching nothing of the user's setup: an isolated CODEX_HOME + a local mock model means no real API call and the real ~/.codex is never read or written. Each helper script ships a --self-test that asserts its scenario against the live machine, so the scripts are both the QA tools and their own regression checks.
Verified against codex-cli 0.140.0 (node, jq, tmux, bun on macOS). Confirm with codex --version; check a flag with codex <cmd> --help.
Golden rules (read before running anything)
- QA ONLY our plugin. Everything that spawns codex uses an isolated
CODEX_HOME (created by cqa_mk_isolated_home) and a LOCAL mock model provider (cqa_start_mock). Never QA against the real ~/.codex, never hit a real model API. The bundled scripts enforce this; if you run codex by hand, export CODEX_HOME="$(mktemp -d)/codex"; mkdir -p "$CODEX_HOME" FIRST (a set CODEX_HOME must already exist or codex hard-errors).
- Prove the real home stayed clean. Every script shasums
~/.codex/config.toml before and after and asserts it is unchanged. If you script by hand, do the same.
- The interactive `codex` is a shell function that injects
--profile quotio.
Bash scripts bypass it and get the real binary; never rely on the interactive alias. See references/isolation.md.
- The first-party way to prove a hook fired is the app-server notification
stream (hook/started / hook/completed), not log scraping. See references/app-server.md.
- The captured JSON / pane IS the evidence — write it under
.omo/evidence/<YYYYMMDD>-<slug>/ (no evidence file == the QA did not happen).
Setup
cd <this-skill-dir> # .agents/skills/codex-qa
bash scripts/lib/common.sh --self-check # confirm deps + isolation harnessDocker is the default QA surface. Run this QA inside a disposable container that has the latest codex and a copy of your config, with the host ~/.codex untouched: script/agent/qa-docker.sh (see references/docker-qa.md). The local scripts below are the fallback for when Docker is unavailable or on Windows.
Router: pick your case
| You need to… | Run | Deep dive |
|---|---|---|
| Prove a plugin hook fires in a LIVE Codex turn (first-party) | scripts/app-server-drive.sh --plugin | app-server.md |
| Prove the app-server driver itself works (no plugin, fast) | scripts/app-server-drive.sh --self-test | app-server.md |
| Install the LOCAL build into an isolated home + assert it landed | scripts/install-verify.sh --self-test | install-verify.md |
| Pin ONE component's hook logic deterministically (no codex) | scripts/hook-unit-probe.sh --self-test | components-hooks.md |
| Smoke the real TUI under tmux (boots, renders, survives) | scripts/tui-smoke.sh --self-test | logging-debug.md |
| Watch runtime logs while QAing | (see reference; RUST_LOG / logs DB / /debug-config) | logging-debug.md |
Scripts index (each is its own regression test)
| Script | --self-test asserts |
|---|---|
scripts/lib/common.sh --self-check | deps present; isolated CODEX_HOME is created inside a sandbox and auto-removed on exit; mock model serves the Responses SSE; real ~/.codex unchanged |
scripts/app-server-drive.sh | --self-test: a bare turn completes and the mock assistant text comes back. --plugin: installs local omo, drives a turn, and asserts hook/completed for sessionStart,userPromptSubmit |
scripts/install-verify.sh | local omo installs into the isolated home; config.toml enables omo@sisyphuslabs; component bins + agent TOMLs linked in the sandbox; real ~/.codex unchanged |
scripts/hook-unit-probe.sh | the ultrawork component injects <ultrawork-mode> on an ulw UserPromptSubmit (also a manual --component/--event mode) |
scripts/tui-smoke.sh | the real codex TUI boots in the isolated home, renders, and survives (no early exit); captures the pane |
When TUI visual QA evidence is needed, do not stop at the raw pane. Replay the captured pane through the repository web-terminal helper so the PR can attach a stable browser screenshot:
node script/qa/web-terminal-visual-qa.mjs --title "Codex TUI QA" \
--from-file .omo/evidence/<slug>/codex-tui-pane.txt \
--evidence-dir .omo/evidence/<slug>/codex-web-terminalThe helper writes terminal.txt, terminal-ansi.txt, terminal.html, terminal.png, and metadata.json. Use that artifact set for TUI visual QA; use app-server-drive.sh --plugin for assertion-grade hook behavior.
Match QA to your change scope
- Component / hook logic (
packages/omo-codex/plugin/components/*):
hook-unit-probe.sh for the exact stdout, THEN app-server-drive.sh --plugin to prove the live wiring. See components-hooks.md.
- Installer / config.toml (
packages/omo-codex/src/install/*):
install-verify.sh.
- Anything that affects a live session (hooks, agents, MCP wiring):
app-server-drive.sh --plugin, and tui-smoke.sh --plugin if the TUI path matters.
Capturing evidence
ev=".omo/evidence/$(date +%Y%m%d)-codex-qa-<slug>"; mkdir -p "$ev"
bash scripts/app-server-drive.sh --plugin > "$ev/app-server-drive.json" 2>&1
bash scripts/install-verify.sh --self-test > "$ev/install-verify.txt" 2>&1On /debugging
There is no /debugging command in Codex. To observe a run: the app-server notification stream (above), RUST_LOG=debug on the app-server's stderr, the logs SQLite under $CODEX_HOME, the TUI's /debug-config, and the codex debug … subcommands. See logging-debug.md.
Codex app-server — the first-party QA channel
The app-server is how a host (IDE, our QA harness) drives Codex programmatically. We speak its protocol directly so we can read the structured notification stream — including hook/started / hook/completed, which is the authoritative proof that an omo plugin hook fired in a live turn.
Verified against codex-cli 0.139.0. Source citations are path:line under ../codex/codex-rs/.
Transport & framing
- Start with
codex app-server(no subcommand runs the server). Implemented by
the codex-app-server crate; entry app-server/src/lib.rs:429.
- Default transport is stdio, framing is newline-delimited JSON (one
message per line) — app-server-transport/src/transport/stdio.rs:46-88.
- It is NOT standard JSON-RPC 2.0: there is no `"jsonrpc"` field. Requests
are {id, method, params}; notifications are {method, params} (app-server-protocol/src/jsonrpc_lite.rs). Field names are camelCase.
Confirm the method set for the installed binary:
codex app-server generate-json-schema --out "$(mktemp -d)" # ClientRequest.json / ServerNotification.jsonDrive one turn (the sequence the driver uses)
{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-qa","version":"0.1.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}
{"method":"initialized"} // notification, REQUIRED, no id
{"id":2,"method":"thread/start","params":{"cwd":"/abs/workdir"}} // -> result.thread.id
{"id":3,"method":"turn/start","params":{"threadId":"<id>","input":[{"type":"text","text":"say hello"}]}} // -> result.turn.idRead stdout line-by-line and collect:
hook/started/hook/completed—params.run.eventName(e.g.sessionStart,
userPromptSubmit, stop), params.run.status (running → completed), params.run.source (plugin). This is the plugin-fired proof.
item/completedwhereitem.type == "agentMessage"—item.textis the
assistant message.
turn/completed— stop whenturn.status == "completed"(or"failed"with
turn.error) for your turnId.
scripts/lib/app-server-client.mjs implements exactly this and exits non-zero unless the turn completes and every EXPECT_HOOK event reaches completed.
Why a mock model
A turn needs a model. We point a custom model_provider at the local scripts/lib/mock-model.mjs (OpenAI Responses SSE), so the turn runs with NO real API call. A non-OpenAI provider needs no auth (requires_openai_auth defaults false). The driver injects the provider via -c overrides — see isolation.md.
Observed result on 0.139.0
With omo installed in an isolated CODEX_HOME, one ulw: say hello turn emits hook/* for sessionStart (rules, telemetry, bootstrap, auto-update), userPromptSubmit (rules, ultrawork, ulw-loop), and stop (start-work-continuation), then the mock assistant message and turn/completed. scripts/app-server-drive.sh --plugin asserts this end to end.
omo-codex components → events → observable proof
The plugin's hook wiring lives in packages/omo-codex/plugin/hooks/hooks.json. Each hook runs node "${PLUGIN_ROOT}/components/<c>/dist/cli.js" hook <event>, reading the event JSON on stdin and writing zero-or-one line of JSON on stdout.
Use this table to pick what to assert. Two proof tiers:
- Unit (
hook-unit-probe.sh): pipe a synthetic event into a component's
dist/cli.js, assert stdout/disk. Deterministic, no codex process.
- Live (
app-server-drive.sh --plugin): drive a real turn, assert the
hook/completed notification fires for the event. Proves Codex WIRES it.
| Component | Codex events | Observable proof it fired |
|---|---|---|
rules | SessionStart; UserPromptSubmit; PostToolUse apply_patch; PostCompact | hookSpecificOutput.additionalContext (rule body) on stdout; session cache at $PLUGIN_DATA/sessions/<id>.json |
ultrawork | UserPromptSubmit | stdout additionalContext contains <ultrawork-mode> only when prompt matches `/ultrawork |
ulw-loop | UserPromptSubmit; PreToolUse create_goal | steer JSON on a steer prompt; permissionDecision:"deny" when create_goal carries keys beyond objective |
comment-checker | PostToolUse (write/edit/apply_patch) | warning text on stdout when an edited file has banned comments; empty when clean |
lsp | PostToolUse (write/edit/apply_patch); PostCompact | LSP diagnostics as additionalContext for mutated files |
start-work-continuation | Stop; SubagentStop | {"decision":"block","reason":...} only when a continuation/boulder state exists for the session |
git-bash | PreToolUse Bash; PostCompact | Windows-only: reminder + marker $PLUGIN_DATA/git-bash-reminder/<id>.seen; no-op elsewhere |
telemetry | SessionStart | empty stdout; side effect is a PostHog event (or a diagnostic file on failure) |
bootstrap | SessionStart | BOOTSTRAP_RESTART_NOTICE additionalContext on first run (gated on PLUGIN_ROOT+PLUGIN_DATA) |
Many components are conditional (only emit on a matching prompt / OS / state). For a stable always-fires assertion, prefer:
- Live:
sessionStartanduserPromptSubmithook/completed(several components
wire them, so the events always fire). app-server-drive.sh --plugin defaults to --expect sessionStart,userPromptSubmit.
- Unit:
ultraworkon anulwprompt deterministically injects<ultrawork-mode>.
hook/* notification eventNames are camelCase (sessionStart, userPromptSubmit, postToolUse, stop, …); the hooks.json matchers use snake_case (session_start, user_prompt_submit, …). The component CLI takes the kebab form (hook user-prompt-submit).
Docker QA (default path)
Run Codex QA inside a DISPOSABLE container so the real ~/.codex is never touched and you always test against the latest codex. The container is the sandbox: latest released codex (and opencode) are baked in, a COPY of your config is loaded, and the container is removed on exit (docker run --rm). This is the DEFAULT; fall back to running the scripts locally (see SKILL.md) only when Docker is unavailable or on Windows.
Use it
qa-docker.sh brings up a disposable box (builds omo-dev then omo-qa on first use, reused after) and drops you into it. From the repo root:
# drive codex via the FIRST-PARTY app-server (no acp): a real turn in the box
script/agent/qa-docker.sh codex
# fallback: the interactive codex TUI in the box (uses your mounted config)
script/agent/qa-docker.sh codex --tui
# a shell inside the box: codex (and opencode) are on PATH
script/agent/qa-docker.sh
script/agent/qa-docker.sh shell
# one-off command, or a codex-qa self-test inside the box:
script/agent/qa-docker.sh exec codex --version
script/agent/qa-docker.sh exec bash .claude/skills/codex-qa/scripts/tui-smoke.sh --self-test
script/agent/qa-docker.sh --clean # remove the QA imagesomo-qa is omo-dev (.devcontainer/Dockerfile) plus the latest @openai/codex and opencode-ai npm packages and sqlite3 jq curl rsync.
Isolation still applies inside
The codex-qa scripts already isolate via an mktemp CODEX_HOME and a local mock model (no real API call). In Docker that runs inside a throwaway container too, so there are two layers: the scripts never touch the mounted real ~/.codex, and the container is discarded on exit. qa-docker.sh mounts ~/.codex READ-ONLY at /mnt/host/codex; the entrypoint copies it into the container's writable home for any case that wants the real config. The host ~/.codex (including config.toml) is never written.
Credentials
codex-qa uses a mock model, so no real key is needed for the first-party hook proof. For runs that do need auth, provide it at run time only: a gitignored .env / .env.local, Codespaces secrets, or the devcontainer remoteEnv passthrough - never baked into the image.
Fallback: local / Windows
qa-docker.sh exits 3 with guidance when Docker is unavailable or on Windows; run the scripts directly on the host there (they isolate via mktemp CODEX_HOME). Windows has no Docker QA path here by design.
Cleanup
Each run auto-removes its container (--rm). The omo-dev / omo-qa images persist for fast re-runs; drop them with script/agent/qa-docker.sh --clean.
Installing the LOCAL omo build into an isolated CODEX_HOME
QA must run THIS repo's local build, not the published package. The installer respects CODEX_HOME for everything, so a non-default home is fully self contained.
Command
export CODEX_HOME="$(mktemp -d)/codex"; mkdir -p "$CODEX_HOME" # must exist first
export OMO_DISABLE_POSTHOG=1 OMO_CODEX_DISABLE_POSTHOG=1
export OMO_CODEX_PROJECT="$(mktemp -d)/project" # keep project-local cleanup off your tree
node packages/omo-codex/scripts/install-local.mjs installcqa_install_local_omo wraps this (logs to $CQA_HOME_ROOT/install.log).
What it writes (all under CODEX_HOME)
Source: packages/omo-codex/src/install/install-codex.ts.
1. Builds + copies the plugin to $CODEX_HOME/plugins/cache/sisyphuslabs/omo/<version>/ (then npm ci --omit=dev). 2. Links component bins into $CODEX_HOME/bin/omo-* (8: comment-checker, git-bash-hook, lsp, rules, start-work-continuation, telemetry, ultrawork, ulw-loop). 3. Links agent TOMLs into $CODEX_HOME/agents/*.toml. 4. Writes a marketplace snapshot under $CODEX_HOME/.tmp/marketplaces/sisyphuslabs/. 5. Edits $CODEX_HOME/config.toml: enables [plugins."omo@sisyphuslabs"], the [marketplaces.sisyphuslabs] local source, [features] (plugins/plugin_hooks/multi_agent/child_agents_md), and one [hooks.state."omo@sisyphuslabs:hooks/hooks.json:<event>:i:j"] trusted_hash per hook (so Codex trusts them — no --dangerously-bypass-hook-trust needed for the app-server turn).
Assertions (what install-verify.sh checks)
ls "$CODEX_HOME"/plugins/cache/sisyphuslabs/omo/*/ # cache present
grep -A2 '\[plugins."omo@sisyphuslabs"\]' "$CODEX_HOME/config.toml" | grep 'enabled = true'
ls "$CODEX_HOME"/bin/omo-* # component bins
ls "$CODEX_HOME"/agents/*.toml # agent linksPlus the cross-cutting invariant every script enforces: the real ~/.codex/config.toml shasum is unchanged.
Notes
- The only thing outside CODEX_HOME is the
omoruntime wrapper, which targets
the repo's dist/cli/index.js (the CLI ships from the repo). dist/cli/index.js must exist (run bun run build if missing) or that link is skipped.
- Cleanup: for an isolated home just
rm -rf "$CODEX_HOME"(the harness does
this on exit). For a normal home, node packages/omo-codex/scripts/install-local.mjs uninstall.
bun run test:codexis the hermetic unit gate (installer/config/component
build) and does NOT launch a real codex — this skill is what proves the live session.
Isolation — QA ONLY our plugin, never the user's real Codex
The whole point of this skill: exercise the omo plugin in a real Codex without reading or writing the user's ~/.codex, and without a real model API call. Two levers do all the work.
Lever 1 — an isolated CODEX_HOME
CODEX_HOME is Codex's master state root: config.toml, auth.json, sessions, the state SQLite, plugins, and logs all hang off it (utils/home-dir/src/lib.rs). Point it at a fresh temp dir and Codex reads/writes nothing else.
Gotcha: when CODEX_HOME is set it must already exist or Codex hard-errors. cqa_mk_isolated_home creates it first.
cqa_mk_isolated_home also exports:
OMO_CODEX_PROJECT+QA_CWD→ a sandbox project dir, so the installer's
project-local cleanup and the TUI's cwd never touch your real tree.
CODEX_LOCAL_BIN_DIR=$CODEX_HOME/bin→ component bins land in the sandbox.
(Even without this, a non-default CODEX_HOME already routes bins to $CODEX_HOME/bin; with the DEFAULT home they would leak to ~/.local/bin.)
OMO_DISABLE_POSTHOG=1+OMO_CODEX_DISABLE_POSTHOG=1→ no install/telemetry
network call.
Proof it stayed clean: cqa_guard_real_home shasums ~/.codex/config.toml before, cqa_assert_real_home_unchanged re-checks after. Every script runs it.
Lever 2 — a local mock model (no real API)
Codex must reach a model to run a turn. Instead of OpenAI, we run scripts/lib/mock-model.mjs (OpenAI Responses SSE) on localhost and point a custom provider at it via -c overrides:
-c model="mock-model"
-c model_provider="mock_provider"
-c model_providers.mock_provider.name="codex-qa mock" # REQUIRED: empty name fails config load
-c model_providers.mock_provider.base_url="http://127.0.0.1:<PORT>/v1"
-c model_providers.mock_provider.wire_api="responses"
-c approval_policy="never"
-c sandbox_mode="read-only"A non-OpenAI provider needs no key/auth, so there is no real egress. -c overrides beat any value in config.toml, so even a misconfigured isolated home still lands on the mock.
The codex shell-function trap
The interactive shell here wraps codex in a function that injects --profile quotio (sparkshell). That breaks non-runtime subcommands like generate-json-schema and would point a turn at the quotio provider. Bash scripts do not inherit that function, so codex inside a #!/usr/bin/env bash script is the real binary on PATH. cqa_codex_bin resolves it explicitly; never rely on the interactive alias. Combined with the isolated CODEX_HOME, the real quotio config is never read.
Observing Codex at runtime (logs + debug surfaces)
The intent "use /debugging to watch logs while QAing" maps to the surfaces below. Codex has no `/debugging` command; these are the real ways to observe a run.
1. The notification stream (best signal for plugin QA)
When you drive via the app-server, the stdout stream IS the live trace: hook/started / hook/completed, item/*, mcpServer/*, error, warning. This is structured and assertion-grade — prefer it over scraping text logs. scripts/app-server-drive.sh captures it; the JSON summary it prints is the evidence.
2. app-server stderr (RUST_LOG)
The app-server writes tracing logs to stderr (not a file), filtered by RUST_LOG (app-server/src/lib.rs:638-651). Turn it up and capture:
RUST_LOG=info # or debug
LOG_FORMAT=json # optional: machine-parseable linesThe driver inherits the env; raise RUST_LOG before invoking it to see the plugin/hook subprocess accounting on stderr (surfaced in the summary's stderrTail).
3. The logs SQLite DB
The app-server also writes structured logs to a SQLite DB under $CODEX_HOME (alongside state_5.sqlite). Query it post-run for a durable record:
ls "$CODEX_HOME"/*.sqlite4. TUI /debug-config
Inside the TUI, the slash command is `/debug-config` (NOT /debugging) — "show config layers and requirement sources" (tui/src/slash_command.rs:107). Useful to confirm which config layer enabled the plugin. Drive it under tmux:
tmux send-keys -t <sess> "/debug-config" Enter
tmux capture-pane -t <sess> -p -S -For TUI visual QA evidence, replay that pane capture through the browser helper from the repository root:
node script/qa/web-terminal-visual-qa.mjs --title "Codex TUI /debug-config" \
--from-file .omo/evidence/<slug>/codex-debug-config-pane.txt \
--evidence-dir .omo/evidence/<slug>/codex-debug-config-web-terminalAttach the resulting terminal.png and keep metadata.json with the cleanup receipt. The pane proves terminal text; the PNG proves the visual TUI surface.
5. codex debug subcommands
codex debug models (raw model catalog), codex debug prompt-input (the model-visible prompt list), and codex debug app-server … (a built-in app-server driver). Run them against the isolated CODEX_HOME for ad-hoc inspection.
Component-level logs
rules emits phase/timing lines to stderr under NODE_DEBUG=codex-rules. Most components prove themselves through their stdout additionalContext or a disk artifact — see components-hooks.md.
#!/usr/bin/env bash
# app-server-drive.sh - FIRST-PARTY codex QA: drive a real `codex app-server`
# turn against an ISOLATED CODEX_HOME + a LOCAL mock model, and read the
# structured notification stream. This is how you prove the omo plugin behaves
# in a live Codex session without scripting the TUI and without a real API call.
#
# Modes:
# --self-test Bare isolated home (no plugin). Proves the driver works: a
# turn runs and the assistant message comes back from the mock.
# Fast; no install.
# --plugin Install THIS repo's local omo build into the isolated home,
# then drive a turn and PROVE the plugin hooks fire by asserting
# hook/completed notifications for the expected events.
# Heavier (runs install-local).
#
# Options (any mode):
# --prompt <text> user message (default: "say hello"; --plugin defaults to
# "ulw: say hello" so the ultrawork userPromptSubmit hook fires)
# --expect <ev,...> hook eventNames that MUST complete (default in --plugin:
# "sessionStart,userPromptSubmit")
# --keep do not delete the isolated home (for inspection)
#
# The captured JSON summary IS the evidence; redirect it into .omo/evidence/.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/lib/common.sh"
cqa_drive() {
local plugin="$1" prompt="$2" expect="$3"
cqa_require codex node jq || return 1
cqa_guard_real_home
cqa_mk_isolated_home
if [ "$plugin" = "1" ]; then
cqa_log "installing local omo build into $CODEX_HOME (this builds the plugin)..."
if ! cqa_install_local_omo; then
cqa_log "install failed; tail:"; tail -20 "$CQA_HOME_ROOT/install.log" >&2 2>/dev/null
return 1
fi
grep -q 'omo@sisyphuslabs' "$CODEX_HOME/config.toml" || { cqa_fail "omo not enabled in isolated config.toml"; return 1; }
fi
cqa_start_mock || return 1
local out
out="$(EXPECT_HOOK="$expect" PROMPT="$prompt" DEADLINE_MS="${DEADLINE_MS:-90000}" \
node "$SCRIPT_DIR/lib/app-server-client.mjs")"
local rc=$?
printf '%s\n' "$out"
cqa_assert_real_home_unchanged || rc=1
if [ "$rc" -eq 0 ]; then
cqa_pass "app-server turn completed; assistant text: $(printf '%s' "$out" | jq -r '.assistantText')"
[ -n "$expect" ] && cqa_pass "hooks fired: $(printf '%s' "$out" | jq -r '[.hooks[]|select(.method=="hook/completed")|.eventName]|unique|join(", ")')"
else
cqa_log "missing hooks: $(printf '%s' "$out" | jq -rc '.missingHooks? // []')"
fi
return "$rc"
}
MODE="--self-test"; PROMPT=""; EXPECT=""; KEEP=0
while [ $# -gt 0 ]; do
case "$1" in
--self-test|--plugin) MODE="$1"; shift ;;
--prompt) PROMPT="$2"; shift 2 ;;
--expect) EXPECT="$2"; shift 2 ;;
--keep) KEEP=1; shift ;;
-h|--help) sed -n '2,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) cqa_log "unknown option: $1"; shift ;;
esac
done
if [ "$KEEP" = "1" ]; then trap - EXIT; fi
if [ "$MODE" = "--plugin" ]; then
cqa_drive 1 "${PROMPT:-ulw: say hello}" "${EXPECT:-sessionStart,userPromptSubmit}"
else
cqa_drive 0 "${PROMPT:-say hello}" "$EXPECT"
fi
exit $?
#!/usr/bin/env bash
# hook-unit-probe.sh - deterministic, binary-free proof that a single omo
# component's hook logic fires. Pipes a synthetic Codex hook event (the exact
# stdin shape Codex sends) into the component's cached dist/cli.js and asserts
# its stdout - no codex process, no model, no network. Fast and exact.
#
# Use this to pin a specific component's behavior; use app-server-drive.sh
# --plugin to prove the app-server actually WIRES that hook in a live turn.
#
# --self-test install local omo (if needed), then assert the
# ultrawork component injects <ultrawork-mode> on
# an "ulw" UserPromptSubmit. (default)
# --component <name> --event <kebab-event> [--prompt <text>]
# run an arbitrary component/event by hand against
# an already-installed isolated CODEX_HOME ($CODEX_HOME).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/lib/common.sh"
cqa_plugin_root() {
ls -d "$CODEX_HOME"/plugins/cache/sisyphuslabs/omo/*/ 2>/dev/null | head -1
}
cqa_run_component() {
local comp="$1" event="$2" prompt="$3" root cli
root="$(cqa_plugin_root)"; [ -n "$root" ] || { cqa_fail "no installed omo under $CODEX_HOME"; return 1; }
cli="$root/components/$comp/dist/cli.js"
[ -f "$cli" ] || { cqa_fail "component cli missing: $cli"; return 1; }
local payload
payload="$(jq -nc --arg p "$prompt" --arg cwd "${QA_CWD:-$PWD}" \
'{hook_event_name:"UserPromptSubmit",prompt:$p,cwd:$cwd,session_id:"cqa-unit",model:"mock-model"}')"
printf '%s' "$payload" | PLUGIN_ROOT="$root" PLUGIN_DATA="$CODEX_HOME/plugins/data/omo-$comp" node "$cli" hook "$event"
}
cqa_self_test() {
cqa_require codex node jq || return 1
cqa_guard_real_home
cqa_mk_isolated_home
cqa_log "installing local omo into $CODEX_HOME ..."
cqa_install_local_omo || { tail -20 "$CQA_HOME_ROOT/install.log" >&2; return 1; }
local out
out="$(cqa_run_component ultrawork user-prompt-submit "ulw: do the thing")"
cqa_assert_real_home_unchanged || return 1
if printf '%s' "$out" | jq -e '.hookSpecificOutput.additionalContext | test("ultrawork-mode")' >/dev/null 2>&1; then
cqa_pass "ultrawork UserPromptSubmit injected <ultrawork-mode> on an ulw prompt"
return 0
fi
cqa_log "FAIL: ultrawork did not inject ultrawork-mode; got: $out"; return 1
}
MODE="self"; COMP=""; EVENT=""; PROMPT="ulw: do the thing"
while [ $# -gt 0 ]; do
case "$1" in
--self-test) MODE="self"; shift ;;
--component) MODE="manual"; COMP="$2"; shift 2 ;;
--event) EVENT="$2"; shift 2 ;;
--prompt) PROMPT="$2"; shift 2 ;;
-h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) cqa_log "unknown option: $1"; shift ;;
esac
done
if [ "$MODE" = "manual" ]; then
[ -n "$COMP" ] && [ -n "$EVENT" ] || { cqa_log "manual mode needs --component and --event"; exit 2; }
cqa_run_component "$COMP" "$EVENT" "$PROMPT"
exit $?
fi
cqa_self_test
exit $?
#!/usr/bin/env bash
# install-verify.sh - install THIS repo's local omo build into an ISOLATED
# CODEX_HOME and prove it landed correctly while the real ~/.codex is untouched.
#
# Asserts: plugin cache dir exists, config.toml enables omo@sisyphuslabs, the
# component bins + agent TOMLs linked inside the sandbox, and the real
# ~/.codex/config.toml shasum is unchanged.
#
# --self-test run the full isolated install + assertions (default)
# --keep keep the isolated home and print its path for inspection
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/lib/common.sh"
cqa_install_verify() {
cqa_require codex node || return 1
cqa_guard_real_home
cqa_mk_isolated_home
cqa_log "installing local omo into $CODEX_HOME ..."
if ! cqa_install_local_omo; then
cqa_log "install failed; tail:"; tail -25 "$CQA_HOME_ROOT/install.log" >&2 2>/dev/null
return 1
fi
local fails=0
if ls "$CODEX_HOME"/plugins/cache/sisyphuslabs/omo/*/ >/dev/null 2>&1; then
cqa_pass "plugin cache present ($(ls "$CODEX_HOME"/plugins/cache/sisyphuslabs/omo/ | head -1))"
else cqa_log "FAIL: plugin cache missing"; fails=$((fails+1)); fi
if grep -q '\[plugins."omo@sisyphuslabs"\]' "$CODEX_HOME/config.toml" 2>/dev/null \
&& grep -A2 '\[plugins."omo@sisyphuslabs"\]' "$CODEX_HOME/config.toml" | grep -q 'enabled = true'; then
cqa_pass "config.toml enables omo@sisyphuslabs"
else cqa_log "FAIL: omo not enabled in isolated config.toml"; fails=$((fails+1)); fi
if ls "$CODEX_HOME"/bin/omo-* >/dev/null 2>&1; then
cqa_pass "component bins linked in sandbox ($(ls "$CODEX_HOME"/bin/omo-* | wc -l | tr -d ' ') bins)"
else cqa_log "FAIL: no component bins under $CODEX_HOME/bin"; fails=$((fails+1)); fi
if [ -d "$CODEX_HOME/agents" ] && ls "$CODEX_HOME"/agents/*.toml >/dev/null 2>&1; then
cqa_pass "agent TOMLs linked in sandbox"
else cqa_log "FAIL: no agent TOMLs under $CODEX_HOME/agents"; fails=$((fails+1)); fi
cqa_assert_real_home_unchanged || fails=$((fails+1))
[ "$KEEP" = "1" ] && cqa_log "kept isolated home: $CODEX_HOME"
if [ "$fails" -eq 0 ]; then cqa_pass "install-verify"; return 0; fi
cqa_log "install-verify had $fails failure(s)"; return 1
}
KEEP=0
while [ $# -gt 0 ]; do
case "$1" in
--self-test) shift ;;
--keep) KEEP=1; shift ;;
-h|--help) sed -n '2,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) cqa_log "unknown option: $1"; shift ;;
esac
done
[ "$KEEP" = "1" ] && trap - EXIT
cqa_install_verify
exit $?
// app-server-client.mjs - drive a real `codex app-server` turn for QA.
//
// This is the FIRST-PARTY way to QA the omo Codex plugin: instead of scripting
// the TUI, we speak the app-server's own protocol (newline-delimited JSON over
// stdio - NO "jsonrpc" field) and watch the structured notification stream.
//
// initialize -> initialized -> thread/start -> turn/start
// ... collect hook/started + hook/completed (plugin proof)
// ... collect item/completed agentMessage (assistant text)
// stop on turn/completed (turn.status == "completed" | "failed")
//
// Env (CODEX_HOME is inherited and MUST already point at the isolated home):
// MOCK_PORT port of the mock model server (required; no real API call).
// PROMPT user message text (default "say hello").
// QA_CWD conversation working dir (default process.cwd()).
// DEADLINE_MS hard stop (default 60000).
// EXPECT_HOOK comma-separated hook eventNames that MUST complete for exit 0
// (e.g. "userPromptSubmit,sessionStart"). Empty = only require
// turn/completed.
// CODEX_BIN codex binary (default "codex"; PATH lookup, no shell function).
//
// Prints a JSON summary to stdout. Exit 0 iff the turn completed, every
// EXPECT_HOOK fired with status "completed", and no hook completed failed.
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
export function parseExpectedHooks(value) {
return (value || "").split(",").map((s) => s.trim()).filter(Boolean);
}
export function summarizeRun({ turnStatus, assistantText, threadId, turnId, expectHook, hooks, stderr }) {
const completed = new Set(
hooks
.filter((h) => h.method === "hook/completed" && h.status === "completed")
.map((h) => h.eventName),
);
const missingHooks = expectHook.filter((eventName) => !completed.has(eventName));
const failedHooks = hooks.filter((h) => h.method === "hook/completed" && h.status !== "completed");
const ok = turnStatus === "completed" && missingHooks.length === 0 && failedHooks.length === 0;
return {
ok,
turnStatus,
assistantText,
threadId,
turnId,
expectHook,
missingHooks,
failedHooks,
hooks,
stderrTail: stderr.split("\n").slice(-10).join("\n"),
};
}
function main() {
const CODEX_BIN = process.env.CODEX_BIN || "codex";
const MOCK_PORT = process.env.MOCK_PORT;
const PROMPT = process.env.PROMPT || "say hello";
const CWD = process.env.QA_CWD || process.cwd();
const DEADLINE_MS = Number(process.env.DEADLINE_MS || 60000);
const EXPECT = parseExpectedHooks(process.env.EXPECT_HOOK || "");
if (!MOCK_PORT) {
console.error("app-server-client: MOCK_PORT is required (start lib/mock-model.mjs first)");
process.exit(2);
}
// Config overrides force codex onto the local mock provider, never the real one.
const overrides = [
`model="mock-model"`,
`model_provider="mock_provider"`,
`model_providers.mock_provider.name="codex-qa mock"`,
`model_providers.mock_provider.base_url="http://127.0.0.1:${MOCK_PORT}/v1"`,
`model_providers.mock_provider.wire_api="responses"`,
`model_providers.mock_provider.request_max_retries=0`,
`model_providers.mock_provider.stream_max_retries=0`,
`approval_policy="never"`,
`sandbox_mode="read-only"`,
];
const args = overrides.flatMap((o) => ["-c", o]).concat("app-server");
const child = spawn(CODEX_BIN, args, { stdio: ["pipe", "pipe", "pipe"], env: process.env });
let stderr = "";
child.stderr.on("data", (c) => (stderr += c));
const hooks = [];
let assistantText = null;
let threadId = null;
let turnId = null;
let turnStatus = null;
let buf = "";
let finished = false;
const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n");
function finish() {
if (finished) return;
finished = true;
try {
child.kill("SIGTERM");
} catch (error) {
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
stderr += `\n[driver] failed to terminate app-server: ${message}\n`;
}
const summary = summarizeRun({ turnStatus, assistantText, threadId, turnId, expectHook: EXPECT, hooks, stderr });
console.log(JSON.stringify(summary, null, 2));
process.exit(summary.ok ? 0 : 1);
}
function handle(msg) {
if (msg.id === 1 && msg.result) {
send({ method: "initialized" });
send({ id: 2, method: "thread/start", params: { cwd: CWD } });
} else if (msg.id === 2 && msg.result) {
threadId = msg.result.thread?.id;
send({ id: 3, method: "turn/start", params: { threadId, input: [{ type: "text", text: PROMPT }] } });
} else if (msg.id === 3 && msg.result) {
turnId = msg.result.turn?.id;
} else if (msg.method === "hook/started" || msg.method === "hook/completed") {
const run = msg.params?.run || {};
hooks.push({
method: msg.method,
eventName: run.eventName,
status: run.status,
source: run.source ?? run.pluginId,
pluginId: run.pluginId,
hookName: run.hookName ?? run.name,
runId: run.id,
});
} else if (msg.method === "item/completed") {
const item = msg.params?.item;
if (item?.type === "agentMessage" && typeof item.text === "string") assistantText = item.text;
} else if (msg.method === "turn/completed") {
turnStatus = msg.params?.turn?.status;
finish();
}
}
child.stdout.on("data", (chunk) => {
buf += chunk;
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch (error) {
if (error instanceof SyntaxError) continue;
throw error;
}
handle(msg);
}
});
child.on("exit", (code) => {
if (finished) return;
console.log(JSON.stringify({ ok: false, exitCode: code, turnStatus, hooks, stderrTail: stderr.split("\n").slice(-15).join("\n") }, null, 2));
process.exit(1);
});
send({ id: 1, method: "initialize", params: { clientInfo: { name: "codex-qa", version: "0.1.0" }, capabilities: { experimentalApi: true, requestAttestation: false } } });
setTimeout(() => { stderr += "\n[driver] deadline reached\n"; finish(); }, DEADLINE_MS);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
import { describe, expect, it } from "bun:test";
import { parseExpectedHooks, summarizeRun } from "./app-server-client.mjs";
describe("app-server-client summary", () => {
it("#given a completed expected event also has a failed hook run #when summarized #then the QA run fails", () => {
const summary = summarizeRun({
turnStatus: "completed",
assistantText: "ok",
threadId: "thread",
turnId: "turn",
expectHook: ["sessionStart", "userPromptSubmit"],
hooks: [
{ method: "hook/completed", eventName: "sessionStart", status: "completed", source: "plugin" },
{ method: "hook/completed", eventName: "userPromptSubmit", status: "completed", source: "plugin" },
{ method: "hook/completed", eventName: "userPromptSubmit", status: "failed", source: "plugin" },
],
stderr: "",
});
expect(summary.ok).toBe(false);
expect(summary.missingHooks).toEqual([]);
expect(summary.failedHooks).toEqual([
{ method: "hook/completed", eventName: "userPromptSubmit", status: "failed", source: "plugin" },
]);
});
it("#given all expected hooks complete #when summarized #then the QA run passes", () => {
const summary = summarizeRun({
turnStatus: "completed",
assistantText: "ok",
threadId: "thread",
turnId: "turn",
expectHook: ["sessionStart", "userPromptSubmit"],
hooks: [
{ method: "hook/completed", eventName: "sessionStart", status: "completed", source: "plugin" },
{ method: "hook/completed", eventName: "userPromptSubmit", status: "completed", source: "plugin" },
],
stderr: "",
});
expect(summary.ok).toBe(true);
expect(summary.failedHooks).toEqual([]);
});
it("#given a comma-separated expectation #when parsed #then whitespace and empties are ignored", () => {
expect(parseExpectedHooks(" sessionStart, ,userPromptSubmit ")).toEqual(["sessionStart", "userPromptSubmit"]);
});
});
#!/usr/bin/env bash
# common.sh - shared helpers for codex-qa scripts.
#
# Source it from a sibling script:
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# . "$SCRIPT_DIR/lib/common.sh"
#
# SAFETY MODEL (read this):
# - We QA ONLY our plugin, never the user's real codex. Everything that
# spawns codex runs against an ISOLATED CODEX_HOME (cqa_mk_isolated_home)
# plus a LOCAL mock model provider (cqa_start_mock) - so there is no real
# API call and the real ~/.codex is never read or written.
# - cqa_guard_real_home snapshots the real ~/.codex/config.toml up front;
# cqa_assert_real_home_unchanged proves QA never touched it.
# - cqa_cleanup runs on EXIT and tears down the app-server, mock model, tmux
# sessions, and every temp dir the helpers created.
#
# The interactive shell may wrap `codex` in a function that injects
# `--profile`; bash scripts do not see that function, so `codex` here is the
# real binary on PATH. We still resolve it explicitly via cqa_codex_bin.
set -uo pipefail
CQA_TMPDIRS=()
CQA_PIDS=()
CQA_TMUX_SESSIONS=()
CQA_REAL_HOME_SUM=""
cqa_log() { printf '%s\n' "$*" >&2; }
cqa_pass() { printf 'PASS: %s\n' "$*"; }
cqa_fail() { printf 'FAIL: %s\n' "$*" >&2; return 1; }
# cqa_require <bin>... -> 0 if all present, else 1 (names the missing ones).
cqa_require() {
local missing=0 b
for b in "$@"; do
command -v "$b" >/dev/null 2>&1 || { cqa_log "missing dependency: $b"; missing=1; }
done
return "$missing"
}
# Absolute path of the REAL codex binary (bypasses any interactive shell
# function/alias). Override with CODEX_BIN.
cqa_codex_bin() {
if [ -n "${CODEX_BIN:-}" ]; then printf '%s' "$CODEX_BIN"; return 0; fi
command -v codex 2>/dev/null
}
cqa_real_codex_home() { printf '%s' "${HOME}/.codex"; }
# Snapshot the real ~/.codex/config.toml so we can prove QA never touched it.
cqa_guard_real_home() {
local cfg; cfg="$(cqa_real_codex_home)/config.toml"
if [ -f "$cfg" ]; then
CQA_REAL_HOME_SUM="$(shasum "$cfg" 2>/dev/null | awk '{print $1}')"
else
CQA_REAL_HOME_SUM="ABSENT"
fi
}
cqa_assert_real_home_unchanged() {
local cfg now; cfg="$(cqa_real_codex_home)/config.toml"
if [ -f "$cfg" ]; then now="$(shasum "$cfg" 2>/dev/null | awk '{print $1}')"; else now="ABSENT"; fi
if [ "$now" = "$CQA_REAL_HOME_SUM" ]; then
cqa_pass "real ~/.codex/config.toml unchanged ($now)"
return 0
fi
cqa_fail "real ~/.codex/config.toml CHANGED ($CQA_REAL_HOME_SUM -> $now)"
}
# Create an isolated CODEX_HOME and project dir, export the env that keeps the
# run hermetic, and register the temp root for cleanup. Sets globals
# CQA_HOME_ROOT / CODEX_HOME / OMO_CODEX_PROJECT / QA_CWD.
#
# IMPORTANT: call this DIRECTLY, never via $(...). A subshell would discard the
# exports and the cleanup registration.
cqa_mk_isolated_home() {
local root; root="$(mktemp -d -t cqa-home.XXXXXX)" || return 1
CQA_TMPDIRS+=("$root")
# CODEX_HOME must EXIST before codex launches, or codex hard-errors.
mkdir -p "$root/codex" "$root/proj"
export CQA_HOME_ROOT="$root"
export CODEX_HOME="$root/codex"
export OMO_CODEX_PROJECT="$root/proj"
export QA_CWD="$root/proj"
# never leak install bins or telemetry out of the sandbox
export CODEX_LOCAL_BIN_DIR="$root/codex/bin"
export OMO_DISABLE_POSTHOG=1
export OMO_CODEX_DISABLE_POSTHOG=1
}
# Start the local mock model server. Sets CQA_MOCK_PID + exports MOCK_PORT.
# Call DIRECTLY (not via $(...)) so the PID + export land in the caller.
cqa_start_mock() {
local lib_dir log; lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log="$(mktemp -t cqa-mock.XXXXXX)"; CQA_TMPDIRS+=("$log")
node "$lib_dir/mock-model.mjs" >"$log" 2>&1 &
CQA_MOCK_PID=$!; CQA_PIDS+=("$CQA_MOCK_PID")
local i port=""
for i in $(seq 1 100); do
port="$(awk '/MOCK_LISTENING/{print $2; exit}' "$log" 2>/dev/null)"
[ -n "$port" ] && break
kill -0 "$CQA_MOCK_PID" 2>/dev/null || { cqa_log "mock model died:"; cat "$log" >&2; return 1; }
sleep 0.1
done
[ -n "$port" ] || { cqa_log "mock model never reported a port"; return 1; }
export MOCK_PORT="$port"
}
# Install THIS repo's local omo build into the isolated CODEX_HOME. Requires
# cqa_mk_isolated_home first. REPO_ROOT defaults to the repo containing this skill.
cqa_install_local_omo() {
local repo="${REPO_ROOT:-}"
if [ -z "$repo" ]; then
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)"
fi
local installer="$repo/packages/omo-codex/scripts/install-local.mjs"
[ -f "$installer" ] || { cqa_fail "installer not found: $installer"; return 1; }
node "$installer" install >"$CQA_HOME_ROOT/install.log" 2>&1
}
# Teardown everything the helpers created. Safe to call multiple times.
cqa_cleanup() {
local p s d
for p in "${CQA_PIDS[@]:-}"; do
[ -n "$p" ] && kill "$p" 2>/dev/null || true
done
for s in "${CQA_TMUX_SESSIONS[@]:-}"; do
[ -n "$s" ] && tmux kill-session -t "$s" 2>/dev/null || true
done
for d in "${CQA_TMPDIRS[@]:-}"; do
[ -n "$d" ] && rm -rf "$d" 2>/dev/null || true
done
CQA_TMPDIRS=(); CQA_PIDS=(); CQA_TMUX_SESSIONS=()
}
trap cqa_cleanup EXIT
# ---- self-check ------------------------------------------------------------
# Run: bash scripts/lib/common.sh --self-check
cqa__self_check() {
local fails=0
if cqa_require codex node jq tmux; then cqa_pass "dependencies present (codex node jq tmux)"
else cqa_log "FAIL: missing dependencies"; fails=$((fails+1)); fi
local bin; bin="$(cqa_codex_bin)"
if [ -n "$bin" ]; then cqa_pass "codex binary -> $bin"
else cqa_log "FAIL: codex binary not found"; fails=$((fails+1)); fi
cqa_guard_real_home
# isolation + trap teardown: an inner shell creates a sandbox (DIRECTLY) and
# exits; the EXIT trap must remove it. Pass the path out via a marker file.
local marker root home
marker="$(mktemp -t cqa-marker.XXXXXX)"
bash -c '. "'"${BASH_SOURCE[0]}"'"; cqa_mk_isolated_home; printf "%s\n%s\n" "$CQA_HOME_ROOT" "$CODEX_HOME" > "'"$marker"'"'
root="$(sed -n '1p' "$marker" 2>/dev/null)"; home="$(sed -n '2p' "$marker" 2>/dev/null)"
rm -f "$marker"
if [ -n "$root" ] && [ ! -d "$root" ]; then cqa_pass "isolated CODEX_HOME auto-removed on exit ($root)"
else cqa_log "FAIL: sandbox not cleaned: '$root'"; fails=$((fails+1)); fi
if [ -n "$home" ] && [ "$home" = "$root/codex" ]; then cqa_pass "CODEX_HOME points inside sandbox, not ~/.codex"
else cqa_log "FAIL: CODEX_HOME not isolated ('$home')"; fails=$((fails+1)); fi
# mock model: start it, confirm it serves the Responses SSE, then cleanup.
cqa_mk_isolated_home
if cqa_start_mock; then
if curl -s -X POST "http://127.0.0.1:$MOCK_PORT/v1/responses" -d '{}' 2>/dev/null | grep -q 'response.completed'; then
cqa_pass "mock model serves Responses SSE on :$MOCK_PORT"
else cqa_log "FAIL: mock model did not return response.completed"; fails=$((fails+1)); fi
else cqa_log "FAIL: mock model did not start"; fails=$((fails+1)); fi
cqa_assert_real_home_unchanged || fails=$((fails+1))
if [ "$fails" -eq 0 ]; then cqa_pass "common.sh self-check"; return 0; fi
cqa_log "common.sh self-check had $fails failure(s)"; return 1
}
if [ "${1:-}" = "--self-check" ]; then
cqa__self_check
exit $?
fi
// mock-model.mjs - a local OpenAI Responses-API SSE server for codex-qa.
//
// WHY: codex talks to a model over HTTP. Pointing a custom model_provider at
// this server lets QA drive a REAL codex turn end-to-end with NO real API
// call, no key, and no network egress - so we test OUR plugin, never OpenAI.
//
// It answers POST .../responses with the 3-event Responses stream codex needs
// for one assistant message (response.created -> output_item.done ->
// response.completed). Each POST gets a fresh response, so a turn that makes
// several model requests (session-start probe + the turn itself) is covered.
//
// Env:
// MOCK_PORT TCP port to bind (default 0 = OS-assigned; the chosen port is
// printed as "MOCK_LISTENING <port>" on stdout so the caller can
// read it back).
// MOCK_TEXT assistant message text (default below).
import { createServer } from "node:http";
const TEXT = process.env.MOCK_TEXT || "Hello from the codex-qa mock model.";
const server = createServer((req, res) => {
if (req.method === "POST" && req.url && req.url.endsWith("/responses")) {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const sse = (obj) => res.write(`event: ${obj.type}\ndata: ${JSON.stringify(obj)}\n\n`);
sse({ type: "response.created", response: { id: "resp-1" } });
sse({
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: "msg-1",
content: [{ type: "output_text", text: TEXT }],
},
});
sse({
type: "response.completed",
response: { id: "resp-1", usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } },
});
res.end();
});
return;
}
res.writeHead(404).end();
});
const port = Number(process.env.MOCK_PORT || 0);
server.listen(port, "127.0.0.1", () => {
process.stdout.write(`MOCK_LISTENING ${server.address().port}\n`);
});
#!/usr/bin/env bash
# tui-smoke.sh - boot the real codex TUI under tmux in an ISOLATED CODEX_HOME
# (+ local mock model) and capture the rendered pane. SMOKE only: it proves the
# TUI launches, renders, and stays alive - it does NOT assert turn behavior
# (use app-server-drive.sh for that). The captured pane is the artifact.
#
# --self-test boot bare TUI, assert it renders + survives, capture pane
# --plugin install local omo first, then boot (proves the plugin
# loads in the real TUI without crashing it)
# --seconds <n> dwell time before capture (default 5)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/lib/common.sh"
cqa_tui_smoke() {
local plugin="$1" dwell="$2"
cqa_require codex tmux node || return 1
cqa_guard_real_home
cqa_mk_isolated_home
if [ "$plugin" = "1" ]; then
cqa_log "installing local omo into $CODEX_HOME ..."
cqa_install_local_omo || { tail -20 "$CQA_HOME_ROOT/install.log" >&2; return 1; }
fi
cqa_start_mock || return 1
local bin sess cap launch exitf errf; bin="$(cqa_codex_bin)"
sess="cqa-tui-$$"; CQA_TMUX_SESSIONS+=("$sess")
cap="$CQA_HOME_ROOT/tui-pane.txt"
launch="$CQA_HOME_ROOT/tui-launch.sh"
exitf="$CQA_HOME_ROOT/tui-exit.txt"
errf="$CQA_HOME_ROOT/tui-stderr.txt"
cat > "$launch" <<LAUNCH
#!/usr/bin/env bash
export CODEX_HOME="$CODEX_HOME"
cd "$QA_CWD" || exit 97
"$bin" -c model=mock-model -c model_provider=mock_provider \
-c model_providers.mock_provider.name="codex-qa mock" \
-c model_providers.mock_provider.base_url=http://127.0.0.1:$MOCK_PORT/v1 \
-c model_providers.mock_provider.wire_api=responses \
-c approval_policy=never -c sandbox_mode=read-only 2>"$errf"
echo "\$?" > "$exitf"
sleep 600
LAUNCH
chmod +x "$launch"
tmux new-session -d -s "$sess" -x 200 -y 50 "bash '$launch'"
sleep "$dwell"
tmux capture-pane -t "$sess" -p -S - > "$cap" 2>/dev/null
tmux send-keys -t "$sess" C-c 2>/dev/null; sleep 0.3
tmux kill-session -t "$sess" 2>/dev/null
cqa_assert_real_home_unchanged || return 1
if [ -f "$exitf" ]; then
cqa_log "codex exited during boot (code $(cat "$exitf")); stderr:"; sed -n '1,20p' "$errf" >&2
return "$(cqa_fail "codex TUI did not stay up")"
fi
cqa_log "captured pane -> $cap"; sed -n '1,40p' "$cap" >&2
if [ ! -s "$cap" ]; then cqa_fail "TUI pane was empty (did not render)"; return 1; fi
if grep -qiE 'panic|panicked|fatal' "$cap"; then cqa_fail "TUI crashed (panic/fatal in pane)"; return 1; fi
cqa_pass "codex TUI booted, rendered, and survived ${dwell}s (no early exit)"; return 0
}
MODE=0; DWELL=5
while [ $# -gt 0 ]; do
case "$1" in
--self-test) MODE=0; shift ;;
--plugin) MODE=1; shift ;;
--seconds) DWELL="$2"; shift 2 ;;
-h|--help) sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) cqa_log "unknown option: $1"; shift ;;
esac
done
cqa_tui_smoke "$MODE" "$DWELL"
exit $?