#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="${ROOT_DIR}/.codex-plugin/plugin.json"
MARKETPLACE="${ROOT_DIR}/.claude-plugin/marketplace.json"
APP_PROOF="${ROOT_DIR}/docs/research/codex-app-smoke.md"
SMOKE_REQUIRED="${HARNESS_CODEX_PLUGIN_SMOKE_REQUIRED:-0}"
fail() {
echo "test-codex-plugin-adapter: FAIL: $1" >&2
exit 1
}
assert_file() {
[ -f "$1" ] || fail "missing $1"
}
assert_contains() {
local file="$1"
local needle="$2"
grep -Fq "$needle" "$file" || fail "missing '$needle' in $file"
}
assert_file "$MANIFEST"
assert_file "$MARKETPLACE"
assert_file "$APP_PROOF"
MANIFEST_VERSION="$(node -e 'const fs=require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).version)' "$MANIFEST")"
node - "$MANIFEST" "$MARKETPLACE" "$ROOT_DIR/VERSION" <<'NODE'
const fs = require("fs");
const [manifestPath, marketplacePath, versionPath] = process.argv.slice(2);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const marketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8"));
const version = fs.readFileSync(versionPath, "utf8").trim();
const plugin = marketplace.plugins.find((entry) => entry.name === "claude-code-harness");
function assert(cond, msg) {
if (!cond) {
console.error(msg);
process.exit(1);
}
}
assert(manifest.name === "claude-code-harness", "manifest name mismatch");
assert(manifest.version === version, "manifest version mismatch");
assert(manifest.skills === "../codex/.codex/skills/", "manifest skills path must target Codex mirror relative to .codex-plugin");
assert(manifest.hooks && !Array.isArray(manifest.hooks), "Codex manifest hooks must be an inline object override");
assert(manifest.hooks.hooks && Object.keys(manifest.hooks.hooks).length === 0, "Codex manifest must explicitly override Claude fallback hooks with an empty hook map");
assert(manifest.interface && manifest.interface.displayName === "Claude Code Harness", "missing interface displayName");
assert(Array.isArray(manifest.interface.defaultPrompt) && manifest.interface.defaultPrompt.length >= 2, "missing default prompts");
assert(String(manifest.interface.longDescription || "").includes("Codex CLI compatibility route"), "manifest must not imply app support");
assert(plugin && plugin.source === "./", "Claude marketplace source should remain repo root");
assert(plugin.version === manifest.version, "marketplace and Codex manifest versions must match");
NODE
assert_contains "$APP_PROOF" 'Codex app remains `candidate`'
assert_contains "$APP_PROOF" "does not prove Codex app behavior"
assert_contains "$APP_PROOF" "not_observed != absent"
assert_contains "$APP_PROOF" "not inferred from Codex CLI help output"
BUILD_SCRIPT="${ROOT_DIR}/scripts/build-host-plugin-dist.sh"
[ -f "$BUILD_SCRIPT" ] || fail "missing $BUILD_SCRIPT"
chmod +x "$BUILD_SCRIPT" 2>/dev/null || true
DIST_TMP="$(mktemp -d)"
trap 'rm -rf "$DIST_TMP"' EXIT
bash "$BUILD_SCRIPT" --host codex --out "$DIST_TMP/codex-dist"
node - "$DIST_TMP/codex-dist/.codex-plugin/plugin.json" <<'NODE'
const fs = require("fs");
const manifest = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
function assert(cond, msg) {
if (!cond) {
console.error(msg);
process.exit(1);
}
}
assert(manifest.skills === "./skills/", "generated codex dist must use ./skills/");
assert(manifest.hooks && !Array.isArray(manifest.hooks), "generated Codex manifest lost inline hooks override");
assert(manifest.hooks.hooks && Object.keys(manifest.hooks.hooks).length === 0, "generated Codex manifest must not fall back to hooks/hooks.json");
assert(manifest.interface.displayName === "Claude Code Harness for Codex", "generated codex displayName mismatch");
assert(JSON.stringify(manifest).includes("../") === false, "generated codex manifest must not contain ..");
NODE
[ -f "$DIST_TMP/codex-dist/skills/harness-plan/SKILL.md" ] \
|| fail "generated codex dist missing harness-plan skill"
if command -v codex >/dev/null 2>&1; then
TMP_HOME="$(mktemp -d)"
TMP_CODEX_HOME="$(mktemp -d)"
TMP_MARKETPLACE="$(mktemp -d)"
PROBE_PROJECT="$(mktemp -d)"
PROBE_PROJECT="$(cd "$PROBE_PROJECT" && pwd -P)"
trap 'rm -rf "$TMP_HOME" "$TMP_CODEX_HOME" "$TMP_MARKETPLACE" "$PROBE_PROJECT" "$DIST_TMP"' EXIT
mkdir -p "$DIST_TMP/codex-dist/hooks"
node - "$DIST_TMP/codex-dist/hooks/hooks.json" <<'NODE'
const fs = require("fs");
const outPath = process.argv[2];
const fixture = {
hooks: {
SessionStart: [{ hooks: [
{ type: "command", command: "true" },
{ type: "agent", prompt: "fixture agent hook must be ignored" },
{ type: "command", command: "true", async: true }
] }]
}
};
fs.writeFileSync(outPath, JSON.stringify(fixture, null, 2) + "\n");
NODE
mkdir -p "$TMP_MARKETPLACE/.claude-plugin"
cp -R "$DIST_TMP/codex-dist" "$TMP_MARKETPLACE/claude-code-harness"
node - "$DIST_TMP/codex-dist/.codex-plugin/plugin.json" "$TMP_MARKETPLACE/.claude-plugin/marketplace.json" <<'NODE'
const fs = require("fs");
const [manifestPath, outPath] = process.argv.slice(2);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const marketplace = {
name: "claude-code-harness-marketplace",
plugins: [
{
name: "claude-code-harness",
version: manifest.version,
source: "./claude-code-harness",
description: manifest.description
}
]
};
fs.writeFileSync(outPath, JSON.stringify(marketplace, null, 2) + "\n");
NODE
HOME="$TMP_HOME" CODEX_HOME="$TMP_CODEX_HOME" codex plugin marketplace add "$TMP_MARKETPLACE" >/tmp/codex-plugin-smoke.$$ 2>&1 \
|| { cat /tmp/codex-plugin-smoke.$$ >&2; fail "codex plugin marketplace add failed"; }
HOME="$TMP_HOME" CODEX_HOME="$TMP_CODEX_HOME" codex plugin list >/tmp/codex-plugin-list.$$ 2>&1 \
|| { cat /tmp/codex-plugin-list.$$ >&2; fail "codex plugin list failed"; }
grep -Fq "claude-code-harness@claude-code-harness-marketplace" /tmp/codex-plugin-list.$$ \
|| { cat /tmp/codex-plugin-list.$$ >&2; fail "Codex marketplace did not list Harness plugin"; }
HOME="$TMP_HOME" CODEX_HOME="$TMP_CODEX_HOME" codex plugin add claude-code-harness@claude-code-harness-marketplace >/tmp/codex-plugin-add.$$ 2>&1 \
|| { cat /tmp/codex-plugin-add.$$ >&2; fail "codex plugin add failed"; }
grep -Fq '[plugins."claude-code-harness@claude-code-harness-marketplace"]' "$TMP_CODEX_HOME/config.toml" \
|| fail "installed plugin not recorded in isolated CODEX_HOME config"
CACHE_ROOT="$TMP_CODEX_HOME/plugins/cache/claude-code-harness-marketplace/claude-code-harness/$MANIFEST_VERSION"
[ -f "$CACHE_ROOT/.codex-plugin/plugin.json" ] || fail "Codex plugin manifest was not cached"
[ -f "$CACHE_ROOT/skills/harness-plan/SKILL.md" ] || fail "Codex harness-plan skill was not cached in generated dist layout"
[ -f "$CACHE_ROOT/hooks/hooks.json" ] || fail "synthetic Claude fallback hooks fixture was not cached"
mkdir -p "$PROBE_PROJECT/.codex"
cp "$ROOT_DIR/go/cmd/harness/testdata/gen/codex-hooks.json" "$PROBE_PROJECT/.codex/hooks.json"
node - "$TMP_CODEX_HOME/config.toml" "$PROBE_PROJECT" <<'NODE'
const fs = require("fs");
const [configPath, projectPath] = process.argv.slice(2);
fs.appendFileSync(configPath, `\n[projects.${JSON.stringify(projectPath)}]\ntrust_level = "trusted"\n`);
NODE
HOOK_PROBE_JSON="$(
HOME="$TMP_HOME" CODEX_HOME="$TMP_CODEX_HOME" python3 - "$PROBE_PROJECT" <<'PY'
import json
import os
import select
import subprocess
import sys
import time
project = sys.argv[1]
process = subprocess.Popen(
["codex", "app-server"],
cwd=project,
env=os.environ.copy(),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
requests = [
{"id": 1, "method": "initialize", "params": {"clientInfo": {"name": "cch-hook-probe", "version": "0.0.1"}, "capabilities": {"experimentalApi": True}}},
{"method": "initialized", "params": {}},
{"id": 2, "method": "hooks/list", "params": {"cwds": [project]}},
]
for request in requests:
process.stdin.write(json.dumps(request) + "\n")
process.stdin.flush()
response = None
deadline = time.time() + 8
while time.time() < deadline:
ready, _, _ = select.select([process.stdout], [], [], 0.5)
if not ready:
continue
line = process.stdout.readline()
if not line:
break
item = json.loads(line)
if item.get("id") == 2:
response = item
break
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
if response is None:
raise SystemExit("Codex app-server hooks/list returned no response")
entry = response.get("result", {}).get("data", [{}])[0]
hooks = entry.get("hooks", [])
print(json.dumps({
"hook_count": len(hooks),
"hooks": [
{
"event_name": hook.get("eventName"),
"handler_type": hook.get("handlerType"),
"source": hook.get("source"),
"plugin_id": hook.get("pluginId"),
"source_path": hook.get("sourcePath"),
}
for hook in hooks
],
"warnings": entry.get("warnings", []),
"errors": entry.get("errors", []),
}))
PY
)" || fail "Codex app-server hook probe failed"
node - "$HOOK_PROBE_JSON" <<'NODE'
const probe = JSON.parse(process.argv[2]);
const hooks = probe.hooks || [];
const warnings = probe.warnings || [];
const forbidden = warnings.filter((warning) => /agent hooks are not supported|async hooks are not supported|failed to parse hooks config|floor_policy/i.test(String(warning)));
if (probe.hook_count !== 2) {
console.error(`expected exactly two generated Codex project command hooks, got ${probe.hook_count}: ${JSON.stringify(probe)}`);
process.exit(1);
}
const eventNames = hooks.map((hook) => hook.event_name).sort();
const projectCommandsOnly = hooks.every((hook) => hook.source === "project" && hook.plugin_id === null && hook.handler_type === "command");
if (!projectCommandsOnly || JSON.stringify(eventNames) !== JSON.stringify(["preToolUse", "stop"])) {
console.error(`expected only generated project command hooks, got: ${JSON.stringify(probe)}`);
process.exit(1);
}
if (forbidden.length > 0 || (probe.errors || []).length > 0) {
console.error(`Codex hook compatibility warnings/errors remain: ${JSON.stringify(probe)}`);
process.exit(1);
}
NODE
echo "test-codex-plugin-adapter: hooks/list loaded 2 project command hooks with no compatibility warnings"
rm -f /tmp/codex-plugin-smoke.$$ /tmp/codex-plugin-list.$$ /tmp/codex-plugin-add.$$
else
if [ "$SMOKE_REQUIRED" = "1" ]; then
fail "codex unavailable; runtime smoke is required"
fi
echo "test-codex-plugin-adapter: WARNING codex unavailable; static checks passed, runtime smoke skipped"
fi
echo "test-codex-plugin-adapter: ok"