From 423ebbc233c3aec665ab51d857f3ff4bed2e075f Mon Sep 17 00:00:00 2001 From: e-jung <8334081+e-jung@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:13:10 +0000 Subject: [PATCH 1/4] spike codex app-server probe --- bin/fm-codex-appserver-probe.sh | 357 +++++++++++++++++++++++++ docs/codex-app-backend-spike.md | 172 ++++++++++++ docs/scripts.md | 1 + tests/fm-codex-appserver-probe.test.sh | 117 ++++++++ 4 files changed, 647 insertions(+) create mode 100755 bin/fm-codex-appserver-probe.sh create mode 100644 docs/codex-app-backend-spike.md create mode 100755 tests/fm-codex-appserver-probe.test.sh diff --git a/bin/fm-codex-appserver-probe.sh b/bin/fm-codex-appserver-probe.sh new file mode 100755 index 00000000..cc399467 --- /dev/null +++ b/bin/fm-codex-appserver-probe.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +# fm-codex-appserver-probe.sh - opt-in Codex app-server probe for backend spikes. +# +# Default mode is a dry run: it prints the safe probe commands without starting +# Codex app-server or writing Codex thread state. Live modes use stdio by +# default, never a network listener. +set -u + +usage() { + cat <<'EOF' +usage: fm-codex-appserver-probe.sh [options] + +Safe defaults: + With no options this is a dry run. It does not start app-server, create + threads, expose listeners, or touch project worktrees. + +Options: + --dry-run Print the probe plan only (default) + --schema-dir DIR Generate the installed app-server JSON schema bundle + into DIR and summarize lifecycle methods + --experimental-schema Include experimental schema methods/fields + --live-handshake Start app-server over stdio and run initialize + + thread/list only + --create-thread With the live stdio probe, create a thread in a temp + cwd and read it; archive is best-effort because a + no-turn thread may not have a rollout yet + --keep-thread Do not archive a created thread; prints codex:// link + --cwd DIR cwd for --create-thread (default: fresh temp dir) + --model MODEL model override for --create-thread + --listen URL Transport to validate. Live probe supports stdio:// + only; ws:// non-loopback requires auth flags. + --ws-auth MODE capability-token or signed-bearer-token + --ws-token-file PATH capability token file for ws auth + --ws-token-sha256 HEX capability token verifier for ws auth + --ws-shared-secret-file PATH + signed bearer shared secret file for ws auth + -h, --help Show this help + +Examples: + fm-codex-appserver-probe.sh + fm-codex-appserver-probe.sh --schema-dir /tmp/fm-codex-schema + fm-codex-appserver-probe.sh --live-handshake + fm-codex-appserver-probe.sh --live-handshake --create-thread --keep-thread +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +is_loopback_ws() { + case "$1" in + ws://127.*|ws://localhost:*|ws://[[]::1[]]:*) return 0 ;; + *) return 1 ;; + esac +} + +has_ws_auth_material() { + [ -n "$WS_AUTH" ] || return 1 + case "$WS_AUTH" in + capability-token) + [ -n "$WS_TOKEN_FILE$WS_TOKEN_SHA256" ] + ;; + signed-bearer-token) + [ -n "$WS_SHARED_SECRET_FILE" ] + ;; + *) + return 1 + ;; + esac +} + +summarize_schema() { + local schema_dir=$1 + node - "$schema_dir" <<'NODE' +const fs = require("fs"); +const path = require("path"); +const dir = process.argv[2]; +const clientRequest = path.join(dir, "ClientRequest.json"); +if (!fs.existsSync(clientRequest)) { + console.error(`error: schema bundle missing ${clientRequest}`); + process.exit(1); +} +const schema = JSON.parse(fs.readFileSync(clientRequest, "utf8")); +const methods = []; +for (const variant of schema.oneOf || []) { + const method = variant?.properties?.method?.enum?.[0]; + if (method) methods.push(method); +} +const required = [ + "initialize", + "thread/start", + "thread/list", + "thread/read", + "thread/archive", + "turn/start", + "turn/steer", + "turn/interrupt", +]; +const missing = required.filter((m) => !methods.includes(m)); +console.log(`schema_dir: ${dir}`); +console.log(`schema_method_count: ${methods.length}`); +console.log(`schema_required_lifecycle: ${missing.length === 0 ? "present" : "missing " + missing.join(",")}`); +console.log(`schema_thread_methods: ${methods.filter((m) => m.startsWith("thread/")).sort().join(",")}`); +console.log(`schema_turn_methods: ${methods.filter((m) => m.startsWith("turn/")).sort().join(",")}`); +if (missing.length) process.exit(2); +NODE +} + +run_live_stdio_probe() { + local create_thread=$1 keep_thread=$2 cwd=$3 model=$4 + node - "$create_thread" "$keep_thread" "$cwd" "$model" <<'NODE' +const { spawn } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const readline = require("readline"); + +const createThread = process.argv[2] === "1"; +const keepThread = process.argv[3] === "1"; +let cwd = process.argv[4] || ""; +const model = process.argv[5] || ""; +let ownedTemp = ""; + +if (createThread && !cwd) { + ownedTemp = fs.mkdtempSync(path.join(os.tmpdir(), "fm-codex-appserver-probe-")); + cwd = ownedTemp; +} +if (createThread && !path.isAbsolute(cwd)) { + console.error("error: --cwd must be absolute for --create-thread"); + process.exit(2); +} + +const child = spawn("codex", ["app-server", "--stdio"], { + stdio: ["pipe", "pipe", "pipe"], +}); +const rl = readline.createInterface({ input: child.stdout }); +const pending = new Map(); +const notifications = {}; +let nextId = 1; +let shuttingDown = false; + +function send(method, params = {}) { + const id = nextId++; + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject, method }); + }); +} + +function notify(method, params = {}) { + child.stdin.write(`${JSON.stringify({ method, params })}\n`); +} + +function finish(code) { + if (shuttingDown) return; + shuttingDown = true; + try { child.stdin.end(); } catch (_) {} + try { child.kill("SIGTERM"); } catch (_) {} + if (ownedTemp) fs.rmSync(ownedTemp, { recursive: true, force: true }); + process.exitCode = code; +} + +const timer = setTimeout(() => { + console.error("error: timed out waiting for app-server response"); + finish(124); +}, 15000); + +child.stderr.on("data", (buf) => process.stderr.write(buf)); +child.on("error", (err) => { + clearTimeout(timer); + console.error(`error: failed to start codex app-server: ${err.message}`); + finish(1); +}); +child.on("exit", (code, signal) => { + if (shuttingDown) return; + clearTimeout(timer); + for (const { reject, method } of pending.values()) { + reject(new Error(`app-server exited before ${method} completed (code=${code}, signal=${signal})`)); + } + if (ownedTemp) fs.rmSync(ownedTemp, { recursive: true, force: true }); + process.exit(code ?? 1); +}); +rl.on("line", (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch (err) { + console.error(`error: invalid app-server JSON: ${err.message}: ${line}`); + finish(1); + return; + } + if (Object.prototype.hasOwnProperty.call(msg, "id")) { + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + if (msg.error) { + p.reject(new Error(`${p.method}: ${msg.error.message || JSON.stringify(msg.error)}`)); + } else { + p.resolve(msg.result || {}); + } + return; + } + if (msg.method) notifications[msg.method] = (notifications[msg.method] || 0) + 1; +}); + +(async () => { + try { + const init = await send("initialize", { + clientInfo: { + name: "firstmate_appserver_probe", + title: "Firstmate app-server probe", + version: "0.1.0", + }, + capabilities: { experimentalApi: true }, + }); + console.log("live_initialize: ok"); + console.log(`codex_home: ${init.codexHome || ""}`); + notify("initialized", {}); + + const list = await send("thread/list", { + limit: 1, + archived: false, + useStateDbOnly: true, + }); + const listed = Array.isArray(list.threads) ? list.threads.length : 0; + console.log(`live_thread_list: ok count=${listed}`); + + if (createThread) { + const params = { + cwd, + approvalPolicy: "on-request", + sandbox: "read-only", + threadSource: "firstmate_probe", + ephemeral: false, + }; + if (model) params.model = model; + const started = await send("thread/start", params); + const thread = started.thread || started; + const threadId = thread.id || started.threadId || started.id; + if (!threadId) throw new Error(`thread/start did not return a thread id: ${JSON.stringify(started)}`); + console.log(`created_thread_id: ${threadId}`); + console.log(`codex_thread_url: codex://threads/${encodeURIComponent(threadId)}`); + try { + await send("thread/read", { threadId, includeTurns: true }); + console.log("thread_read_after_create: ok includeTurns=true"); + } catch (err) { + if (!String(err.message || "").includes("includeTurns")) throw err; + await send("thread/read", { threadId, includeTurns: false }); + console.log("thread_read_after_create: ok includeTurns=false"); + } + if (keepThread) { + console.log("archived_created_thread: no"); + } else { + try { + await send("thread/archive", { threadId }); + console.log("archived_created_thread: yes"); + } catch (err) { + if (!String(err.message || "").includes("no rollout")) throw err; + console.log("archived_created_thread: unavailable-no-rollout"); + } + } + console.log(`thread_cwd: ${cwd}`); + } + + const summary = Object.entries(notifications) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join(","); + console.log(`notifications_seen: ${summary}`); + clearTimeout(timer); + finish(0); + } catch (err) { + clearTimeout(timer); + console.error(`error: ${err.message}`); + finish(1); + } +})(); +NODE +} + +DRY_RUN=1 +SCHEMA_DIR= +EXPERIMENTAL_SCHEMA=0 +LIVE_HANDSHAKE=0 +CREATE_THREAD=0 +KEEP_THREAD=0 +CWD_ARG= +MODEL_ARG= +LISTEN_URL="stdio://" +WS_AUTH= +WS_TOKEN_FILE= +WS_TOKEN_SHA256= +WS_SHARED_SECRET_FILE= + +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --schema-dir) [ "$#" -ge 2 ] || die "--schema-dir requires DIR"; SCHEMA_DIR=$2; DRY_RUN=0; shift 2 ;; + --experimental-schema) EXPERIMENTAL_SCHEMA=1; shift ;; + --live-handshake) LIVE_HANDSHAKE=1; DRY_RUN=0; shift ;; + --create-thread) CREATE_THREAD=1; LIVE_HANDSHAKE=1; DRY_RUN=0; shift ;; + --keep-thread) KEEP_THREAD=1; shift ;; + --cwd) [ "$#" -ge 2 ] || die "--cwd requires DIR"; CWD_ARG=$2; shift 2 ;; + --model) [ "$#" -ge 2 ] || die "--model requires MODEL"; MODEL_ARG=$2; shift 2 ;; + --listen) [ "$#" -ge 2 ] || die "--listen requires URL"; LISTEN_URL=$2; shift 2 ;; + --ws-auth) [ "$#" -ge 2 ] || die "--ws-auth requires MODE"; WS_AUTH=$2; shift 2 ;; + --ws-token-file) [ "$#" -ge 2 ] || die "--ws-token-file requires PATH"; WS_TOKEN_FILE=$2; shift 2 ;; + --ws-token-sha256) [ "$#" -ge 2 ] || die "--ws-token-sha256 requires HEX"; WS_TOKEN_SHA256=$2; shift 2 ;; + --ws-shared-secret-file) [ "$#" -ge 2 ] || die "--ws-shared-secret-file requires PATH"; WS_SHARED_SECRET_FILE=$2; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +case "$LISTEN_URL" in + stdio://|stdio) ;; + ws://*) + if ! is_loopback_ws "$LISTEN_URL" && ! has_ws_auth_material; then + die "refusing unauthenticated non-loopback WebSocket listener '$LISTEN_URL'" + fi + [ "$LIVE_HANDSHAKE" -eq 0 ] || die "live probe currently supports stdio:// only; use codex app-server directly for authenticated WebSocket testing" + ;; + unix://*) [ "$LIVE_HANDSHAKE" -eq 0 ] || die "live probe currently supports stdio:// only" ;; + off) [ "$LIVE_HANDSHAKE" -eq 0 ] || die "cannot run live handshake with --listen off" ;; + *) die "unsupported --listen URL: $LISTEN_URL" ;; +esac + +if [ "$DRY_RUN" -eq 1 ]; then + cat < +would_live_handshake: codex app-server --stdio +would_create_thread: no (pass --create-thread to create/read a throwaway no-turn thread) +safety: no listener opened; no Codex thread created; no project worktree modified +EOF + exit 0 +fi + +command -v codex >/dev/null 2>&1 || die "codex CLI not found on PATH" +command -v node >/dev/null 2>&1 || die "node is required for JSON-RPC probing" + +echo "codex_cli: $(codex --version 2>/dev/null || echo unknown)" + +if [ -n "$SCHEMA_DIR" ]; then + mkdir -p "$SCHEMA_DIR" + schema_args=(app-server generate-json-schema --out "$SCHEMA_DIR") + [ "$EXPERIMENTAL_SCHEMA" -eq 0 ] || schema_args+=(--experimental) + codex "${schema_args[@]}" + summarize_schema "$SCHEMA_DIR" +fi + +if [ "$LIVE_HANDSHAKE" -eq 1 ]; then + run_live_stdio_probe "$CREATE_THREAD" "$KEEP_THREAD" "$CWD_ARG" "$MODEL_ARG" +fi diff --git a/docs/codex-app-backend-spike.md b/docs/codex-app-backend-spike.md new file mode 100644 index 00000000..957b162f --- /dev/null +++ b/docs/codex-app-backend-spike.md @@ -0,0 +1,172 @@ +# Codex App-Server Backend Spike + +Date: 2026-07-04 + +This is an evidence note for a future `backend=codex-app`. It deliberately does +not add `codex-app` to firstmate's spawn-capable backend list. + +## Local Evidence + +The installed Codex CLI exposes these relevant commands: + +- `codex app-server`: experimental JSON-RPC app-server over stdio, Unix socket, + WebSocket, or `off`. +- `codex app-server generate-json-schema --out `: writes a schema bundle + matching the installed Codex version. +- `codex app-server generate-ts --out `: writes matching TypeScript + bindings. +- `codex app-server daemon`: manages a local app-server daemon. +- `codex remote-control`: starts/stops app-server daemon remote control. + +The current Codex manual describes app-server as the integration surface used by +rich clients. Its stable transport default is stdio (`--listen stdio://`), and +its WebSocket transport is experimental. A non-loopback WebSocket listener can +accept unauthenticated connections unless WebSocket auth is configured, so +firstmate probes must never expose `ws://0.0.0.0:...` or any other non-loopback +listener without `--ws-auth` plus token or secret material. + +The generated schema from this environment includes the lifecycle methods needed +for a possible backend: + +- `initialize` +- `thread/start`, `thread/list`, `thread/read`, `thread/archive` +- `thread/resume`, `thread/fork`, `thread/delete` +- `turn/start`, `turn/steer`, `turn/interrupt` + +A local stdio smoke verified `initialize` and read-only `thread/list` against +this installed app-server without opening a listener. A throwaway +`thread/start` returned a `codex_thread_id` and emitted `thread/started`, but +`thread/read includeTurns=true` failed before the first user message because the +thread was not materialized yet. Retrying `thread/read includeTurns=false` +succeeded. `thread/archive` then reported no rollout for the new id, so archive +is not verified until a probe starts at least one turn. That proves basic +transport and thread-id creation, not a production backend. + +## Probe Helper + +Use `bin/fm-codex-appserver-probe.sh` for future manual checks: + +```sh +bin/fm-codex-appserver-probe.sh +bin/fm-codex-appserver-probe.sh --schema-dir /tmp/fm-codex-schema +bin/fm-codex-appserver-probe.sh --live-handshake +bin/fm-codex-appserver-probe.sh --live-handshake --create-thread --keep-thread +``` + +Default mode is a dry run. It does not start app-server, create a thread, expose +a listener, or touch project worktrees. + +`--live-handshake` starts `codex app-server --stdio` and performs only +`initialize` plus a read-only `thread/list`. + +`--create-thread` additionally creates a thread in a temporary cwd and reads it. +Before a first turn exists, app-server may report the thread as not materialized +for `includeTurns=true` and may reject archive with "no rollout"; the helper +reports that as `archived_created_thread: unavailable-no-rollout`. Pass +`--keep-thread` only when a human wants to open the printed +`codex://threads/` link and confirm whether app-server-created threads +appear as normal Codex GUI threads. + +## GUI Visibility Finding + +The current evidence does not prove that app-server-created threads appear in +the Codex GUI sidebar as normal user-visible threads. The schema and stdio +handshake prove a callable protocol, and `thread/start` can produce a thread id, +but a no-turn thread may not have a rollout yet. The explicit `--keep-thread` +probe exists to produce a real `codex_thread_id` and `codex://threads/` link +for a manual GUI check without risking real project state. + +Until that manual GUI check is repeated and documented, app-server threads should +be treated as callable Codex sessions whose GUI visibility is unverified. + +## Metadata A Backend Would Need + +A real `backend=codex-app` task meta record would need at least: + +- `backend=codex-app` +- `codex_thread_id=` +- `codex_thread_url=codex://threads/` +- `codex_active_turn_id=` while a turn is running +- `worktree=` and an owner marker such as `worktree_owner=treehouse` or + `worktree_owner=codex` +- `codex_transport=stdio|unix|ws` +- `codex_archive_on_teardown=on|off` + +`turn/steer` requires an `expectedTurnId`, and `turn/interrupt` requires both +`threadId` and `turnId`. Firstmate therefore cannot treat a Codex thread as a +send-only terminal. It must track active turn identity or read it before steer +and interrupt operations. + +Read semantics should map to `thread/read` and app-server notifications, not a +terminal scrollback tail. Archive/kill semantics should map to `turn/interrupt` +for active work and `thread/archive` only after firstmate has safely captured +status/report evidence. + +## Codex-Managed Worktrees In Firstmate Terms + +Treehouse-owned worktrees and Codex-managed worktrees are different ownership +models. + +Treehouse today: + +- Firstmate asks treehouse for an isolated checkout. +- `fm-spawn.sh` records the worktree path and verifies it is not the primary + checkout. +- `fm-teardown.sh` owns landed-work checks, report checks, and treehouse return. +- Worktree cleanup is blocked when ship work is dirty or unlanded. + +Codex-managed worktrees: + +- Codex app creates worktrees under `$CODEX_HOME/worktrees`. +- They are usually detached HEADs dedicated to one thread. +- Codex can hand threads between Local and Worktree and can restore snapshots + after deleting managed worktrees. +- Codex automatically deletes some managed worktrees when threads are archived + or app worktree retention needs cleanup. +- Codex app controls where managed worktrees live. + +Firstmate implications: + +- A Codex-owned worktree is not safe to remove through treehouse teardown. +- Firstmate must record whether the worktree is treehouse-owned or Codex-owned. +- Teardown must fail closed when Codex has deleted or moved a worktree before + firstmate can prove the ship work is clean and landed. +- Branch and PR safety still belongs to firstmate. A Codex GUI thread may create + commits, push branches, or open PRs, but firstmate must record `pr=` and + `pr_head=` and must still require captain/firstmate merge policy. +- Handoff can change where the thread's code lives, so a backend needs a fresh + worktree read before teardown, review, or PR checks. + +Codex-managed worktrees are acceptable for firstmate only if these lifecycle +rules can be made mechanical and tested. They should not replace treehouse by +default in the first backend iteration. + +## Roadmap Recommendation + +Do not build a production `codex-app` backend yet. + +Recommended sequence: + +1. Keep Herdr and tmux unchanged. Use `bin/fm-codex-appserver-probe.sh` to + repeat schema and stdio checks as Codex versions change. +2. Manually run `--create-thread --keep-thread`, open the printed + `codex://threads/` link in Codex GUI, and record whether the thread is + visible, readable, steerable, interruptible, and archivable from both sides. +3. Add a non-spawn backend adapter sketch only after create/read/archive and + active-turn tracking are verified by tests or repeatable smoke output. +4. Start ship/scout-only. Do not support secondmates until a normal task can + create, read, steer, interrupt, archive, PR, and teardown safely. +5. Decide worktree ownership explicitly per task. Prefer treehouse-owned + worktrees for the first implementation; add Codex-managed worktrees only + after teardown and handoff behavior are proven. + +The production gate for adding `codex-app` to `FM_BACKEND_SPAWN` should be: + +- create/read/send-or-turn-start/archive verified +- active turn id tracked +- GUI visibility understood +- no unauthenticated non-loopback listener path +- worktree owner recorded +- teardown fails closed on unknown or moved worktrees +- focused backend tests cover metadata, send/steer, read, interrupt/archive, and + cleanup behavior diff --git a/docs/scripts.md b/docs/scripts.md index c0525c5d..8f009db0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -23,6 +23,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `backends/zellij.sh` | Experimental zellij session-provider adapter used by `fm-backend.sh`; owns version/tool gating, one-session/tab-per-task creation, session-scoped CLI calls, send, capture, active current-path probing, label-checked target safety, label-based live discovery, and tab cleanup primitives | | `backends/orca.sh` | Experimental Orca backend used by `fm-backend.sh`; owns repo registration, worktree creation/removal, terminal creation, capture, send text, Enter/Ctrl-C interrupt keys, and close; Escape is unsupported | | `backends/cmux.sh` | Experimental cmux session-provider adapter used by `fm-backend.sh`; owns version/tool/socket-access gating, home-scoped workspace creation, current-path probing, title-based recovery, send/capture, structural composer-state verification, Enter/Escape/Ctrl-C keys, and workspace cleanup primitives | +| `fm-codex-appserver-probe.sh` | Experimental, opt-in Codex app-server probe for future `backend=codex-app` work; defaults to dry-run, can generate and summarize the installed app-server schema, and can run stdio-only initialize/thread-list or create/read/best-effort-archive throwaway-thread checks without registering a production backend | | `fm-config-push.sh` | Config-only mid-session push of declared inheritable local config into live secondmate homes; reports each item as pushed, unchanged, skipped, or error without fast-forwarding tracked files or nudging agents | | `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | diff --git a/tests/fm-codex-appserver-probe.test.sh b/tests/fm-codex-appserver-probe.test.sh new file mode 100755 index 00000000..101fc1d6 --- /dev/null +++ b/tests/fm-codex-appserver-probe.test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# tests/fm-codex-appserver-probe.test.sh - safety and protocol-shape tests for +# the experimental Codex app-server probe helper. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-codex-appserver-probe-tests) + +make_fake_codex() { # -> echoes fakebin + local fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/codex" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = "--version" ]; then + echo "codex-cli 0.142.5-test" + exit 0 +fi +if [ "${1:-}" = "app-server" ] && [ "${2:-}" = "generate-json-schema" ]; then + out= + while [ "$#" -gt 0 ]; do + case "$1" in + --out) out=$2; shift 2 ;; + *) shift ;; + esac + done + [ -n "$out" ] || { echo "missing --out" >&2; exit 2; } + mkdir -p "$out" + cat > "$out/ClientRequest.json" <<'JSON' +{ + "oneOf": [ + {"properties":{"method":{"enum":["initialize"]}}}, + {"properties":{"method":{"enum":["thread/start"]}}}, + {"properties":{"method":{"enum":["thread/list"]}}}, + {"properties":{"method":{"enum":["thread/read"]}}}, + {"properties":{"method":{"enum":["thread/archive"]}}}, + {"properties":{"method":{"enum":["turn/start"]}}}, + {"properties":{"method":{"enum":["turn/steer"]}}}, + {"properties":{"method":{"enum":["turn/interrupt"]}}} + ] +} +JSON + exit 0 +fi +if [ "${1:-}" = "app-server" ] && [ "${2:-}" = "--stdio" ]; then + while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) printf '{"id":1,"result":{"codexHome":"/tmp/fake-codex"}}\n' ;; + *'"method":"thread/list"'*) printf '{"id":2,"result":{"threads":[]}}\n' ;; + *'"method":"thread/start"'*) printf '{"id":3,"result":{"thread":{"id":"00000000-0000-4000-8000-000000000001"}}}\n' ;; + *'"method":"thread/read"'*) printf '{"id":4,"result":{"thread":{"id":"00000000-0000-4000-8000-000000000001"},"turns":[]}}\n' ;; + *'"method":"thread/archive"'*) printf '{"id":5,"result":{}}\n' ;; + *) : ;; + esac + done + exit 0 +fi +echo "unexpected fake codex args: $*" >&2 +exit 64 +SH + chmod +x "$fb/codex" + printf '%s\n' "$fb" +} + +test_default_is_dry_run() { + local out + out=$(bash "$ROOT/bin/fm-codex-appserver-probe.sh") + assert_contains "$out" "mode: dry-run" "default invocation should be dry-run" + assert_contains "$out" "no Codex thread created" "dry-run should state no thread is created" + pass "fm-codex-appserver-probe: default invocation is safe dry-run" +} + +test_refuses_unauthenticated_non_loopback_ws() { + local out status + set +e + out=$(bash "$ROOT/bin/fm-codex-appserver-probe.sh" --live-handshake --listen ws://0.0.0.0:4500 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "non-loopback ws listener without auth should fail" + assert_contains "$out" "refusing unauthenticated non-loopback WebSocket listener" \ + "unsafe ws refusal should explain the safety issue" + pass "fm-codex-appserver-probe: refuses unauthenticated non-loopback WebSocket listener" +} + +test_schema_generation_summarizes_required_methods() { + local case_dir fb out schema_dir + case_dir="$TMP_ROOT/schema" + fb=$(make_fake_codex "$case_dir") + schema_dir="$case_dir/schema-out" + out=$(PATH="$fb:$PATH" bash "$ROOT/bin/fm-codex-appserver-probe.sh" --schema-dir "$schema_dir") + assert_present "$schema_dir/ClientRequest.json" "schema generation should write ClientRequest.json" + assert_contains "$out" "schema_required_lifecycle: present" \ + "schema summary should confirm lifecycle methods" + assert_contains "$out" "schema_turn_methods: turn/interrupt,turn/start,turn/steer" \ + "schema summary should list turn methods" + pass "fm-codex-appserver-probe: schema generation summarizes lifecycle methods" +} + +test_live_stdio_probe_can_create_read_archive_thread() { + local case_dir fb out + case_dir="$TMP_ROOT/live" + fb=$(make_fake_codex "$case_dir") + out=$(PATH="$fb:$PATH" bash "$ROOT/bin/fm-codex-appserver-probe.sh" --live-handshake --create-thread) + assert_contains "$out" "live_initialize: ok" "live probe should initialize" + assert_contains "$out" "live_thread_list: ok count=0" "live probe should list threads" + assert_contains "$out" "created_thread_id: 00000000-0000-4000-8000-000000000001" \ + "live probe should report created thread id" + assert_contains "$out" "thread_read_after_create: ok" "live probe should read created thread" + assert_contains "$out" "archived_created_thread: yes" "live probe should archive by default" + pass "fm-codex-appserver-probe: live stdio probe drives create/read/archive protocol path" +} + +test_default_is_dry_run +test_refuses_unauthenticated_non_loopback_ws +test_schema_generation_summarizes_required_methods +test_live_stdio_probe_can_create_read_archive_thread From 4deef78620ae16730e0cf3d5e2ceebcf04d4efc5 Mon Sep 17 00:00:00 2001 From: e-jung <8334081+e-jung@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:29:30 +0000 Subject: [PATCH 2/4] no-mistakes(review): propagate schema exit codes and harden codex app-server probe error matching --- bin/fm-codex-appserver-probe.sh | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/bin/fm-codex-appserver-probe.sh b/bin/fm-codex-appserver-probe.sh index cc399467..e36463ef 100755 --- a/bin/fm-codex-appserver-probe.sh +++ b/bin/fm-codex-appserver-probe.sh @@ -245,8 +245,7 @@ rl.on("line", (line) => { try { await send("thread/read", { threadId, includeTurns: true }); console.log("thread_read_after_create: ok includeTurns=true"); - } catch (err) { - if (!String(err.message || "").includes("includeTurns")) throw err; + } catch (_) { await send("thread/read", { threadId, includeTurns: false }); console.log("thread_read_after_create: ok includeTurns=false"); } @@ -257,8 +256,12 @@ rl.on("line", (line) => { await send("thread/archive", { threadId }); console.log("archived_created_thread: yes"); } catch (err) { - if (!String(err.message || "").includes("no rollout")) throw err; - console.log("archived_created_thread: unavailable-no-rollout"); + const archiveMsg = String(err.message || "").toLowerCase(); + if (archiveMsg.includes("rollout")) { + console.log("archived_created_thread: unavailable-no-rollout"); + } else { + console.log(`archived_created_thread: unavailable: ${err.message || "unknown error"}`); + } } } console.log(`thread_cwd: ${cwd}`); @@ -348,8 +351,14 @@ if [ -n "$SCHEMA_DIR" ]; then mkdir -p "$SCHEMA_DIR" schema_args=(app-server generate-json-schema --out "$SCHEMA_DIR") [ "$EXPERIMENTAL_SCHEMA" -eq 0 ] || schema_args+=(--experimental) - codex "${schema_args[@]}" - summarize_schema "$SCHEMA_DIR" + schema_rc=0 + codex "${schema_args[@]}" || schema_rc=$? + if [ "$schema_rc" -eq 0 ]; then + summarize_schema "$SCHEMA_DIR" || schema_rc=$? + fi + if [ "$schema_rc" -ne 0 ]; then + exit "$schema_rc" + fi fi if [ "$LIVE_HANDSHAKE" -eq 1 ]; then From 2b62de4bdaf92a5b2dc903cb5787c2eb1f0cb4b7 Mon Sep 17 00:00:00 2001 From: e-jung <8334081+e-jung@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:53:38 +0000 Subject: [PATCH 3/4] no-mistakes(test): fix node-on-PATH test flakiness in fm-session-start tests --- tests/fm-session-start.test.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index a5acede4..c8ec907e 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -257,7 +257,9 @@ EOF make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" # Force a MISSING diagnostic line so the bootstrap section is non-trivial. - rm -f "$fakebin/node" + # lavish-axi is a fake-provided tool guaranteed absent from BASE_PATH + # (/usr/bin:/bin:/usr/sbin:/sbin), unlike node which may live in /usr/bin. + rm -f "$fakebin/lavish-axi" printf 'window=fm-sess:w1\nkind=ship\n' > "$home/state/task-a.meta" @@ -280,7 +282,7 @@ EOF [ "$context_line" -lt "$fleet_line" ] || fail "CONTEXT did not precede FLEET STATE" [ "$fleet_line" -lt "$next_line" ] || fail "FLEET STATE did not precede NEXT STEP" - missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: node' | head -1 | cut -d: -f1) + missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: lavish-axi' | head -1 | cut -d: -f1) [ -n "$missing_line" ] || fail "MISSING diagnostic did not appear at all" [ "$missing_line" -lt "$fleet_line" ] || fail "actionable MISSING diagnostic was buried after the bulk fleet-state digest" @@ -400,7 +402,10 @@ $rec EOF make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" - rm -f "$fakebin/node" + # lavish-axi is fake-provided and guaranteed absent from BASE_PATH, so + # removing it deterministically forces a MISSING line (node would resolve + # from /usr/bin on hosts that have it installed). + rm -f "$fakebin/lavish-axi" append_wake "$home/state" signal task-z "needs-decision: pick a library" @@ -409,7 +414,7 @@ EOF # fm-lock.sh's own exact success text. assert_contains "$out" "lock acquired: harness pid" "fm-lock.sh's real output did not appear (composition, not reimplementation)" # fm-bootstrap.sh's own exact MISSING-tool line format. - assert_contains "$out" "MISSING: node (install:" "fm-bootstrap.sh's real detect line did not appear verbatim" + assert_contains "$out" "MISSING: lavish-axi (install:" "fm-bootstrap.sh's real detect line did not appear verbatim" # fm-wake-drain.sh's real drained record (raw tab-separated queue line). assert_contains "$out" "$(printf 'signal\ttask-z\tneeds-decision: pick a library')" "fm-wake-drain.sh's real drained record did not appear" From 51ffd763edc1f4ab8f6c0cce97457f9ac3ba8e92 Mon Sep 17 00:00:00 2001 From: e-jung <8334081+e-jung@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:07:30 +0000 Subject: [PATCH 4/4] no-mistakes(document): Sync docs for Codex app-server probe addition --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d6d31cd0..c6482c2c 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/orca-backend.md](docs/orca-backend.md) - setup guide for the experimental Orca backend, plus its lifecycle notes and known gaps. - [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified Claude Code Stop-hook mechanism, scoping, and known gaps. +- [docs/codex-app-backend-spike.md](docs/codex-app-backend-spike.md) - evidence note and opt-in probe helper for a future `backend=codex-app`; not a registered or spawn-capable backend. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [`AGENTS.md`](AGENTS.md) - firstmate's full operating manual for the orchestrator agent. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands.