Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,156 @@ jobs:
[ "$fail" -eq 0 ] || exit 1
echo "PASS: uninstaller stripped Dash0 entries and preserved the user-authored hook"

- name: "Contract G — Codex credential delivery reaches a real OTLP request (config file + env vars)"
Comment thread
bertschneider marked this conversation as resolved.
run: |
# Mirror of Contract D for the Codex side. Asserts that credentials
# supplied via config file or env vars both reach the wire through
# scripts/codex-on-event.sh. The bootstrap is version-pinned; build the
# binary at that exact path so no release download is needed.
export DASH0_PLUGIN_DATA=/tmp/codex-pdata
VERSION=$(grep '^VERSION=' scripts/codex-on-event.sh | sed 's/VERSION="//;s/"//')
mkdir -p "$DASH0_PLUGIN_DATA/bin"
make build-binary PKG=./cmd/codex-on-event OUT="$DASH0_PLUGIN_DATA/bin/codex-on-event-${VERSION}-linux-amd64"
make build-binary PKG=./test/e2e/mock-otlp-server OUT=/tmp/mock-otlp-codex
/tmp/mock-otlp-codex &
sleep 1

# G1 — credentials from ~/.codex/dash0-agent-plugin.local.md.
export HOME=/tmp/codex-home-cfg
mkdir -p "$HOME/.codex"
cat > "$HOME/.codex/dash0-agent-plugin.local.md" <<'MD'
---
otlp_url: "http://localhost:4319"
auth_token: "codex-cfg-token"
dataset: "codex-cfg-ds"
---
MD
# Clean cwd so the repo's own .codex/ can't shadow the global config
# (codex-on-event.sh checks the project file first).
( cd "$(mktemp -d)" \
&& echo '{"hook_event_name":"SessionStart","session_id":"contract-g1","model":"gpt-5.5","source":"startup"}' \
| bash "$GITHUB_WORKSPACE/scripts/codex-on-event.sh" )

# G2 — credentials from env vars only, no config file present.
export HOME=/tmp/codex-home-env
mkdir -p "$HOME/.codex"
( cd "$(mktemp -d)" \
&& echo '{"hook_event_name":"SessionStart","session_id":"contract-g2","model":"gpt-5.5","source":"startup"}' \
| DASH0_OTLP_URL=http://localhost:4319 \
CODEX_PLUGIN_OPTION_AUTH_TOKEN=codex-env-token \
DASH0_DATASET=codex-env-ds \
bash "$GITHUB_WORKSPACE/scripts/codex-on-event.sh" )

sleep 2
RESULT=$(curl -s http://localhost:4319/requests)
echo "::group::mock requests"; echo "$RESULT" | jq .; echo "::endgroup::"
fail=0
[ "$(echo "$RESULT" | jq '[.requests[]|select(.auth=="Bearer codex-cfg-token")]|length')" -ge 1 ] \
|| { echo "::error::codex config-file token did not reach the OTLP request"; fail=1; }
[ "$(echo "$RESULT" | jq '[.requests[]|select(.auth=="Bearer codex-env-token")]|length')" -ge 1 ] \
|| { echo "::error::codex env-var token did not reach the OTLP request"; fail=1; }
[ "$fail" -eq 0 ] || exit 1
echo "PASS: both config-file and env-var credentials flow through codex-on-event.sh to real OTLP requests"

- name: "Contract H — install-codex.sh merges hooks + pre-trust into config.toml, preserving user content"
run: |
# Codex has no release yet (M4), so pre-stage the version-pinned binary
# and bootstrap; install-codex.sh skips the download when they're
# present. This exercises the real merge: managed block appended,
# [hooks.state] trust entries written, and any user-authored hooks /
# settings preserved. DASH0_VERSION pins the path so no release lookup.
export HOME=/tmp/codex-installer-home
export XDG_STATE_HOME=/tmp/codex-installer-state
rm -rf "$HOME" "$XDG_STATE_HOME"
VERSION=$(grep '^VERSION=' scripts/codex-on-event.sh | sed 's/VERSION="//;s/"//')
STATE_BASE="$XDG_STATE_HOME/dash0-agent-plugin/codex"
mkdir -p "$STATE_BASE/bin" "$HOME/.codex"
make build-binary PKG=./cmd/codex-on-event OUT="$STATE_BASE/bin/codex-on-event-${VERSION}-linux-amd64"
cp scripts/codex-on-event.sh "$STATE_BASE/codex-on-event.sh"

make build-binary PKG=./test/e2e/mock-otlp-server OUT=/tmp/mock-otlp-codex-h
/tmp/mock-otlp-codex-h &
sleep 1

# Seed config.toml with an unrelated setting AND a user-authored hook the
# installer must preserve.
cat > "$HOME/.codex/config.toml" <<'TOML'
model = "gpt-5.5"

[[hooks.PreToolUse]]
matcher = "*"
[[hooks.PreToolUse.hooks]]
type = "command"
command = 'echo user-hook'
TOML

DASH0_VERSION="$VERSION" \
DASH0_OTLP_URL=http://localhost:4319 \
DASH0_AUTH_TOKEN=codex-install-token \
bash "$GITHUB_WORKSPACE/install-codex.sh" 2>&1 | tail -25

CONFIG_TOML="$HOME/.codex/config.toml"
echo "::group::resulting config.toml"; cat "$CONFIG_TOML"; echo "::endgroup::"
fail=0
[ -f "$HOME/.codex/dash0-agent-plugin.local.md" ] \
|| { echo "::error::installer did not write the config .local.md"; fail=1; }
grep -q ">>> dash0-agent-plugin (managed)" "$CONFIG_TOML" \
|| { echo "::error::managed block not appended to config.toml"; fail=1; }
trust_n=$(grep -c 'trusted_hash = "sha256:' "$CONFIG_TOML" || true)
[ "$trust_n" -eq 10 ] \
|| { echo "::error::expected 10 pre-trust entries, found $trust_n"; fail=1; }

# Structural assertions via a real TOML parser: valid TOML, user setting
# and user hook preserved, our hooks + 10 trust-state keys present.
python3 - "$CONFIG_TOML" <<'PY' || fail=1
import sys, tomllib
d = tomllib.load(open(sys.argv[1], "rb"))
assert d.get("model") == "gpt-5.5", "user setting lost"
pre = d["hooks"]["PreToolUse"]
cmds = [h["command"] for g in pre for h in g["hooks"]]
assert any(c == "echo user-hook" for c in cmds), "user hook lost"
assert any("codex-on-event.sh" in c for c in cmds), "dash0 PreToolUse hook missing"
assert len(d["hooks"]["state"]) == 10, f"expected 10 trust keys, got {len(d['hooks']['state'])}"
print("TOML OK: user content preserved, dash0 hooks + trust present")
PY

[ "$fail" -eq 0 ] || exit 1
echo "PASS: install-codex.sh merged hooks + pre-trust and preserved user config"

- name: "Contract I — uninstall-codex.sh strips the managed block, preserves user content"
run: |
# Assumes Contract H ran and left a merged config.toml in place.
export HOME=/tmp/codex-installer-home
export XDG_STATE_HOME=/tmp/codex-installer-state
CONFIG_TOML="$HOME/.codex/config.toml"
[ -f "$CONFIG_TOML" ] || { echo "::error::Contract H did not produce a config.toml"; exit 1; }

bash "$GITHUB_WORKSPACE/uninstall-codex.sh" --yes 2>&1 | tail -20

echo "::group::config.toml after uninstall"; cat "$CONFIG_TOML"; echo "::endgroup::"
fail=0
# config .local.md and state dir gone entirely.
for p in "$HOME/.codex/dash0-agent-plugin.local.md" "$XDG_STATE_HOME/dash0-agent-plugin/codex"; do
[ -e "$p" ] && { echo "::error::uninstaller left behind: $p"; fail=1; }
done
# config.toml still exists (user content), with NO managed block or trust.
grep -q ">>> dash0-agent-plugin (managed)" "$CONFIG_TOML" \
&& { echo "::error::managed block survived uninstall"; fail=1; }
grep -q 'codex-on-event.sh' "$CONFIG_TOML" \
&& { echo "::error::dash0 hook command survived uninstall"; fail=1; }
python3 - "$CONFIG_TOML" <<'PY' || fail=1
import sys, tomllib
d = tomllib.load(open(sys.argv[1], "rb"))
assert d.get("model") == "gpt-5.5", "user setting lost on uninstall"
cmds = [h["command"] for g in d["hooks"]["PreToolUse"] for h in g["hooks"]]
assert cmds == ["echo user-hook"], f"user hook not cleanly preserved: {cmds}"
assert "state" not in d.get("hooks", {}), "trust state survived uninstall"
print("TOML OK: user content intact, dash0 fully removed")
PY

[ "$fail" -eq 0 ] || exit 1
echo "PASS: uninstall-codex.sh stripped the managed block and preserved user config"

e2e:
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
Expand Down
44 changes: 28 additions & 16 deletions install-codex.sh
Original file line number Diff line number Diff line change
Expand Up @@ -139,26 +139,38 @@ BASE_URL="https://github.com/${REPO}/releases/download/v${VERSION}"
BIN_ASSET="codex-on-event-${OS}-${ARCH}"
RAW_BASE="https://raw.githubusercontent.com/${REPO}/v${VERSION}"

info "downloading codex-on-event v${VERSION}..."
fetch "$BASE_URL/$BIN_ASSET" "$BIN_PATH" || die "failed to download binary: $BASE_URL/$BIN_ASSET"
CHECKSUMS=$(fetch_stdout "$BASE_URL/checksums.txt" || true)
if [ -n "$CHECKSUMS" ]; then
EXPECTED=$(echo "$CHECKSUMS" | grep " ${BIN_ASSET}\$" | cut -d' ' -f1)
if [ -n "$EXPECTED" ]; then
ACTUAL=$(sha256 "$BIN_PATH")
if [ -n "$ACTUAL" ] && [ "$ACTUAL" != "$EXPECTED" ]; then
rm -f "$BIN_PATH"; die "checksum mismatch for $BIN_ASSET (expected $EXPECTED, got $ACTUAL)"
# The binary path is version-pinned, so an already-present binary is exactly
# this version — skip the download (idempotent re-install; also lets an offline
# or pre-staged binary work). A version bump changes BIN_PATH, forcing a fetch.
if [ -x "$BIN_PATH" ]; then
ok "binary already present → $BIN_PATH"
else
info "downloading codex-on-event v${VERSION}..."
fetch "$BASE_URL/$BIN_ASSET" "$BIN_PATH" || die "failed to download binary: $BASE_URL/$BIN_ASSET"
CHECKSUMS=$(fetch_stdout "$BASE_URL/checksums.txt" || true)
if [ -n "$CHECKSUMS" ]; then
EXPECTED=$(echo "$CHECKSUMS" | grep " ${BIN_ASSET}\$" | cut -d' ' -f1)
if [ -n "$EXPECTED" ]; then
ACTUAL=$(sha256 "$BIN_PATH")
if [ -n "$ACTUAL" ] && [ "$ACTUAL" != "$EXPECTED" ]; then
rm -f "$BIN_PATH"; die "checksum mismatch for $BIN_ASSET (expected $EXPECTED, got $ACTUAL)"
fi
fi
fi
chmod +x "$BIN_PATH"
ok "installed binary → $BIN_PATH"
fi
chmod +x "$BIN_PATH"
ok "installed binary → $BIN_PATH"

# 5b. Install the bootstrap script from the tagged ref.
info "downloading codex-on-event.sh..."
fetch "$RAW_BASE/scripts/codex-on-event.sh" "$SCRIPT_PATH" || die "failed to download: $RAW_BASE/scripts/codex-on-event.sh"
chmod +x "$SCRIPT_PATH"
ok "installed bootstrap → $SCRIPT_PATH"
# 5b. Install the bootstrap script from the tagged ref (skip if already present).
if [ -f "$SCRIPT_PATH" ]; then
chmod +x "$SCRIPT_PATH"
ok "bootstrap already present → $SCRIPT_PATH"
else
info "downloading codex-on-event.sh..."
fetch "$RAW_BASE/scripts/codex-on-event.sh" "$SCRIPT_PATH" || die "failed to download: $RAW_BASE/scripts/codex-on-event.sh"
chmod +x "$SCRIPT_PATH"
ok "installed bootstrap → $SCRIPT_PATH"
fi

# 6. Collect configuration (env var > interactive prompt > skip).
prompt_value() {
Expand Down
121 changes: 82 additions & 39 deletions test/e2e/codex_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,16 @@ func TestE2EFullFlowWithCodex(t *testing.T) {

pluginDir := findPluginDir(t)

// Hermetic Codex home so we never touch the developer's real ~/.codex.
codexHome := t.TempDir()
// Hermetic HOME + state so install-codex.sh writes to a throwaway ~/.codex and
// never touches the developer's real config.
home := t.TempDir()
state := t.TempDir()
codexHome := filepath.Join(home, ".codex")
require.NoError(t, os.MkdirAll(codexHome, 0o755))
if !authenticateCodex(t, codexBin, codexHome) {
t.Fatal("no Codex auth available — set OPENAI_API_KEY (CI: a service-account key) or run `codex login` (local)")
}

// Build the Codex entrypoint binary.
binary := filepath.Join(t.TempDir(), "codex-on-event")
build := exec.Command("go", "build", "-o", binary, "./cmd/codex-on-event")
build.Dir = pluginDir
out, err := build.CombinedOutput()
require.NoError(t, err, "build failed: %s", string(out))

// Mock OTLP server records every request body.
var (
mu sync.Mutex
Expand All @@ -83,24 +80,11 @@ func TestE2EFullFlowWithCodex(t *testing.T) {
}))
defer srv.Close()

pluginData := t.TempDir()

// Bootstrap wrapper: injects OTLP config into the environment and execs the
// binary. Codex runs this as the hook command and pipes the event on stdin.
wrapper := filepath.Join(t.TempDir(), "codex-on-event-wrapper.sh")
wrapperScript := fmt.Sprintf(`#!/usr/bin/env bash
export DASH0_OTLP_URL=%q
export CODEX_PLUGIN_OPTION_AUTH_TOKEN="e2e-codex-token"
export DASH0_PLUGIN_DATA=%q
export DASH0_OMIT_IO="false"
exec %q
`, srv.URL, pluginData, binary)
require.NoError(t, os.WriteFile(wrapper, []byte(wrapperScript), 0o755))

// Install hooks the customer way: register in config.toml AND pre-trust them
// via the installer's own emit path. The command written must match what we
// hash, so emit owns both. No bypass flag below — this is the real path.
writeCodexHooksTrusted(t, codexHome, binary, fmt.Sprintf("bash %q", wrapper))
// Install exactly as a customer would: run install-codex.sh, which appends the
// hooks + reproduced trust to ~/.codex/config.toml and writes the creds config.
// This drives the whole path in one test — installer → config.toml → live Codex
// → OTLP — so a break anywhere (config merge, trust hash, hook contract) fails here.
installCodex(t, pluginDir, home, state, srv.URL, "e2e-codex-token")

// Work in a throwaway git repo so the agent has somewhere to write.
workDir := t.TempDir()
Expand All @@ -109,16 +93,18 @@ exec %q
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()

// NOTE: deliberately NO --dangerously-bypass-hook-trust — the hooks above are
// pre-trusted, exactly as a customer install leaves them.
// NOTE: deliberately NO --dangerously-bypass-hook-trust — install-codex.sh
// pre-trusted the hooks, exactly as a real install leaves them.
cmd := exec.CommandContext(ctx, codexBin, "exec",
"-s", "workspace-write",
"-c", "approval_policy=\"never\"",
"-C", workDir,
"Create a file hello.txt containing exactly the text 'hi from codex', then run the shell command 'cat hello.txt'. Keep it brief.",
)
cmd.Env = append(os.Environ(), "CODEX_HOME="+codexHome)
out, err = cmd.CombinedOutput()
// The hook (bootstrap → binary) inherits this env: HOME locates the creds
// config, XDG_STATE_HOME the installed binary, CODEX_HOME the config.toml.
cmd.Env = append(os.Environ(), "HOME="+home, "XDG_STATE_HOME="+state, "CODEX_HOME="+codexHome)
out, err := cmd.CombinedOutput()
t.Logf("codex exec output (err=%v):\n%s", err, string(out))
require.NoError(t, err, "codex exec failed")

Expand Down Expand Up @@ -189,15 +175,72 @@ func spanHasPositiveTokenUsage(s otlp.Span) bool {
return false
}

// writeCodexHooksTrusted writes CODEX_HOME/config.toml using the installer's own
// emit path: hooks + reproduced [hooks.state] trusted_hash for the given command.
func writeCodexHooksTrusted(t *testing.T, codexHome, binary, command string) {
// installCodex runs install-codex.sh the customer way against a hermetic HOME +
// XDG_STATE_HOME, so it appends hooks + reproduced trust to $HOME/.codex/config.toml
// and writes the creds config. The version-pinned binary and the bootstrap are
// pre-staged so the script skips the release download (none exists until M4);
// everything else — the config.toml merge, trust emission, and creds file — is
// the real installer exercised end to end.
func installCodex(t *testing.T, pluginDir, home, state, otlpURL, token string) {
t.Helper()
ver := codexPluginVersion(t, pluginDir)
goos, arch := unameOSArch(t)

codexState := filepath.Join(state, "dash0-agent-plugin", "codex")
require.NoError(t, os.MkdirAll(filepath.Join(codexState, "bin"), 0o755))
binPath := filepath.Join(codexState, "bin", fmt.Sprintf("codex-on-event-%s-%s-%s", ver, goos, arch))
build := exec.Command("go", "build", "-o", binPath, "./cmd/codex-on-event")
build.Dir = pluginDir
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build failed: %s", string(out))
}
bootstrap, err := os.ReadFile(filepath.Join(pluginDir, "scripts", "codex-on-event.sh"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(codexState, "codex-on-event.sh"), bootstrap, 0o755))

cmd := exec.Command("bash", filepath.Join(pluginDir, "install-codex.sh"))
cmd.Env = append(os.Environ(),
"HOME="+home, "XDG_STATE_HOME="+state,
"DASH0_VERSION="+ver, "DASH0_OTLP_URL="+otlpURL,
"DASH0_AUTH_TOKEN="+token, "DASH0_DATASET=default",
)
out, err := cmd.CombinedOutput()
t.Logf("install-codex.sh output:\n%s", string(out))
require.NoError(t, err, "install-codex.sh failed")
}

// codexPluginVersion reads the pinned VERSION from scripts/codex-on-event.sh so
// the pre-staged binary path matches what install-codex.sh derives.
func codexPluginVersion(t *testing.T, pluginDir string) string {
t.Helper()
configPath := filepath.Join(codexHome, "config.toml")
cmd := exec.Command(binary, "emit-codex-hooks", "--config", configPath, "--command", command)
block, err := cmd.CombinedOutput()
require.NoError(t, err, "emit-codex-hooks failed: %s", string(block))
require.NoError(t, os.WriteFile(configPath, block, 0o644))
data, err := os.ReadFile(filepath.Join(pluginDir, "scripts", "codex-on-event.sh"))
require.NoError(t, err)
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "VERSION=") {
return strings.Trim(strings.TrimPrefix(line, "VERSION="), `"`)
}
}
t.Fatal("VERSION= not found in scripts/codex-on-event.sh")
return ""
}

// unameOSArch mirrors install-codex.sh's platform detection so the pre-staged
// binary name matches exactly.
func unameOSArch(t *testing.T) (string, string) {
t.Helper()
osOut, err := exec.Command("uname", "-s").Output()
require.NoError(t, err)
archOut, err := exec.Command("uname", "-m").Output()
require.NoError(t, err)
goos := strings.ToLower(strings.TrimSpace(string(osOut)))
arch := strings.TrimSpace(string(archOut))
switch arch {
case "x86_64":
arch = "amd64"
case "aarch64", "arm64":
arch = "arm64"
}
return goos, arch
}

// authenticateCodex sets up auth inside a hermetic CODEX_HOME. Returns false
Expand Down
Loading