From ad5ec7a863bde316a571dda7fd9adfefb6791ff1 Mon Sep 17 00:00:00 2001 From: kappaseijin4codex Date: Sun, 13 Sep 2026 17:12:42 +0900 Subject: [PATCH] fix(g4): judge N1 argv only after the launcher has exec'ed claude (#448) N1 sampled the launcher PID's command line once, right after the binding. The launcher still runs its claim, roster and digest checks after publishing the binding, so that sample could be the launcher itself, which never carries --session-id and was judged a fail. Implements docs/decisions/2026-09-13T164649_issue-448-n1-exec-observation.md: - isolation observe-n1-exec polls ps every 0.2 s for up to 10 s from the binding observation and classifies launcher / claude / unrecognized / absent (an unreaped launcher is absent). The existing argv rule, now process_argv_matches, runs only on claude: match is pass, mismatch is the only fail; exec_not_observed, launcher_exited_before_exec and process_identity_unrecognized are unknown. - exec-observation.json records elapsed seconds, class transitions and the binding mtime. When claude is not reached, launcher-stat.txt, process-command.raw and launcher-output-tail.txt (pty.raw tail) are kept, and exec_not_observed adds a 5 s late observation that never changes the verdict. - runner: N1_EXEC_TIMEOUT_SECONDS=10, N1_EXEC_POLL_SECONDS=0.2; the one-shot record_process_command is removed; the case reason is carried into the N1 result. Tests N1E-01..08 (bats) and classification unit tests. Refs #448 #434 #407 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Gd6MbRAYGpkDzHAk1XZroZ --- scripts/lib/pilot-gate-isolation.py | 302 +++++++++++++++++++++++++++- scripts/pilot-gate-runner.sh | 92 ++++----- tests/test_pilot_gate_isolation.py | 53 +++++ tests/test_pilot_gate_runner.bats | 190 ++++++++++++++++- 4 files changed, 580 insertions(+), 57 deletions(-) diff --git a/scripts/lib/pilot-gate-isolation.py b/scripts/lib/pilot-gate-isolation.py index ad3690727..4b8ce8f2b 100755 --- a/scripts/lib/pilot-gate-isolation.py +++ b/scripts/lib/pilot-gate-isolation.py @@ -3125,8 +3125,28 @@ def command_validate_process_command( if not argv: return 2 + return process_argv_matches( + argv, + mode=args.mode, + session_id=args.session_id, + settings=args.settings, + ) + + +def process_argv_matches( + argv: list[str], + *, + mode: str, + session_id: str, + settings: str, +) -> int: + """The N1 argv rule: 0 matches, 1 does not match, 2 cannot judge. + + Shared by validate-process-command and observe-n1-exec (#448), which + applies it only to a process already classified as claude. + """ settings_real = canonical( - args.settings + settings ) expected_settings_seen = False @@ -3151,13 +3171,13 @@ def command_validate_process_command( expected_settings_seen = True break - if args.mode == "fresh": + if mode == "fresh": session_seen = ( find_option_value( argv, "--session-id", ) - == args.session_id + == session_id ) resume_seen = ( @@ -3171,13 +3191,13 @@ def command_validate_process_command( and expected_settings_seen ) - elif args.mode == "resume": + elif mode == "resume": session_seen = ( find_option_value( argv, "--resume", ) - == args.session_id + == session_id ) fresh_seen = ( @@ -4002,6 +4022,277 @@ def add_n1_input_precondition_commands(subparsers: Any) -> None: command.set_defaults(handler=command_wait_n1_ready) +# --- #448: observe the launcher's exec into claude --------------------------- + + +def _real(path: str) -> str | None: + try: + return os.path.realpath(path) + except (OSError, ValueError): + return None + + +def read_process_command(pid: int) -> str | None: + try: + result = subprocess.run( + ["ps", "-ww", "-p", str(pid), "-o", "command="], + capture_output=True, + text=True, + errors="replace", + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return result.stdout.strip() + + +def read_process_stat(pid: int) -> str: + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "stat="], + capture_output=True, + text=True, + errors="replace", + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return "absent" + value = result.stdout.strip() + return value if result.returncode == 0 and value else "absent" + + +def pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def classify_n1_process( + command: str | None, + alive: bool, + *, + launcher: str, + claude_bin: str, + claude_bin_canonical: str, +) -> tuple[str, list[str] | None]: + """Classify one observation of the launcher PID (#448 section 3).""" + if command is None: + return ("unrecognized" if alive else "absent"), None + if not command: + return "unrecognized", None + try: + argv = shlex.split(command, posix=True) + except ValueError: + return "unrecognized", None + if not argv: + return "unrecognized", None + + launcher_real = _real(launcher) + for token in argv[:2]: + if launcher_real is not None and _real(token) == launcher_real: + return "launcher", argv + + if argv[0] == claude_bin or _real(argv[0]) == claude_bin_canonical: + return "claude", argv + return "unrecognized", argv + + +def observe_n1_process( + pid: int, + *, + launcher: str, + claude_bin: str, + claude_bin_canonical: str, +) -> tuple[str, list[str] | None, str | None]: + """One observation: class, argv, raw command line.""" + command = read_process_command(pid) + alive = pid_alive(pid) if command is None else True + cls, argv = classify_n1_process( + command, + alive, + launcher=launcher, + claude_bin=claude_bin, + claude_bin_canonical=claude_bin_canonical, + ) + if cls == "unrecognized" and read_process_stat(pid).startswith("Z"): + # An exited launcher its parent has not reaped yet (""). + return "absent", None, None + return cls, argv, command + + +def command_observe_n1_exec( + args: argparse.Namespace, +) -> int: + """Watch the launcher PID until it has exec'ed into claude (#448). + + pass (0): claude with the expected args. fail (1): claude with other + args, the only fail. unknown (2): never reached claude, the PID exited, + or an unrecognised process. The launcher's own command line is never + put through the args rule. + """ + import time + + case_dir = pathlib.Path(args.case_dir) + started = time.monotonic() + deadline = started + args.timeout + transitions: list[dict[str, Any]] = [] + last_class: str | None = None + last_command: str | None = None + poll_count = 0 + elapsed_to_claude: float | None = None + + try: + binding_mtime = dt.datetime.fromtimestamp( + os.stat(args.binding).st_mtime, dt.timezone.utc + ).astimezone().isoformat(timespec="milliseconds") + except OSError: + binding_mtime = None + + def observe() -> tuple[str, list[str] | None, float]: + nonlocal last_class, last_command, poll_count + cls, argv, command = observe_n1_process( + args.pid, + launcher=args.launcher, + claude_bin=args.claude_bin, + claude_bin_canonical=args.claude_bin_canonical, + ) + elapsed = round(time.monotonic() - started, 3) + poll_count += 1 + if command: + last_command = command + if cls != last_class: + transitions.append({"elapsedSeconds": elapsed, "class": cls}) + last_class = cls + return cls, argv, elapsed + + def record(result: str) -> None: + write_json( + case_dir / "exec-observation.json", + { + "schemaVersion": 1, + "result": result, + "timeoutSeconds": args.timeout, + "pollIntervalSeconds": args.poll, + "bindingObservedElapsedFromLauncherStart": args.binding_observed_elapsed, + "bindingFileMtime": binding_mtime, + "elapsedSecondsToClaude": elapsed_to_claude, + "pollCount": poll_count, + "transitions": transitions, + }, + ) + (case_dir / "process-command.raw").write_text( + (last_command or "") + "\n", encoding="utf-8" + ) + + def record_unreached() -> None: + (case_dir / "launcher-stat.txt").write_text( + read_process_stat(args.pid) + "\n", encoding="utf-8" + ) + final = read_process_command(args.pid) + if final: + (case_dir / "process-command.raw").write_text(final + "\n", encoding="utf-8") + try: + with open(args.pty_raw, "rb") as fh: + fh.seek(0, os.SEEK_END) + size = fh.tell() + fh.seek(max(0, size - 4096)) + tail = fh.read().decode("utf-8", errors="replace") + except OSError: + tail = "" + (case_dir / "launcher-output-tail.txt").write_text( + strip_terminal_escapes(tail), encoding="utf-8" + ) + + result: str + while True: + cls, argv, elapsed = observe() + if cls == "claude": + elapsed_to_claude = elapsed + status = process_argv_matches( + argv or [], + mode=args.mode, + session_id=args.session_id, + settings=args.settings, + ) + if status == 0: + record("claude_matched") + return 0 + if status == 1: + record("claude_args_mismatch") + return 1 + result = "process_identity_unrecognized" + break + if cls == "absent": + result = "launcher_exited_before_exec" + break + if cls == "unrecognized": + result = "process_identity_unrecognized" + break + if time.monotonic() >= deadline: + result = "exec_not_observed" + break + time.sleep(args.poll) + + record(result) + record_unreached() + + if result == "exec_not_observed": + # Section 6.3: keep watching without changing the verdict, to tell a + # late exec from a launcher that never gets there. + late_started = time.monotonic() + late_deadline = late_started + args.late_window + late_class = last_class + late_elapsed: float | None = None + while time.monotonic() < late_deadline: + time.sleep(args.poll) + late_class, _, _ = observe_n1_process( + args.pid, + launcher=args.launcher, + claude_bin=args.claude_bin, + claude_bin_canonical=args.claude_bin_canonical, + ) + if late_class != "launcher": + if late_class == "claude": + late_elapsed = round(time.monotonic() - started, 3) + break + write_json( + case_dir / "exec-late-observation.json", + { + "lateWindowSeconds": args.late_window, + "lateClass": late_class, + "lateElapsedSecondsToClaude": late_elapsed, + }, + ) + return 2 + + +def add_n1_exec_observation_command(subparsers: Any) -> None: + command = subparsers.add_parser("observe-n1-exec") + command.add_argument("--pid", required=True, type=int) + command.add_argument("--launcher", required=True) + command.add_argument("--claude-bin", required=True) + command.add_argument("--claude-bin-canonical", required=True) + command.add_argument("--mode", required=True, choices=("fresh", "resume")) + command.add_argument("--session-id", required=True) + command.add_argument("--settings", required=True) + command.add_argument("--binding", required=True) + command.add_argument("--binding-observed-elapsed", required=True, type=float) + command.add_argument("--pty-raw", required=True) + command.add_argument("--case-dir", required=True) + command.add_argument("--timeout", required=True, type=float) + command.add_argument("--poll", required=True, type=float) + command.add_argument("--late-window", required=True, type=float) + command.set_defaults(handler=command_observe_n1_exec) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( @@ -4663,6 +4954,7 @@ def build_parser() -> argparse.ArgumentParser: ) add_n1_input_precondition_commands(subparsers) + add_n1_exec_observation_command(subparsers) return parser diff --git a/scripts/pilot-gate-runner.sh b/scripts/pilot-gate-runner.sh index 1a38e4b17..40ff4207f 100755 --- a/scripts/pilot-gate-runner.sh +++ b/scripts/pilot-gate-runner.sh @@ -44,6 +44,11 @@ readonly DEFAULT_COLLECTOR_CUTOFF_SECONDS=180 readonly N1_START_TIMEOUT_SECONDS=30 readonly N1_TRANSCRIPT_TIMEOUT_SECONDS=20 readonly N1_EXIT_GRACE_SECONDS=5 +# #448: after the binding is observed, how long the launcher PID is watched +# for its exec into claude, and how often. A separate budget from +# N1_START_TIMEOUT_SECONDS so an unobserved exec means only that. +readonly N1_EXEC_TIMEOUT_SECONDS=10 +readonly N1_EXEC_POLL_SECONDS=0.2 # #444 section 5: how long to wait for the native prompt input, and the CLI # versions whose ready/blocking screen texts the verifier has observed. Add a # version only after the verifier has measured it. @@ -1032,20 +1037,6 @@ terminate_native_process() { return 0 } -record_process_command() { - local pid="$1" - local output="$2" - - if ps -ww -p "$pid" -o command= \ - > "$output" 2>/dev/null - then - return 0 - fi - - : > "$output" - return 1 -} - # Wait for the pty helper to report the launcher's PID. Fails if the helper # exits first or nothing valid appears in time. wait_for_launcher_pid() { @@ -1132,13 +1123,19 @@ wait_for_n1_ready() { # The reason recorded by a precondition helper, or the given fallback. n1_record_reason() { + n1_record_reason_field "$1" reason "$2" +} + +# A string field of a helper's JSON record, or the given fallback. +n1_record_reason_field() { local record="$1" - local fallback="$2" + local field="$2" + local fallback="$3" local reason reason="$( - python3 -c 'import json,sys; r=json.load(open(sys.argv[1])).get("reason"); print(r if isinstance(r, str) and r else "")' \ - "$record" 2>/dev/null + python3 -c 'import json,sys; r=json.load(open(sys.argv[1])).get(sys.argv[2]); print(r if isinstance(r, str) and r else "")' \ + "$record" "$field" 2>/dev/null )" || reason="" printf '%s\n' "${reason:-$fallback}" @@ -1159,6 +1156,8 @@ launch_n1_case() { local validation_status local control_socket local prompt + local launch_started_seconds + local binding_observed_elapsed CASE_BINDING="" CASE_SESSION="" @@ -1220,6 +1219,7 @@ launch_n1_case() { 2> "$case_dir/stderr.raw" 3>&- 4>&- & pty_helper_pid="$!" + launch_started_seconds="$SECONDS" if ! launcher_pid="$( wait_for_launcher_pid \ @@ -1303,46 +1303,38 @@ launch_n1_case() { esac fi - # The immutable binding is written immediately before launcher exec. - # Therefore a binding without a still-live process is insufficient proof that - # the native Claude process actually started. - if ! _agmsg_pid_alive_local "$launcher_pid"; then - stop_native_case "" "$pty_helper_pid" - CURRENT_NATIVE_PID="" - - printf '%s\n' "unknown" > "$case_dir/verdict" - CASE_STATUS="$EX_GATE_UNKNOWN" - return "$EX_GATE_UNKNOWN" - fi - - process_command="$case_dir/process-command.raw" - - if ! record_process_command \ - "$launcher_pid" \ - "$process_command" - then - stop_native_case "$launcher_pid" "$pty_helper_pid" - CURRENT_NATIVE_PID="" - - printf '%s\n' "unknown" > "$case_dir/verdict" - CASE_STATUS="$EX_GATE_UNKNOWN" - return "$EX_GATE_UNKNOWN" - fi - - CASE_PROCESS_COMMAND="$( - cat "$process_command" - )" - + # The launcher publishes the binding and then still checks its claim, the + # roster and the profile/guard/broker digests (several node starts) before + # it execs claude (#448). The launcher PID may therefore still be the + # launcher here. Watch it until it has become claude, and only then judge + # the argv; the launcher's own command line is never an args mismatch. + binding_observed_elapsed="$((SECONDS - launch_started_seconds))" validation_status=0 - python3 "$ISOLATION_HELPER" validate-process-command \ - --command-file "$process_command" \ + python3 "$ISOLATION_HELPER" observe-n1-exec \ + --pid "$launcher_pid" \ + --launcher "$GATE_REPO/scripts/pilot-launcher.sh" \ + --claude-bin "$CLAUDE_BIN" \ + --claude-bin-canonical "$CLAUDE_BIN_CANONICAL" \ --mode "$mode" \ --session-id "$session_id" \ - --settings "$GATE_REPO/.claude/settings.local.json" || validation_status="$?" + --settings "$GATE_REPO/.claude/settings.local.json" \ + --binding "$binding" \ + --binding-observed-elapsed "$binding_observed_elapsed" \ + --pty-raw "$case_dir/pty.raw" \ + --case-dir "$case_dir" \ + --timeout "$N1_EXEC_TIMEOUT_SECONDS" \ + --poll "$N1_EXEC_POLL_SECONDS" \ + --late-window "$N1_EXIT_GRACE_SECONDS" || validation_status="$?" + + process_command="$case_dir/process-command.raw" + CASE_PROCESS_COMMAND="$(cat "$process_command" 2>/dev/null || true)" if [ "$validation_status" -ne 0 ]; then + CASE_REASON="$(n1_record_reason_field "$case_dir/exec-observation.json" result process_identity_unrecognized)" + log "N1/$mode: exec into claude not confirmed: $CASE_REASON" stop_native_case "$launcher_pid" "$pty_helper_pid" CURRENT_NATIVE_PID="" + printf '%s\n' "$CASE_REASON" > "$case_dir/reason" case "$validation_status" in 1) diff --git a/tests/test_pilot_gate_isolation.py b/tests/test_pilot_gate_isolation.py index d39048a20..28903d72a 100644 --- a/tests/test_pilot_gate_isolation.py +++ b/tests/test_pilot_gate_isolation.py @@ -13,6 +13,7 @@ import subprocess import sys import tempfile +import time import unittest from unittest import mock @@ -7629,3 +7630,55 @@ def test_n1p14_capture_environment_records_no_credential_value(self): json.loads(text)["claudeAuthEnvironmentPresent"], {key: True for key in ISOLATION.CLAUDE_AUTH_ENV_KEYS}, ) + + +class PilotGateIsolationN1ExecClassificationTests(unittest.TestCase): + """#448 section 3: classification details the runner fixtures cannot pin.""" + + def test_an_exited_unreaped_launcher_is_absent_not_unrecognized(self): + # ps shows an exited child its parent has not waited for as + # ""; that is a launcher that is gone. + child = subprocess.Popen([sys.executable, "-c", "pass"]) + try: + deadline = time.monotonic() + 10 + while ISOLATION.read_process_stat(child.pid)[:1] != "Z": + self.assertLess(time.monotonic(), deadline, "child never became a zombie") + time.sleep(0.05) + cls, argv, _ = ISOLATION.observe_n1_process( + child.pid, + launcher="/nonexistent/pilot-launcher.sh", + claude_bin="/nonexistent/claude", + claude_bin_canonical="/nonexistent/claude", + ) + self.assertEqual((cls, argv), ("absent", None)) + finally: + child.wait() + + def test_the_launcher_is_recognised_as_interpreter_argument_or_directly(self): + with tempfile.TemporaryDirectory() as temp: + launcher = Path(os.path.realpath(temp)) / "pilot-launcher.sh" + launcher.write_text("", encoding="utf-8") + for command in (f"bash {launcher} --fresh", f"{launcher} --fresh"): + with self.subTest(command=command): + cls, _ = ISOLATION.classify_n1_process( + command, True, + launcher=str(launcher), + claude_bin="/nonexistent/claude", + claude_bin_canonical="/nonexistent/claude", + ) + self.assertEqual(cls, "launcher") + for command, alive, expected in ( + (None, False, "absent"), + (None, True, "unrecognized"), + ("", True, "unrecognized"), + ("unterminated 'quote", True, "unrecognized"), + ("sleep 60", True, "unrecognized"), + ): + with self.subTest(command=command, alive=alive): + cls, _ = ISOLATION.classify_n1_process( + command, alive, + launcher=str(launcher), + claude_bin="/nonexistent/claude", + claude_bin_canonical="/nonexistent/claude", + ) + self.assertEqual(cls, expected) diff --git a/tests/test_pilot_gate_runner.bats b/tests/test_pilot_gate_runner.bats index e5013cca7..d90397ded 100644 --- a/tests/test_pilot_gate_runner.bats +++ b/tests/test_pilot_gate_runner.bats @@ -1472,7 +1472,6 @@ prepare_n1_marker_case() { wait_for_n1_ready() { :; } wait_for_binding() { sleep 1; printf '%s\n' "$TEST_ROOT/binding.json"; } json_field() { printf '%s\n' "123e4567-e89b-42d3-a456-426614174000"; } - record_process_command() { printf '%s\n' "claude" > "$2"; } wait_for_transcript() { printf '%s|%s\n' "$1" "$2" >> "$TEST_ROOT/transcript-calls" return 1 @@ -1623,7 +1622,6 @@ EOF wait_for_binding() { sleep 1; printf '%s\n' "$TEST_ROOT/binding.json"; } json_field() { printf '%s\n' "123e4567-e89b-42d3-a456-426614174000"; } - record_process_command() { printf '%s\n' "claude" > "$2"; } wait_for_transcript() { printf '%s\n' "$2" >> "$TEST_ROOT/transcript-calls"; return 1; } } @@ -1778,3 +1776,191 @@ data = json.load(open(sys.argv[1])) assert data["projects"][sys.argv[2]]["hasTrustDialogAccepted"] is True, data ' "$GATE_CLAUDE_CONFIG/.claude.json" "$GATE_REPO" } + +# --- #448: N1 observes the launcher's exec into claude ----------------------- + +N1E_SESSION="123e4567-e89b-42d3-a456-426614174000" + +# A real launcher PID whose command line changes the way the native one does: +# `bash .../pilot-launcher.sh` first, then (optionally) an exec whose argv[0] +# is the configured claude path. The stand-in keeps running as /bin/sh so the +# PID stays alive with that argv. +prepare_n1_exec_case() { + local behaviour="$1" + + prepare_n1_precondition_case ready + CLAUDE_BIN="$RUN_ROOT/bin/claude" + mkdir -p "$RUN_ROOT/bin" "$GATE_REPO/.claude" + : > "$RUN_ROOT/bin/claude-real" + chmod +x "$RUN_ROOT/bin/claude-real" + ln -s "$RUN_ROOT/bin/claude-real" "$CLAUDE_BIN" + CLAUDE_BIN_CANONICAL="$RUN_ROOT/bin/claude-real" + printf '%s\n' '{}' > "$GATE_REPO/.claude/settings.local.json" + printf '%s\n' '{}' > "$RUN_ROOT/other-settings.json" + + printf '%s\n' "$behaviour" > "$GATE_REPO/exec-behaviour" + printf '%s\n' "$CLAUDE_BIN" > "$GATE_REPO/exec-argv0" + printf '%s\n' "$N1E_SESSION" > "$GATE_REPO/exec-session" + + cat > "$GATE_REPO/scripts/pilot-launcher.sh" <<'EOF' +#!/usr/bin/env bash +repo="$(cd "$(dirname "$0")/.." && pwd)" +argv0="$(cat "$repo/exec-argv0")" +session="$(cat "$repo/exec-session")" +settings="$repo/.claude/settings.local.json" +become_claude() { + exec -a "$argv0" /bin/sh -c 'sleep 60; :' sh "$@" +} +read -r behaviour delay < "$repo/exec-behaviour" +case "$behaviour" in + exec-after) sleep "$delay"; become_claude --session-id "$session" --settings "$settings" ;; + stay) printf '%s\n' "pilot-launcher: still before exec"; sleep 60 ;; + exit-after) sleep "$delay"; printf '%s\n' "pilot-launcher: roster preflight failed"; exit 3 ;; + unrecognized) exec sleep 60 ;; + other-settings) become_claude --session-id "$session" --settings "$repo/../other-settings.json" ;; + fresh-args) become_claude --session-id "$session" --settings "$settings" ;; + canonical-argv0) argv0="$(cd "$(dirname "$argv0")" && pwd -P)/claude-real"; become_claude --session-id "$session" --settings "$settings" ;; +esac +EOF + chmod +x "$GATE_REPO/scripts/pilot-launcher.sh" + + # The real helper also observes the exec; other isolation checks accept. + local real_isolation="$SCRIPTS/lib/pilot-gate-isolation.py" + cat > "$ISOLATION_HELPER" <> "$TEST_ROOT/ready-calls"; return 1; } +} + +n1e_field() { + python3 -c 'import json,sys; v=json.load(open(sys.argv[1])) +for k in sys.argv[2].split("."): v=v[int(k)] if isinstance(v, list) else v[k] +print(json.dumps(v) if not isinstance(v, str) else v)' "$1" "$2" +} + +@test "#448 N1E-01: a launcher that execs claude a little after the binding passes the exec check" { + # The binding stub takes about a second, so exec at 3 s is observed as a + # launcher first and as claude about 2 s later. + prepare_n1_exec_case "exec-after 3" + + local case_status=0 + launch_n1_case fresh 1 || case_status="$?" + + local record="$ARTIFACT_DIR/N1/fresh/exec-observation.json" + [ "$(n1e_field "$record" result)" = "claude_matched" ] || + { cat "$record" "$ARTIFACT_DIR/N1/fresh/stderr.raw" >&2; return 1; } + [ "$(n1e_field "$record" transitions.0.class)" = "launcher" ] + [ "$(n1e_field "$record" transitions.1.class)" = "claude" ] + python3 -c 'import json,sys; e=json.load(open(sys.argv[1]))["elapsedSecondsToClaude"]; assert 0.8 <= e <= 5, e' "$record" + # Passed the exec check: the case went on to the readiness wait. + [ "$(n1p_count "$TEST_ROOT/ready-calls")" = "1" ] +} + +@test "#448 N1E-02: a launcher that never execs is unknown exec_not_observed, never fail" { + prepare_n1_exec_case stay + + local case_status=0 + launch_n1_case fresh 1 || case_status="$?" + + local case_dir="$ARTIFACT_DIR/N1/fresh" + [ "$case_status" -eq "$EX_GATE_UNKNOWN" ] || + { echo "status $case_status: $(cat "$case_dir/verdict")" >&2; cat "$case_dir/exec-observation.json" >&2; return 1; } + [ "$(cat "$case_dir/verdict")" = "unknown" ] + [ "$(cat "$case_dir/reason")" = "exec_not_observed" ] + [ -s "$case_dir/launcher-stat.txt" ] + grep -qF "still before exec" "$case_dir/launcher-output-tail.txt" + grep -qF "pilot-launcher.sh" "$case_dir/process-command.raw" + [ "$(n1e_field "$case_dir/exec-late-observation.json" lateClass)" = "launcher" ] + [ "$(n1p_count "$TEST_ROOT/ready-calls")" = "0" ] +} + +@test "#448 N1E-03: claude with a different --settings is fail claude_args_mismatch" { + prepare_n1_exec_case other-settings + + local case_status=0 + launch_n1_case fresh 1 || case_status="$?" + + local case_dir="$ARTIFACT_DIR/N1/fresh" + [ "$case_status" -eq "$EX_GATE_FAIL" ] || + { echo "status $case_status" >&2; cat "$case_dir/exec-observation.json" >&2; return 1; } + [ "$(cat "$case_dir/verdict")" = "fail" ] + [ "$(cat "$case_dir/reason")" = "claude_args_mismatch" ] +} + +@test "#448 N1E-04: a launcher that exits before exec is unknown launcher_exited_before_exec" { + prepare_n1_exec_case "exit-after 1" + + local case_status=0 + launch_n1_case fresh 1 || case_status="$?" + + local case_dir="$ARTIFACT_DIR/N1/fresh" + [ "$case_status" -eq "$EX_GATE_UNKNOWN" ] + [ "$(cat "$case_dir/reason")" = "launcher_exited_before_exec" ] || + { cat "$case_dir/exec-observation.json" >&2; return 1; } + grep -qF "pilot-launcher: roster preflight failed" "$case_dir/launcher-output-tail.txt" + [ "$(cat "$case_dir/launcher-stat.txt")" = "absent" ] +} + +@test "#448 N1E-05: an unrecognised process is unknown at once, without waiting for the timeout" { + prepare_n1_exec_case unrecognized + + local started="$SECONDS" + local case_status=0 + launch_n1_case fresh 1 || case_status="$?" + + local case_dir="$ARTIFACT_DIR/N1/fresh" + [ "$case_status" -eq "$EX_GATE_UNKNOWN" ] + [ "$(cat "$case_dir/reason")" = "process_identity_unrecognized" ] || + { cat "$case_dir/exec-observation.json" >&2; return 1; } + python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); assert r["pollCount"] <= 3, r' "$case_dir/exec-observation.json" + [ ! -e "$case_dir/exec-late-observation.json" ] +} + +@test "#448 N1E-06: an exec after the timeout stays unknown; the late observation records claude" { + # Observation starts about 1 s after launch and ends 10 s later; exec at + # 13 s lands inside the 5 s late window. + prepare_n1_exec_case "exec-after 13" + + local case_status=0 + launch_n1_case fresh 1 || case_status="$?" + + local case_dir="$ARTIFACT_DIR/N1/fresh" + [ "$case_status" -eq "$EX_GATE_UNKNOWN" ] + [ "$(cat "$case_dir/reason")" = "exec_not_observed" ] + [ "$(n1e_field "$case_dir/exec-late-observation.json" lateClass)" = "claude" ] || + { cat "$case_dir/exec-late-observation.json" >&2; return 1; } + [ "$(n1e_field "$case_dir/exec-observation.json" elapsedSecondsToClaude)" = "null" ] + [ "$(n1p_count "$TEST_ROOT/ready-calls")" = "0" ] +} + +@test "#448 N1E-07: claude with fresh args in the resume case is fail claude_args_mismatch" { + prepare_n1_exec_case fresh-args + + local case_status=0 + launch_n1_case resume 2 "$N1E_SESSION" || case_status="$?" + + local case_dir="$ARTIFACT_DIR/N1/resume" + [ "$case_status" -eq "$EX_GATE_FAIL" ] || + { cat "$case_dir/exec-observation.json" >&2; return 1; } + [ "$(cat "$case_dir/reason")" = "claude_args_mismatch" ] +} + +@test "#448 N1E-08: a symlinked CLAUDE_BIN is recognised by its path and by its canonical target" { + local behaviour + for behaviour in "exec-after 0" canonical-argv0; do + prepare_n1_exec_case "$behaviour" + rm -f "$TEST_ROOT/ready-calls" + + local case_status=0 + launch_n1_case fresh 1 || case_status="$?" + + local record="$ARTIFACT_DIR/N1/fresh/exec-observation.json" + [ "$(n1e_field "$record" result)" = "claude_matched" ] || + { echo "$behaviour" >&2; cat "$record" "$ARTIFACT_DIR/N1/fresh/process-command.raw" >&2; return 1; } + rm -rf "$RUN_ROOT" + done +}