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 +}