diff --git a/bin/chatgpt_oracle_diagnose.py b/bin/chatgpt_oracle_diagnose.py index 97493010..4940db76 100644 --- a/bin/chatgpt_oracle_diagnose.py +++ b/bin/chatgpt_oracle_diagnose.py @@ -82,6 +82,18 @@ def _load(name: str, path: Path): ), ) +# Every eligible pre-submit family gets its own signature and settlement reason +# so the report, the settlement receipt, and the audit trail cannot disagree. +ELIGIBILITY_SIGNATURES = { + "oracle-chatgpt-session-absent/v1": "session-absent", + "oracle-direct-app-route-unconfirmed/v1": "app-route-unconfirmed", + "oracle-web-multi-child/v1": "web-multi-child", +} +ELIGIBILITY_SETTLEMENT_REASONS = { + "oracle-chatgpt-session-absent/v1": "user-confirmed-no-submission-after-session-absent", + "oracle-direct-app-route-unconfirmed/v1": "user-confirmed-no-submission-after-app-route-unconfirmed", + "oracle-web-multi-child/v1": "user-confirmed-no-submission-after-prompt-timeout", +} REMEDIATION = { PRE_SUBMIT_HOST: "Fix the local launch contract; no web submission occurred, so a fresh run is safe.", PRE_SUBMIT_UI: "Relax or realign the ChatGPT UI contract; no web submission occurred, so a fresh run is safe.", @@ -119,12 +131,18 @@ def classify_run( transcript_text: str = "", user_confirmed_no_submission: bool = False, pre_submit_host_failure: dict[str, Any] | None = None, + submission_authority: dict[str, Any] | None = None, ) -> dict[str, str]: """Return the bucket and signature for one persisted run. Ordering matters more than breadth here. Local exit codes and local status never outrank durable evidence, and a pre-submit signature always - wins over a post-submit interpretation. + wins over a post-submit interpretation. The single submission-authority + verdict from ``STATE.classify_submission_authority`` is binding when it is + provided: a run the authority layer proved was never submitted must never + be reported as an uncertain live session, and an eligible-but-unconfirmed + pre-submit refusal stays a host-side decision awaiting explicit user + confirmation. No bucket beyond those two guarantees is re-derived here. """ outcome = str(state.get("task_outcome") or "") # Single authority source, shared with the runner, so the report and the @@ -133,6 +151,24 @@ def classify_run( lifecycle = str(verdict["lifecycle"]) source = str(verdict["authority_source"]) + authority_class = str((submission_authority or {}).get("class") or "") + settlement_eligibility = (submission_authority or {}).get("settlement_eligibility") + requires_user_confirmation = bool((submission_authority or {}).get("requires_user_confirmation")) + if authority_class == "SUBMITTED_UNKNOWN" and requires_user_confirmation: + # The authority layer bound exact eligible pre-submit evidence: nothing + # was sent, but only an explicit user confirmation may release the + # project lock. This outranks the lifecycle-running fallback below so + # an exited login-refusal run can never read as a live exact session. + # The signature names the exact eligibility so this report, the + # settlement receipt, and the audit trail cannot disagree. + return { + "bucket": PRE_SUBMIT_HOST, + "signature": ( + f"{ELIGIBILITY_SIGNATURES.get(str(settlement_eligibility), 'pre-submit')}" + "-awaiting-user-confirmation" + ), + } + pre_submit_failure = state.get("pre_submit_failure") host_failure = pre_submit_failure if isinstance(pre_submit_failure, dict) else pre_submit_host_failure if ( @@ -172,6 +208,14 @@ def classify_run( if outcome == "not_executed" and has_output: return {"bucket": TASK_NOT_EXECUTED, "signature": "durable-output-reports-no-execution"} if user_confirmed_no_submission: + if ( + str(state.get("task_outcome_reason") or "") + == "user-confirmed-no-submission-after-session-absent" + ): + return { + "bucket": PRE_SUBMIT_UI, + "signature": "user-confirmed-no-submission-after-session-absent", + } return { "bucket": PRE_SUBMIT_UI, "signature": ( @@ -190,7 +234,7 @@ def classify_run( if needle in stdout_text: return {"bucket": bucket, "signature": signature} - if lifecycle == "running": + if lifecycle == "running" and authority_class != "PRE_SUBMIT_PROVEN": return {"bucket": ACTIVE, "signature": f"lifecycle-running-via-{source}"} if has_output: return {"bucket": PROVIDER_INCOMPLETE, "signature": "output-present-without-terminal-settlement"} @@ -235,11 +279,15 @@ def _exact_run_dir(state_root: Path, value: Path) -> Path: def _run_record(run_dir: Path) -> tuple[dict[str, Any] | None, dict[str, Any]]: state_path = run_dir / "state.json" + authority = STATE.classify_submission_authority(run_dir) try: state = STATE.load_state(state_path) except Exception as exc: # noqa: BLE001 - corrupt state must remain actionable return None, { "run_dir": str(run_dir), + "authority_class": str(authority.get("class") or ""), + "settlement_eligibility": authority.get("settlement_eligibility"), + "requires_user_confirmation": bool(authority.get("requires_user_confirmation")), "bucket": UNCLASSIFIED, "signature": "state-unreadable", "detail": type(exc).__name__, @@ -256,6 +304,7 @@ def _run_record(run_dir: Path) -> tuple[dict[str, Any] | None, dict[str, Any]]: STATE.proven_user_confirmed_no_submission(state_path) is not None ), pre_submit_host_failure=STATE.proven_pre_submit_host_failure(state_path), + submission_authority=authority, ) return state, { "run_dir": str(run_dir), @@ -264,6 +313,9 @@ def _run_record(run_dir: Path) -> tuple[dict[str, Any] | None, dict[str, Any]]: "session_authority": str(state.get("session_authority") or ""), "lifecycle": str(lifecycle["lifecycle"]), "authority_source": str(lifecycle["authority_source"]), + "authority_class": str(authority.get("class") or ""), + "settlement_eligibility": authority.get("settlement_eligibility"), + "requires_user_confirmation": bool(authority.get("requires_user_confirmation")), "output_path": str(output_path), **verdict, } @@ -299,6 +351,33 @@ def _next_action( "kind": "report_incident", "argv": _argv("chatgpt_oracle_incident.py", "report", "--run-dir", str(run_dir)), }) + elif ( + str(record.get("authority_class") or "") == "SUBMITTED_UNKNOWN" + and record.get("requires_user_confirmation") + ): + # The authority layer bound an eligible pre-submit refusal. Nothing was + # sent, but the project lock may only be released by an explicit user + # confirmation; never by a fresh run or state editing. + action.update({ + "kind": "settle_no_submission", + "reason": ( + "Exact pre-send refusal with no conversation URL; only an explicit " + "user confirmation may release the project lock." + ), + "argv": _argv( + "chatgpt_oracle_run.py", + "settle-no-submission", + "--run-dir", + str(run_dir), + "--confirmation", + STATE.USER_CONFIRMED_NO_SUBMISSION, + "--reason", + ELIGIBILITY_SETTLEMENT_REASONS.get( + str(record.get("settlement_eligibility") or ""), + "user-confirmed-no-submission", + ), + ), + }) elif lifecycle == "running": action.update({ "kind": "watch_exact_run", @@ -448,13 +527,18 @@ def diagnose(state_root: Path | None = None) -> dict[str, Any]: try: state = STATE.load_state(run_dir / "state.json") except Exception as exc: # noqa: BLE001 - a corrupt run must stay visible + authority = STATE.classify_submission_authority(run_dir) runs.append({ "run_dir": str(run_dir), + "authority_class": str(authority.get("class") or ""), + "settlement_eligibility": authority.get("settlement_eligibility"), + "requires_user_confirmation": bool(authority.get("requires_user_confirmation")), "bucket": UNCLASSIFIED, "signature": "state-unreadable", "detail": type(exc).__name__, }) continue + authority = STATE.classify_submission_authority(run_dir) artifacts = state.get("artifacts") if isinstance(state.get("artifacts"), dict) else {} output_path = Path(str(artifacts.get("output") or (run_dir / "output.md"))) verdict = classify_run( @@ -466,12 +550,16 @@ def diagnose(state_root: Path | None = None) -> dict[str, Any]: STATE.proven_user_confirmed_no_submission(run_dir / "state.json") is not None ), pre_submit_host_failure=STATE.proven_pre_submit_host_failure(run_dir / "state.json"), + submission_authority=authority, ) runs.append({ "run_dir": str(run_dir), "project_root": str(state.get("project_root") or ""), "status": str(state.get("status") or ""), "session_authority": str(state.get("session_authority") or ""), + "authority_class": str(authority.get("class") or ""), + "settlement_eligibility": authority.get("settlement_eligibility"), + "requires_user_confirmation": bool(authority.get("requires_user_confirmation")), **verdict, }) @@ -479,25 +567,62 @@ def diagnose(state_root: Path | None = None) -> dict[str, Any]: for run in runs: counts[str(run["bucket"])] = counts.get(str(run["bucket"]), 0) + 1 unresolved = [run for run in runs if run["bucket"] not in {COMPLETE, ACTIVE}] + # A bucket is only fresh-run-safe when no run inside it still owns its + # project. An eligible pre-submit refusal awaiting explicit user + # confirmation keeps its lock, so its bucket must not be advertised as safe. + locked_buckets = { + str(run["bucket"]) for run in runs if run.get("requires_user_confirmation") + } + safe_buckets = [ + bucket for bucket in (PRE_SUBMIT_HOST, PRE_SUBMIT_UI) + if counts.get(bucket) and bucket not in locked_buckets + ] return { "schema": SCHEMA, "state_root": str(root), "total_runs": len(runs), "bucket_counts": {name: count for name, count in counts.items() if count}, "top_buckets": [ - {"bucket": name, "count": count, "remediation": REMEDIATION.get(name, "")} + { + "bucket": name, + "count": count, + "remediation": ( + REMEDIATION.get(name, "") + if name not in locked_buckets + else "A run in this bucket still owns its project: settle it with an " + "explicit user no-submission confirmation before any fresh run." + ), + } for name, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) if count ], - "safe_for_fresh_run_buckets": [PRE_SUBMIT_HOST, PRE_SUBMIT_UI], + "safe_for_fresh_run_buckets": safe_buckets, "unresolved_runs": unresolved, } +# `--summary-only` belongs to the aggregate no-subcommand report only. +# `triage` and `watch` are single-run forms and reject the flag; the rejection +# message spells out the exact usage so operators never conflate the two forms. +SUMMARY_ONLY_USAGE = ( + "SUMMARY_ONLY_FOR_AGGREGATE_DIAGNOSIS: `--summary-only` is accepted only by " + "the aggregate no-subcommand report, e.g. " + "`chatgpt_oracle_diagnose.py --summary-only`. `triage` and `watch` already " + "target one exact run and reject `--summary-only`; call them without it." +) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Read-only Oracle failure-signature report.") parser.add_argument("--state-root", type=Path, default=None) - parser.add_argument("--summary-only", action="store_true") + parser.add_argument( + "--summary-only", + action="store_true", + help=( + "Aggregate-only: omit the per-run detail list from the no-subcommand " + "report. Rejected with triage/watch, which are single-run forms." + ), + ) commands = parser.add_subparsers(dest="command") triage_parser = commands.add_parser("triage", help="Classify one exact run or project and show safe next actions.") triage_selector = triage_parser.add_mutually_exclusive_group(required=True) @@ -515,7 +640,7 @@ def main(argv: list[str] | None = None) -> int: try: if args.command == "watch": if args.summary_only: - raise ValueError("SUMMARY_ONLY_FOR_AGGREGATE_DIAGNOSIS") + raise ValueError(SUMMARY_ONLY_USAGE) return watch( args.run_dir, state_root=args.state_root, @@ -524,7 +649,7 @@ def main(argv: list[str] | None = None) -> int: ) if args.command == "triage": if args.summary_only: - raise ValueError("SUMMARY_ONLY_FOR_AGGREGATE_DIAGNOSIS") + raise ValueError(SUMMARY_ONLY_USAGE) report = triage( state_root=args.state_root, project_root=args.project_root, diff --git a/bin/chatgpt_oracle_incident.py b/bin/chatgpt_oracle_incident.py index 24c1e186..a285e01c 100644 --- a/bin/chatgpt_oracle_incident.py +++ b/bin/chatgpt_oracle_incident.py @@ -81,6 +81,7 @@ def build_packet(run_dir: Path, *, reporter_role: str = REPORTER_ROLE) -> dict[s state = STATE.load_state(state_path) artifacts = state.get("artifacts") if isinstance(state.get("artifacts"), dict) else {} output_path = Path(str(artifacts.get("output") or (directory / "output.md"))) + submission_authority = STATE.classify_submission_authority(directory) verdict = DIAGNOSE.classify_run( state, stdout_text=DIAGNOSE._read_text(directory / "stdout.log"), @@ -90,6 +91,7 @@ def build_packet(run_dir: Path, *, reporter_role: str = REPORTER_ROLE) -> dict[s STATE.proven_user_confirmed_no_submission(state_path) is not None ), pre_submit_host_failure=STATE.proven_pre_submit_host_failure(state_path), + submission_authority=submission_authority, ) lifecycle = STATE.resolve_lifecycle( state, output_is_present=DIAGNOSE._output_is_nonempty(output_path) @@ -109,6 +111,9 @@ def build_packet(run_dir: Path, *, reporter_role: str = REPORTER_ROLE) -> dict[s "signature": str(verdict["signature"]), "lifecycle": str(lifecycle["lifecycle"]), "authority_source": str(lifecycle["authority_source"]), + "authority_class": str(submission_authority.get("class") or ""), + "settlement_eligibility": submission_authority.get("settlement_eligibility"), + "requires_user_confirmation": bool(submission_authority.get("requires_user_confirmation")), "conversation_url": str((state.get("oracle") or {}).get("conversation_url") or "") if isinstance(state.get("oracle"), dict) else "", diff --git a/bin/chatgpt_oracle_run.py b/bin/chatgpt_oracle_run.py index babc3e16..496de49d 100644 --- a/bin/chatgpt_oracle_run.py +++ b/bin/chatgpt_oracle_run.py @@ -1367,6 +1367,20 @@ def execute_run( ), "result": state, } + # A user-confirmable pre-submit refusal (e.g. the exact login-screen + # session-absent exit) is never auto-settled here. Surface the authority + # classification so callers can see the run still owns the project and + # requires an explicit user confirmation before it can be released. + authority = STATE.classify_submission_authority(layout.run_dir) + if authority["requires_user_confirmation"]: + return { + "ok": status == "complete", + "run_dir": str(layout.run_dir), + "result": state, + "authority_class": authority["class"], + "settlement_eligibility": authority["settlement_eligibility"], + "requires_user_confirmation": authority["requires_user_confirmation"], + } return {"ok": status == "complete", "run_dir": str(layout.run_dir), "result": state} @@ -1944,7 +1958,7 @@ def recover_run( and settle_timeout_seconds > 0 and result.get("status") in {"session_live", "terminal_settle_disagreement"} ): - return { + result = { **result, "status": "live_settle_timeout", "settle_timeout_seconds": settle_timeout_seconds, @@ -1953,7 +1967,13 @@ def recover_run( "preserve this session and do not relaunch, replace, or resubmit" ), } - return result + authority = STATE.classify_submission_authority(directory) + return { + **result, + "authority_class": authority["class"], + "settlement_eligibility": authority["settlement_eligibility"], + "requires_user_confirmation": authority["requires_user_confirmation"], + } def build_parser() -> argparse.ArgumentParser: diff --git a/bin/chatgpt_oracle_state.py b/bin/chatgpt_oracle_state.py index 4dc8287c..dd390a54 100644 --- a/bin/chatgpt_oracle_state.py +++ b/bin/chatgpt_oracle_state.py @@ -62,6 +62,7 @@ def is_attachment_transport(transport: str) -> bool: ORACLE_MODEL_SWITCHER_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} ORACLE_COPY_PROFILE_MANUAL_LOGIN_CONFLICT_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} ORACLE_PROFILE_COPY_RSYNC_MISSING_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} +ORACLE_CHATGPT_SESSION_ABSENT_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} ORACLE_PACKAGE = "@steipete/oracle" STATE_SCHEMA = "codex.chatgpt.oracle-run-state/v1" STATUSES = {"prepared", "running", "complete", "failed", "attention_required", "abandoned"} @@ -170,11 +171,56 @@ def is_attachment_transport(transport: str) -> bool: r"Browser mode is using Oracle's private Chrome profile at (?P[^,\r\n]+), " r"separate from your normal Chrome profile\. Run first-time setup, sign in there, then retry:" ) +# Oracle 0.17.3 stopped copying cookies from a live Chrome profile by default, +# so a stale signed-in seed profile now refuses before the composer is opened. +# Both the plain and the `User error (browser-automation)` line must be present: +# that pair is Oracle's own terminal pre-send refusal for a missing session. +ORACLE_CHATGPT_SESSION_ABSENT_RE = re.compile( + r"(?m)^(?PERROR|User error \(browser-automation\)):\s+" + r"ChatGPT session not detected\. Login button detected on page\. " + r"No ChatGPT cookies were applied; sign in to chatgpt\.com in Chrome or pass " + r"inline cookies" +) ORACLE_NO_LIVE_TAB_MARKER = "No live ChatGPT tab matched session" ORACLE_NO_RECOVERABLE_URL_MARKER = ( "session metadata has no recoverable ChatGPT conversation URL" ) USER_CONFIRMED_NO_SUBMISSION = "user-confirmed-no-submission" +SUBMISSION_AUTHORITY_SCHEMA = "codex.chatgpt.oracle-submission-authority/v1" +# One bounded submission-authority vocabulary. Every consumer (lock checks, +# settlement, diagnosis) classifies through `classify_submission_authority` +# instead of re-deriving its own string rules; that duplication is what left a +# proven pre-submit run locked forever while diagnosis called it running. +SUBMISSION_AUTHORITY_CLASSES = ( + "PRE_SUBMIT_PROVEN", + "SUBMITTED_BOUND", + "SUBMITTED_UNKNOWN", + "TERMINAL", + "INVALID_EVIDENCE", +) +# The task_outcome_reason values written by `settle_user_confirmed_no_submission` +# for each user-confirmable no-submission eligibility. A pre_submit record that +# carries one of these reasons but no longer revalidates is a tampered or lost +# settlement and must fall back to fail-closed ownership. +USER_CONFIRMED_NO_SUBMISSION_REASONS = ( + "user-confirmed-no-submission-after-prompt-timeout", + "user-confirmed-no-submission-after-app-route-unconfirmed", + "user-confirmed-no-submission-after-session-absent", +) +# Only these persisted authorities describe a session that may still be live on +# the web. `pre_submit` never submitted, `terminal` is harvested, and a legacy +# ledger row without an authority is not evidence of a live session. +ACTIVE_SESSION_AUTHORITIES = frozenset(("submitted_unknown", "live", "terminal_observed")) +# A persisted `pre_submit` authority is only believable when the independently +# written transport status agrees that nothing was sent. Requiring both fields +# means a single edited field can never release a live submitted run. +PRE_SUBMIT_TRANSPORT_STATUSES = frozenset(( + "prepared", + "rejected_pre_submit", + "failed_pre_submit", + "not_submitted", + "not_submitted_user_confirmed", +)) ORACLE_RECOVERY_STATE_RE = re.compile(r"(?im)^\s*State:\s*[a-z][a-z0-9_-]*\s*$") ORACLE_PROFILE_COPY_EBUSY_RE = re.compile( r"(?im)^(?:ERROR:\s*|User error \(browser-automation\):\s*)?" @@ -1576,39 +1622,19 @@ def _settlement_logs_have_conversation_url(state_path: Path) -> bool: return False -def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] | None: - """Bind an exact direct pre-send app-route rejection to user adjudication. +def _bind_pre_send_no_submission_artifacts(state: dict[str, Any], run_dir: Path) -> dict[str, Any] | None: + """Bind the shared pre-send no-submission artifact contract. - The APP_MENTION_ROUTE_UNCONFIRMED marker is bound only for the 0.17.2 - runtime (exact-recovery-only) and the active runtime; older runtimes stay - fail-closed because their promptComposer patches were never bound here. + Every direct pre-send rejection proof (app-route unconfirmed, chatgpt + session absent) must bind the same durable evidence before it may be + presented for user adjudication: the output artifact is absent, all three + logs sit at their exact canonical paths, none is a symlink, all decode as + strict UTF-8, none carries a ChatGPT conversation URL, the transport + mission bytes equal the bound source bytes, and the mission and manifest + SHA-256 digests still match the persisted values. Returns the bound values + (log texts under underscore keys so they can never be persisted into a + settlement artifact) or None on any mismatch. """ - state = load_state(state_path) - profile = state.get("profile") if isinstance(state.get("profile"), dict) else {} - # Both DevSpace-family routes mention the same app, so both can be refused - # before send with the same marker. The qualified Pro route is additionally - # bound to its exact Pro profile so no other shape can settle through it. - app_route_contract = ( - str(state.get("app_name") or "").casefold() == DEVSPACE_APP_NAME.casefold() - and ( - str(state.get("transport") or "").casefold() == "devspace" - or ( - is_pro_devspace_transport(state.get("transport")) - and str(profile.get("model") or "").casefold() == "gpt-5.6-sol" - and str(profile.get("thinking_time") or "").casefold() == "heavy" - ) - ) - ) - if ( - str(state.get("session_authority") or "") not in {"submitted_unknown", "pre_submit"} - or state.get("parallel_parent_id") not in {None, ""} - or state.get("terminal_harvested") is True - or _state_has_conversation_url(state) - or str(state.get("mode") or "").casefold() != "browser" - or not app_route_contract - ): - return None - run_dir = state_path.parent artifacts = state.get("artifacts") if isinstance(state.get("artifacts"), dict) else {} output = Path(str(artifacts.get("output") or "")) if output.resolve() != (run_dir / "output.md").resolve() or output.is_symlink() or output_is_nonempty(output): @@ -1636,17 +1662,7 @@ def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] return None oracle = state.get("oracle") if isinstance(state.get("oracle"), dict) else {} locator = str(oracle.get("session_locator") or oracle.get("slug") or "").strip() - stdout_lines = {line.strip() for line in stdout_text.splitlines()} - if ( - normalize_oracle_version(oracle.get("resolved_version")) - not in ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_PROOF_VERSIONS - or not locator - or f"Session: {locator}" not in stdout_text - or { - f"ERROR: {ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_MARKER}", - f"User error (browser-automation): {ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_MARKER}", - }.issubset(stdout_lines) is False - ): + if not locator or f"Session: {locator}" not in stdout_text: return None mission = state.get("mission") if isinstance(state.get("mission"), dict) else {} manifest = state.get("manifest") if isinstance(state.get("manifest"), dict) else {} @@ -1686,9 +1702,7 @@ def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] ): return None return { - "settlement_eligibility": "oracle-direct-app-route-unconfirmed/v1", "project_root": str(project_root), - "run_id": str(state.get("run_id") or ""), "source_mission_path": str(source_path), "source_mission_sha256": mission_sha256, "transport_mission_path": str(transport_path), @@ -1700,6 +1714,75 @@ def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] "stdout_sha256": hashlib.sha256(stdout_bytes).hexdigest(), "stderr_sha256": hashlib.sha256(stderr_bytes).hexdigest(), "transcript_sha256": hashlib.sha256(transcript_bytes).hexdigest(), + "output_absent": True, + "conversation_url_absent": True, + "_stdout_text": stdout_text, + "_stderr_text": stderr_text, + "_transcript_text": transcript_text, + } + + +def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] | None: + """Bind an exact direct pre-send app-route rejection to user adjudication. + + The APP_MENTION_ROUTE_UNCONFIRMED marker is bound only for the 0.17.2 + runtime (exact-recovery-only) and the active runtime; older runtimes stay + fail-closed because their promptComposer patches were never bound here. + """ + state = load_state(state_path) + profile = state.get("profile") if isinstance(state.get("profile"), dict) else {} + # Both DevSpace-family routes mention the same app, so both can be refused + # before send with the same marker. The qualified Pro route is additionally + # bound to its exact Pro profile so no other shape can settle through it. + app_route_contract = ( + str(state.get("app_name") or "").casefold() == DEVSPACE_APP_NAME.casefold() + and ( + str(state.get("transport") or "").casefold() == "devspace" + or ( + is_pro_devspace_transport(state.get("transport")) + and str(profile.get("model") or "").casefold() == "gpt-5.6-sol" + and str(profile.get("thinking_time") or "").casefold() == "heavy" + ) + ) + ) + if ( + str(state.get("session_authority") or "") not in {"submitted_unknown", "pre_submit"} + or state.get("parallel_parent_id") not in {None, ""} + or state.get("terminal_harvested") is True + or _state_has_conversation_url(state) + or str(state.get("mode") or "").casefold() != "browser" + or not app_route_contract + ): + return None + bound = _bind_pre_send_no_submission_artifacts(state, state_path.parent) + if bound is None: + return None + oracle = state.get("oracle") if isinstance(state.get("oracle"), dict) else {} + stdout_lines = {line.strip() for line in bound["_stdout_text"].splitlines()} + if ( + normalize_oracle_version(oracle.get("resolved_version")) + not in ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_PROOF_VERSIONS + or { + f"ERROR: {ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_MARKER}", + f"User error (browser-automation): {ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_MARKER}", + }.issubset(stdout_lines) is False + ): + return None + return { + "settlement_eligibility": "oracle-direct-app-route-unconfirmed/v1", + "project_root": bound["project_root"], + "run_id": str(state.get("run_id") or ""), + "source_mission_path": bound["source_mission_path"], + "source_mission_sha256": bound["source_mission_sha256"], + "transport_mission_path": bound["transport_mission_path"], + "transport_mission_sha256": bound["transport_mission_sha256"], + "manifest_path": bound["manifest_path"], + "manifest_sha256": bound["manifest_sha256"], + "mission_sha256": bound["mission_sha256"], + "oracle_locator": bound["oracle_locator"], + "stdout_sha256": bound["stdout_sha256"], + "stderr_sha256": bound["stderr_sha256"], + "transcript_sha256": bound["transcript_sha256"], "recovery_evidence": [], "output_absent": True, "conversation_url_absent": True, @@ -1707,6 +1790,86 @@ def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] } +def _chatgpt_session_absent_no_submission_evidence(state_path: Path) -> dict[str, Any] | None: + """Bind Oracle's exact pre-send session-absent refusal to user adjudication. + + Oracle 0.17.3 no longer copies cookies out of a live Chrome profile, so an + expired signed-in seed profile makes Oracle refuse on the login page before + anything is composed or sent. The paired refusal lines are accepted only + for the runtimes that ship them, together with the full pre-send artifact + contract shared with the app-route rejection. This evidence is exactly as + strong as the direct app-route rejection, but it only makes the run + eligible for an explicit user no-submission confirmation — it never + releases the project lock on its own. + """ + state = load_state(state_path) + if ( + str(state.get("session_authority") or "") not in {"submitted_unknown", "pre_submit"} + or state.get("parallel_parent_id") not in {None, ""} + or state.get("terminal_harvested") is True + or _state_has_conversation_url(state) + or str(state.get("mode") or "").casefold() != "browser" + ): + return None + oracle = state.get("oracle") if isinstance(state.get("oracle"), dict) else {} + if ( + normalize_oracle_version(oracle.get("resolved_version")) + not in ORACLE_CHATGPT_SESSION_ABSENT_PROOF_VERSIONS + ): + return None + # A completed local exit is required, and the host watchdog must not still + # be preserving a live process: armed or wall-clock-expired processes may + # yet act on the exact session, so they can never be adjudicated as absent. + host_watchdog = state.get("host_watchdog") if isinstance(state.get("host_watchdog"), dict) else {} + if state.get("exit_code") is None or str(host_watchdog.get("status") or "") in {"armed", "expired"}: + return None + bound = _bind_pre_send_no_submission_artifacts(state, state_path.parent) + if bound is None: + return None + stdout_text = bound["_stdout_text"] + stderr_text = bound["_stderr_text"] + transcript_text = bound["_transcript_text"] + # No submission may have been observed: neither an `Answer:` line nor the + # prompt-timeout marker may appear anywhere in the run's logs. + if ( + "Answer:" in stdout_text + or "Answer:" in stderr_text + or "Answer:" in transcript_text + or ORACLE_PROMPT_NOT_OBSERVED_MARKER in stdout_text + or ORACLE_PROMPT_NOT_OBSERVED_MARKER in stderr_text + or ORACLE_PROMPT_NOT_OBSERVED_MARKER in transcript_text + ): + return None + prefixes = { + match.group("prefix") + for text in (stdout_text, stderr_text) + for match in ORACLE_CHATGPT_SESSION_ABSENT_RE.finditer(text) + } + if prefixes != {"ERROR", "User error (browser-automation)"}: + return None + return { + "settlement_eligibility": "oracle-chatgpt-session-absent/v1", + "project_root": bound["project_root"], + "run_id": str(state.get("run_id") or ""), + "source_mission_path": bound["source_mission_path"], + "source_mission_sha256": bound["source_mission_sha256"], + "transport_mission_path": bound["transport_mission_path"], + "transport_mission_sha256": bound["transport_mission_sha256"], + "manifest_path": bound["manifest_path"], + "manifest_sha256": bound["manifest_sha256"], + "mission_sha256": bound["mission_sha256"], + "oracle_locator": bound["oracle_locator"], + "stdout_sha256": bound["stdout_sha256"], + "stderr_sha256": bound["stderr_sha256"], + "transcript_sha256": bound["transcript_sha256"], + "recovery_evidence": [], + "output_absent": True, + "conversation_url_absent": True, + "process_exited": True, + "_task_outcome_reason": "user-confirmed-no-submission-after-session-absent", + } + + def _user_confirmable_no_submission_evidence(state_path: Path) -> dict[str, Any] | None: """Return exact evidence for an eligible user-adjudicated run.""" if _settlement_logs_have_conversation_url(state_path): @@ -1724,6 +1887,9 @@ def _user_confirmable_no_submission_evidence(state_path: Path) -> dict[str, Any] direct = _direct_app_route_no_submission_evidence(state_path) if direct is not None: return direct + session_absent = _chatgpt_session_absent_no_submission_evidence(state_path) + if session_absent is not None: + return session_absent return _web_multi_child_no_submission_evidence(state_path) @@ -1776,6 +1942,12 @@ def proven_user_confirmed_no_submission(state_path: Path) -> dict[str, Any] | No "transport_mission_path", "transport_mission_sha256", "manifest_path", "manifest_sha256", "transcript_sha256", ) + elif current.get("settlement_eligibility") == "oracle-chatgpt-session-absent/v1": + required = ( + "settlement_eligibility", "source_mission_path", "source_mission_sha256", + "transport_mission_path", "transport_mission_sha256", "manifest_path", + "manifest_sha256", "transcript_sha256", "process_exited", + ) elif current.get("settlement_eligibility") == "oracle-web-multi-child/v1": required = ( "settlement_eligibility", "parallel_parent_id", "source_mission_path", @@ -1845,7 +2017,11 @@ def settle_user_confirmed_no_submission( "terminal_harvested": False, "artifact_sha256": None, "transport_status": "not_submitted_user_confirmed", - "task_outcome": "pending", + "task_outcome": ( + "not_executed" + if evidence.get("settlement_eligibility") == "oracle-chatgpt-session-absent/v1" + else "pending" + ), "task_outcome_reason": evidence["_task_outcome_reason"], "user_confirmed_no_submission": { "schema": "codex.chatgpt.oracle-settlement-reference/v1", @@ -2770,6 +2946,213 @@ def classify_task_outcome(path: Path, *, contract: str, transport: str) -> str: return "unknown" if contract == "v1" else "legacy_unclassified" +def _run_logs_show_answer(state: dict[str, Any]) -> bool: + """Report whether the run's own logs recorded a delivered ChatGPT answer. + + An observed answer means the prompt reached the web session, so no stored + authority string may downgrade that run to "never submitted". + """ + for name in ("stdout", "stderr", "transcript"): + record = _artifact_bytes(state, name) + if record is None: + continue + try: + text = record[1].decode("utf-8", errors="strict") + except UnicodeDecodeError: + return True + if "Answer:" in text: + return True + return False + + +def classify_submission_authority(run_dir: Path) -> dict[str, Any]: + """Classify one run's submission authority from durable evidence only. + + Pure read-only classification: it never mutates state, artifacts, or + locks, and it never re-derives authority from per-consumer string rules. + Ownership is fail-closed: a run keeps owning the project until exact + terminal evidence, a revalidated automatic pre-submit proof, or a + revalidated explicit user confirmation proves otherwise. The one + deliberate exception is persisted ``pre_submit`` authority itself: the + settle paths only ever write it after proof, so honoring it here without + duplicating that proof is what keeps settled runs from resurrecting + project locks. A pre_submit record that still carries user-confirmation + markers but no longer revalidates them is a tampered or lost settlement + and falls through to fail-closed ownership instead. + """ + run_dir = run_dir.expanduser().resolve() + state_path = run_dir / "state.json" + evidence: dict[str, Any] = { + "output_present": False, + "conversation_url_present": False, + "process_exited": False, + "proven_pre_submit": None, + "user_confirmed": False, + } + + def invalid(reason: str) -> dict[str, Any]: + return { + "schema": SUBMISSION_AUTHORITY_SCHEMA, + "class": "INVALID_EVIDENCE", + "reason": reason, + "run_id": "", + "project_root": "", + "session_authority": "", + "owns_project": True, + "settlement_eligibility": None, + "requires_user_confirmation": False, + "evidence": evidence, + } + + if state_path.is_symlink(): + return invalid("state-symlink") + if not state_path.is_file(): + return invalid("state-missing") + try: + state = load_state(state_path) + except OracleStateError as exc: + return invalid({ + "STATE_SCHEMA_INVALID": "state-schema-mismatch", + "STATE_JSON_INVALID": "state-unreadable", + "UTF8_REQUIRED": "state-unreadable", + }.get(exc.code, "state-unreadable")) + + def verdict( + class_name: str, + reason: str, + *, + owns: bool, + eligibility: str | None = None, + requires_confirmation: bool = False, + **extra: Any, + ) -> dict[str, Any]: + return { + "schema": SUBMISSION_AUTHORITY_SCHEMA, + "class": class_name, + "reason": reason, + "run_id": str(state.get("run_id") or ""), + "project_root": str(state.get("project_root") or ""), + "session_authority": str(state.get("session_authority") or ""), + "owns_project": owns, + "settlement_eligibility": eligibility, + "requires_user_confirmation": requires_confirmation, + "evidence": {**evidence, **extra}, + } + + # Normalize exactly like the legacy lock check did: a non-canonical stored + # string must never be read as "not an active owner". + session_authority = str(state.get("session_authority") or "").strip().casefold() + artifacts = state.get("artifacts") if isinstance(state.get("artifacts"), dict) else {} + output_path = Path(str(artifacts.get("output") or "")) + evidence["output_present"] = bool(str(output_path)) and output_is_nonempty(output_path) + evidence["process_exited"] = state.get("exit_code") is not None + # Any persisted or logged conversation URL binds the run to its exact web + # conversation: only exact-slug recovery may end it. + evidence["conversation_url_present"] = ( + _state_has_conversation_url(state) + or _settlement_logs_have_conversation_url(state_path) + ) + # 1. Exact terminal web evidence outranks everything. Two deliberate + # exclusions keep this as strict as `resolve_lifecycle`: `terminal_observed` + # means an observer saw a finished session but nothing durable was + # harvested, and a bare local-ledger `status == "complete"` is the weakest + # authority there is. Both keep owning the project until an exact harvest + # or a proven settlement ends the run. + if state.get("terminal_harvested") is True or session_authority == "terminal": + return verdict("TERMINAL", "terminal-harvested", owns=False) + # 2. A run that claims a user no-submission settlement but fails + # revalidation is tampered evidence and must fail closed before any + # mechanical proof can release it. + settlement_markers = ( + "user_confirmed_no_submission" in state + or str(state.get("transport_status") or "") == "not_submitted_user_confirmed" + or str(state.get("task_outcome_reason") or "") in USER_CONFIRMED_NO_SUBMISSION_REASONS + or (state_path.parent / "user-confirmed-no-submission.json").exists() + ) + confirmed = proven_user_confirmed_no_submission(state_path) + if settlement_markers and confirmed is None: + return verdict("SUBMITTED_UNKNOWN", "tampered-user-confirmation", owns=True) + # 3. A persisted or logged conversation URL binds the run to its exact web + # conversation: only exact-slug recovery may end it. + if evidence["conversation_url_present"]: + return verdict("SUBMITTED_BOUND", "conversation-url-bound", owns=True) + # 3b. Durable output means the run got past the composer, and every + # pre-submit family requires an absent output, so no proof or eligibility + # can apply. Deciding this here keeps ownership checks — which run before + # every submission — from hashing artifacts for finished runs. + if evidence["output_present"] and session_authority in ACTIVE_SESSION_AUTHORITIES: + return verdict( + "SUBMITTED_UNKNOWN", + "durable-output-without-terminal-settlement", + owns=True, + ) + # 4. A revalidated explicit user confirmation proves non-submission. + if confirmed is not None: + return verdict( + "PRE_SUBMIT_PROVEN", + "user-confirmed-no-submission", + owns=False, + user_confirmed=True, + ) + # 5. A persisted `pre_submit` record never submitted anything. This is + # decided before the proof chains because it needs no artifact hashing: + # every settled or prepared pre-submit run reaches the same verdict either + # way, and ownership checks run on every submission. + # Two independently written fields must agree and the run's own logs may + # not show a delivered answer, so a single edited field can never release + # a live submitted run. + if session_authority == "pre_submit": + transport_status = str(state.get("transport_status") or "").strip().casefold() + if transport_status and transport_status not in PRE_SUBMIT_TRANSPORT_STATUSES: + return verdict( + "SUBMITTED_UNKNOWN", + "pre-submit-claim-contradicts-transport-status", + owns=True, + ) + if _run_logs_show_answer(state): + return verdict("SUBMITTED_UNKNOWN", "answer-observed-despite-pre-submit", owns=True) + return verdict("PRE_SUBMIT_PROVEN", "session-authority-pre-submit", owns=False) + # 6. Automatic pre-submit proof. The user-confirmed branch inside + # `proven_pre_submit_failure` belongs to step 4 and cannot win here. + automatic = proven_pre_submit_failure(state_path) + if automatic is not None and automatic.get("code") != "ORACLE_USER_CONFIRMED_NO_SUBMISSION": + return verdict( + "PRE_SUBMIT_PROVEN", + "mechanical-pre-submit-proof", + owns=False, + proven_pre_submit=str(automatic.get("code") or ""), + ) + # 7. Runs eligible for an explicit user no-submission adjudication keep + # owning the project until the user confirms. `requires_user_confirmation` + # is the single field every consumer gates on: one eligible family (the + # comprehensive stage evidence) carries no eligibility label, so a label + # must never decide behaviour. + confirmable = _user_confirmable_no_submission_evidence(state_path) + if confirmable is not None: + return verdict( + "SUBMITTED_UNKNOWN", + "user-confirmable-no-submission", + owns=True, + eligibility=str(confirmable.get("settlement_eligibility") or "") or None, + requires_confirmation=True, + ) + # 8. A legacy ledger row without any recorded authority is not a live owner: + # locking on absence of evidence would strand every project forever. + if session_authority not in ACTIVE_SESSION_AUTHORITIES: + # Legacy running records fail closed because the provider may still be + # active. Legacy attention-required records predate explicit session + # authority and must not become permanent project locks. + if not session_authority and str(state.get("status") or "").casefold() == "running": + return verdict("SUBMITTED_UNKNOWN", "legacy-running-without-authority", owns=True) + return verdict( + "SUBMITTED_UNKNOWN", + "legacy-ledger-without-active-authority", + owns=False, + ) + # 8. Fail-closed fallback: an active authority without proof keeps the lock. + return verdict("SUBMITTED_UNKNOWN", "unproven-active-authority", owns=True) + + def unresolved_project_sessions( run_root: Path, project_root: Path, @@ -2779,63 +3162,64 @@ def unresolved_project_sessions( ) -> list[dict[str, str]]: """Return exact submitted sessions that still own this project. - A local Oracle exit is not web-terminal authority. Ownership therefore - survives ``running``/``attention_required`` host states until exact-session - recovery records terminal completion. Parallel children from the same - persisted parent are allowed to coexist; a different parent is not. + Ownership is decided by the single submission-authority classifier; this + function never re-derives authority from its own string rules. A local + Oracle exit is not web-terminal authority, so ownership survives running/ + attention_required host states until exact-session recovery records + terminal completion or a proven pre-submit settlement releases it. + Parallel children from the same persisted parent are allowed to coexist; a + different parent is not. """ - root = run_root.expanduser().resolve() + # The scanned ledger is the host-authored `run_root` for this project. That + # field, the Oracle state root environment and every run record share one + # trust boundary: whoever can author them can author any host state, so this + # function deliberately does not go looking for extra ledgers. Widening the + # scan to ambient roots would make one project's lock depend on unrelated + # host state instead of on this project's exact evidence. expected_project = str(project_root.expanduser().resolve()).casefold() + root = run_root.expanduser().resolve() expected_parent = str(parallel_parent_id or "").strip().casefold() - active_authorities = {"submitted_unknown", "live", "terminal_observed"} owners: list[dict[str, str]] = [] if not root.is_dir(): return owners for candidate in sorted(root.glob("*/state.json"), key=lambda item: str(item)): + verdict = classify_submission_authority(candidate.parent) + run_id = str(verdict.get("run_id") or "") + if run_id == exclude_run_id: + continue + # Compare canonical paths: a cosmetically different spelling of the same + # project root must never hide an owner from the lock. An unresolvable + # stored root (embedded NUL, symlink loop) is kept as an owner instead of + # being skipped, so broken evidence can never widen the release. + raw_owner_project = str(verdict.get("project_root") or "") try: - payload = load_state(candidate) - except (OSError, OracleStateError): + owner_project = str(Path(raw_owner_project).expanduser().resolve()).casefold() + except (OSError, ValueError, RuntimeError): + owner_project = None + if owner_project is not None and owner_project != expected_project: continue - run_id = str(payload.get("run_id") or "") - if run_id == exclude_run_id or str(payload.get("project_root") or "").casefold() != expected_project: + if not verdict.get("owns_project"): continue - authority = str(payload.get("session_authority") or "").strip().casefold() - settlement_artifact = candidate.parent / "user-confirmed-no-submission.json" - settlement_derived = ( - "user_confirmed_no_submission" in payload - or str(payload.get("transport_status") or "") == "not_submitted_user_confirmed" - or str(payload.get("task_outcome_reason") or "") in { - "user-confirmed-no-submission-after-prompt-timeout", - "user-confirmed-no-submission-after-app-route-unconfirmed", - } - or settlement_artifact.exists() - ) - invalid_settlement = False + try: + payload = load_state(candidate) + except (OSError, OracleStateError): + payload = {} + owner_parent = str(payload.get("parallel_parent_id") or "").strip().casefold() + # A revoked settlement restores fail-closed ownership even for a + # persisted parallel child, so a tampered release can never let a new + # submission start beside it. if ( - authority == "pre_submit" - and settlement_derived - and proven_user_confirmed_no_submission(candidate) is None + expected_parent + and owner_parent == expected_parent + and str(verdict.get("reason") or "") != "tampered-user-confirmation" ): - # A missing or changed settlement artifact revokes the release and - # restores fail-closed ownership before any new submission. - authority = "submitted_unknown" - invalid_settlement = True - # Legacy running records fail closed because the provider may still be - # active. Legacy attention-required records predate explicit session - # authority and must not become permanent project locks; new runs - # persist submitted_unknown/live explicitly before reaching attention. - if not authority and str(payload.get("status") or "").casefold() == "running": - authority = "submitted_unknown" - if authority not in active_authorities: - continue - owner_parent = str(payload.get("parallel_parent_id") or "").strip().casefold() - if expected_parent and owner_parent == expected_parent and not invalid_settlement: continue owners.append({ "run_id": run_id, "session_locator": str((payload.get("oracle") or {}).get("session_locator") or ""), - "session_authority": authority, + "session_authority": str(verdict.get("session_authority") or ""), "state_path": str(candidate), + "authority_class": str(verdict.get("class") or ""), }) return owners diff --git a/skills/chatgpt-oracle-runtime/SKILL.md b/skills/chatgpt-oracle-runtime/SKILL.md index f7b69ef7..70d9b6ab 100644 --- a/skills/chatgpt-oracle-runtime/SKILL.md +++ b/skills/chatgpt-oracle-runtime/SKILL.md @@ -129,7 +129,14 @@ recovery do not use this DevSpace readiness path. Use `chatgpt_oracle_diagnose.py triage --run-dir ` for a bounded next action and `watch --run-dir ` for read-only NDJSON lifecycle changes. Only execute the returned exact recovery argv when triage identifies that action; -an active session is watched, never recovered or replaced. +an active session is watched, never recovered or replaced. The aggregate bucket +overview is `chatgpt_oracle_diagnose.py --summary-only` (no subcommand); that +flag is rejected with `triage`/`watch`, which are single-run forms. An unsettled +Oracle `session not detected` refusal that exited before send is reported by +triage as `session-absent-awaiting-user-confirmation` with a +`settle_no_submission` argv: run that exact settle command +(`chatgpt_oracle_run.py settle-no-submission --run-dir --confirmation user-confirmed-no-submission --reason `) +only after the operator explicitly confirms the run was never submitted. ## Recovery diff --git a/skills/mcp-update-guard/SKILL.md b/skills/mcp-update-guard/SKILL.md index ac7446b2..45805abb 100644 --- a/skills/mcp-update-guard/SKILL.md +++ b/skills/mcp-update-guard/SKILL.md @@ -44,12 +44,20 @@ instead of the layer that failed. `python "$env:USERPROFILE\.codex\bin\chatgpt_oracle_incident.py" report --run-dir `. The packet carries the exact run directory, the classified bucket, the lifecycle verdict with its authority source, and existing evidence paths. -- Classify before repairing. Run +- Classify before repairing. Run the aggregate report (no subcommand) `python "$env:USERPROFILE\.codex\bin\chatgpt_oracle_diagnose.py" --summary-only` - and fix the largest bucket rather than the newest report. A `pre-submit-*` - bucket proves no web submission occurred and is safe to retry; a - `post-submit-*` bucket requires exact-slug recovery and never a replacement - submission. + and fix the largest bucket rather than the newest report. `--summary-only` + belongs to that aggregate form only: it omits the per-run `unresolved_runs` + detail. `triage --run-dir ` is the single-run form for one bounded + next action and `watch --run-dir ` streams one run's lifecycle; + both reject `--summary-only`, so never combine the flag with a subcommand. A + `pre-submit-*` bucket proves no web submission occurred and is safe to retry; + a `post-submit-*` bucket requires exact-slug recovery and never a replacement + submission. A `session-absent-awaiting-user-confirmation` run is provably + pre-submit but still owns the project: release it only through the returned + settle command + (`chatgpt_oracle_run.py settle-no-submission --run-dir --confirmation user-confirmed-no-submission --reason `) + after the operator confirms, never by editing state. - Treat `safe_for_fresh_run: false` as binding. Do not resubmit, stop, or close another session's work while repairing code. diff --git a/tests/test_chatgpt_oracle_diagnose.py b/tests/test_chatgpt_oracle_diagnose.py index dfa2b5a3..db1d44a3 100644 --- a/tests/test_chatgpt_oracle_diagnose.py +++ b/tests/test_chatgpt_oracle_diagnose.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import importlib.util import json import sys @@ -598,3 +599,230 @@ def clock() -> float: assert code == 3 assert [event["event"] for event in events] == ["snapshot", "timeout"] + + +def write_session_absent_run( + state_root: Path, + run_id: str, + *, + project_root: Path, + user_confirmed: bool = False, +) -> Path: + """Persist an exact bound Oracle session-absent refusal run. + + Mirrors the artifacts the runner records for a run that exited on Oracle's + paired pre-send refusal (login button detected, no cookies applied): no + output, no conversation URL, no submission markers, hash-bound mission and + manifest, on a proof-version Oracle runtime. With ``user_confirmed`` it + also persists the exact settlement artifact and state that + ``settle-no-submission --confirmation user-confirmed-no-submission`` writes + for the ``oracle-chatgpt-session-absent/v1`` eligibility. + """ + project_root = project_root.resolve() + run_dir = state_root / "projects" / "projectkey" / "runs" / run_id + run_dir.mkdir(parents=True, exist_ok=True) + locator = "c-" + "a" * 24 + refusal = ( + "ERROR: ChatGPT session not detected. Login button detected on page. " + "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass " + "inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON).\n" + "User error (browser-automation): ChatGPT session not detected. Login button " + "detected on page. No ChatGPT cookies were applied; sign in to chatgpt.com " + "in Chrome or pass inline cookies (--browser-inline-cookies[(-file)] / " + "ORACLE_BROWSER_COOKIES_JSON).\n" + ) + stdout_path = run_dir / "stdout.log" + stderr_path = run_dir / "stderr.log" + transcript_path = run_dir / "transcript.md" + mission_path = run_dir / "mission.md" + stdout_path.write_text(f"Session: {locator}\n" + refusal, encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + transcript_path.write_text("", encoding="utf-8") + project_root.mkdir(parents=True, exist_ok=True) + source_mission = project_root / "oracle-job.txt" + mission_bytes = f"mission sha256 bound qualified-Pro send\nrun_id={run_id}\n".encode("utf-8") + source_mission.write_bytes(mission_bytes) + mission_path.write_bytes(mission_bytes) + manifest = project_root / "oracle-manifest.json" + manifest_bytes = json.dumps({ + "schema": "codex.chatgpt.oracle-manifest/v1", + "run_id": run_id, + "transport": "pro-devspace", + }, sort_keys=True).encode("utf-8") + manifest.write_bytes(manifest_bytes) + manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest() + state = { + "schema": "codex.chatgpt.oracle-run-state/v1", + "status": "attention_required", + "run_id": run_id, + "project_root": str(project_root), + "session_authority": "submitted_unknown", + "terminal_harvested": False, + "exit_code": 1, + "mode": "browser", + "transport": "pro-devspace", + "app_name": "DevSpace", + "task_outcome": "pending", + "oracle": { + "resolved_version": "0.17.3", + "session_locator": locator, + }, + "artifacts": { + "output": str(run_dir / "output.md"), + "stdout": str(stdout_path), + "stderr": str(stderr_path), + "transcript": str(transcript_path), + }, + "mission": { + "path": str(source_mission), + "transport_path": str(mission_path), + "sha256": hashlib.sha256(mission_bytes).hexdigest(), + }, + "manifest": { + "path": str(manifest), + "actual_sha256": manifest_sha256, + "expected_sha256": manifest_sha256, + }, + } + if user_confirmed: + evidence = { + "settlement_eligibility": "oracle-chatgpt-session-absent/v1", + "project_root": str(project_root), + "run_id": run_id, + "mission_sha256": hashlib.sha256(mission_bytes).hexdigest(), + "oracle_locator": locator, + "stdout_sha256": hashlib.sha256(stdout_path.read_bytes()).hexdigest(), + "stderr_sha256": hashlib.sha256(stderr_path.read_bytes()).hexdigest(), + "recovery_evidence": [], + "output_absent": True, + "conversation_url_absent": True, + "process_exited": True, + "source_mission_path": str(source_mission), + "source_mission_sha256": hashlib.sha256(mission_bytes).hexdigest(), + "transport_mission_path": str(mission_path), + "transport_mission_sha256": hashlib.sha256(mission_bytes).hexdigest(), + "manifest_path": str(manifest), + "manifest_sha256": manifest_sha256, + "transcript_sha256": hashlib.sha256(transcript_path.read_bytes()).hexdigest(), + } + recorded = { + "schema": "codex.chatgpt.oracle-user-confirmed-no-submission/v1", + "code": "ORACLE_USER_CONFIRMED_NO_SUBMISSION", + "confirmation": "user-confirmed-no-submission", + "reason": "user-confirmed-no-submission-after-session-absent", + **evidence, + } + settlement_path = run_dir / "user-confirmed-no-submission.json" + settlement_path.write_text( + json.dumps(recorded, sort_keys=True, separators=(",", ":")), + encoding="utf-8", + ) + state.update({ + "session_authority": "pre_submit", + "transport_status": "not_submitted_user_confirmed", + "task_outcome": "not_executed", + "task_outcome_reason": "user-confirmed-no-submission-after-session-absent", + "user_confirmed_no_submission": { + "schema": "codex.chatgpt.oracle-settlement-reference/v1", + "path": str(settlement_path), + "sha256": hashlib.sha256(settlement_path.read_bytes()).hexdigest(), + }, + }) + (run_dir / "state.json").write_text(json.dumps(state), encoding="utf-8") + return run_dir + + +def test_session_absent_unsettled_run_waits_for_user_confirmation(tmp_path: Path) -> None: + module = load() + state_root = tmp_path / "oracle-state" + project = tmp_path / "project" + run_dir = write_session_absent_run(state_root, "g" * 8, project_root=project) + + record = module.triage(state_root=state_root, run_dir=run_dir)["runs"][0] + + assert record["authority_class"] == "SUBMITTED_UNKNOWN" + assert record["settlement_eligibility"] == "oracle-chatgpt-session-absent/v1" + assert record["requires_user_confirmation"] is True + assert record["bucket"] == "pre-submit-host-environment" + assert record["signature"] == "session-absent-awaiting-user-confirmation" + action = record["next_action"] + assert action["kind"] == "settle_no_submission" + assert action["safe_for_fresh_run"] is False + argv = " ".join(action["argv"] or []) + assert "settle-no-submission" in argv + assert "--confirmation" in argv + assert "user-confirmed-no-submission" in argv + assert "--run-dir" in argv + + aggregate = module.diagnose(state_root) + assert aggregate["unresolved_runs"][0]["signature"] == "session-absent-awaiting-user-confirmation" + assert "active-or-uncertain" not in aggregate["bucket_counts"] + + +def test_session_absent_user_confirmed_run_is_proven_pre_submit_and_released(tmp_path: Path) -> None: + module = load() + state_root = tmp_path / "oracle-state" + project = tmp_path / "project" + run_dir = write_session_absent_run( + state_root, "h" * 8, project_root=project, user_confirmed=True + ) + + record = module.triage(state_root=state_root, run_dir=run_dir)["runs"][0] + + assert record["authority_class"] == "PRE_SUBMIT_PROVEN" + assert "lifecycle-running" not in record["signature"] + assert record["next_action"]["unresolved_owners"] == [] + + +def test_conversation_url_run_is_bound_and_never_offers_settlement(tmp_path: Path) -> None: + module = load() + state_root = tmp_path / "oracle-state" + run_dir = write_run( + state_root, + "i" * 8, + status="attention_required", + session_authority="submitted_unknown", + ) + state_path = run_dir / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["conversation_url"] = "https://chatgpt.com/c/abc123" + state_path.write_text(json.dumps(state), encoding="utf-8") + + record = module.triage(state_root=state_root, run_dir=run_dir)["runs"][0] + + assert record["authority_class"] == "SUBMITTED_BOUND" + assert record["settlement_eligibility"] is None + action = record["next_action"] + assert action["kind"] != "settle_no_submission" + assert "settle-no-submission" not in " ".join(action["argv"] or []) + + +def test_summary_only_is_aggregate_only_and_rejects_triage_with_usage( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = load() + state_root = tmp_path / "oracle-state" + write_run(state_root, "s" * 8, status="failed") + + assert module.main(["--state-root", str(state_root), "--summary-only"]) == 0 + report = json.loads(capsys.readouterr().out) + assert report["schema"] == "codex.chatgpt.oracle-diagnosis/v1" + assert "unresolved_runs" not in report + + run_dir = state_root / "projects" / "projectkey" / "runs" / ("s" * 8) + assert module.main([ + "--state-root", + str(state_root), + "--summary-only", + "triage", + "--run-dir", + str(run_dir), + ]) == 1 + error = json.loads(capsys.readouterr().out) + assert error["ok"] is False + assert error["error"]["code"] == "ORACLE_DIAGNOSE_FAILED" + message = error["error"]["message"] + assert "--summary-only" in message + assert "triage" in message + assert "aggregate" in message diff --git a/tests/test_chatgpt_oracle_run.py b/tests/test_chatgpt_oracle_run.py index 9cb5ddf9..cbb35da3 100644 --- a/tests/test_chatgpt_oracle_run.py +++ b/tests/test_chatgpt_oracle_run.py @@ -2400,6 +2400,397 @@ def test_profile_copy_ebusy_is_proven_pre_submit_and_releases_project(tmp_path: ) == [] +CHATGPT_SESSION_ABSENT_MARKER = ( + "ChatGPT session not detected. Login button detected on page. No ChatGPT cookies were " + "applied; sign in to chatgpt.com in Chrome or pass inline cookies " + "(--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON)." +) + + +def chatgpt_session_absent_popen(command, **kwargs): + """Reproduce Oracle 0.17.3 refusing before send on an expired seed profile.""" + slug = command[command.index("--slug") + 1] + kwargs["stdout"].write( + ( + f"Session: {slug}\n" + f"ERROR: {CHATGPT_SESSION_ABSENT_MARKER}\n" + f"User error (browser-automation): {CHATGPT_SESSION_ABSENT_MARKER}\n" + ).encode() + ) + kwargs["stdout"].flush() + return Process(1, []) + + +def post_submit_disconnect_popen(command, **kwargs): + """Reproduce a browser disconnect after a visible post-submit response stream.""" + slug = command[command.index("--slug") + 1] + kwargs["stdout"].write( + ( + f"Session: {slug}\n" + "prompt submitted; response streaming\n" + "URL: https://chatgpt.com/c/aaaaaaaaaaaaaaaa\n" + "browser disconnected before response completion\n" + ).encode() + ) + kwargs["stdout"].flush() + return Process(1, []) + + +def _no_mutation(runner, run_dir, manifest_path): + return None + + +def _mutate_conversation_url_present(runner, run_dir, manifest_path): + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["oracle"]["conversation_url"] = "https://chatgpt.com/c/blocked-12345678" + runner.STATE.write_json_atomic(state_path, state) + + +def _mutate_answer_marker_present(runner, run_dir, manifest_path): + stdout = run_dir / "stdout.log" + transcript = run_dir / "transcript.md" + marker = b"\nAnswer: delivered assistant response before disconnect\n" + stdout.write_bytes(stdout.read_bytes() + marker) + transcript.write_bytes(transcript.read_bytes() + marker) + + +def _mutate_output_present(runner, run_dir, manifest_path): + (run_dir / "output.md").write_text("partial work product before disconnect", encoding="utf-8") + + +def _mutate_provider_progress(runner, run_dir, manifest_path): + stdout = run_dir / "stdout.log" + transcript = run_dir / "transcript.md" + marker = ("\n" + runner.STATE.ORACLE_PROMPT_NOT_OBSERVED_MARKER + "\n").encode("utf-8") + stdout.write_bytes(stdout.read_bytes() + marker) + transcript.write_bytes(transcript.read_bytes() + marker) + + +def _mutate_process_survival(runner, run_dir, manifest_path): + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["host_watchdog"] = { + "status": "armed", + "timeout_seconds": 5430, + "oracle_process_pid": 1234, + "process_action": "preserve", + } + runner.STATE.write_json_atomic(state_path, state) + + +def _mutate_utf8_decode_failure(runner, run_dir, manifest_path): + stdout = run_dir / "stdout.log" + transcript = run_dir / "transcript.md" + stdout.write_bytes(stdout.read_bytes() + b"\xff\xfe") + transcript.write_bytes(transcript.read_bytes() + b"\xff\xfe") + + +def _mutate_mission_hash_changed(runner, run_dir, manifest_path): + state = runner.STATE.load_state(run_dir / "state.json") + mission_path = Path(state["mission"]["path"]) + mission_path.write_bytes(mission_path.read_bytes() + b"\nmutated after launch") + + +def _mutate_manifest_hash_changed(runner, run_dir, manifest_path): + manifest_path.write_bytes(manifest_path.read_bytes() + b"\n") + + +def test_chatgpt_session_absent_user_confirmation_releases_the_lock_and_allows_a_fresh_run( + tmp_path: Path, +) -> None: + """End-to-end: a login-screen refusal stays locked until user confirmation. + + The removed auto-settlement must never release the project again: the run + is exposed as SUBMITTED_UNKNOWN with the exact session-absent eligibility, + the lock survives until settle_user_confirmed_no_submission succeeds, and + only then may a fresh run proceed on the same project. + """ + runner = load_runner() + manifest_path = pro_devspace_manifest(tmp_path) + result = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=chatgpt_session_absent_popen, + ) + run_dir = Path(result["run_dir"]) + state_path = run_dir / "state.json" + + # At the submission point the failure is exposed as user-confirmable and + # the project lock is still held (no auto-settlement happened). + assert result["ok"] is False + assert result["authority_class"] == "SUBMITTED_UNKNOWN" + assert result["settlement_eligibility"] == "oracle-chatgpt-session-absent/v1" + assert result["requires_user_confirmation"] is True + assert result["result"]["status"] == "attention_required" + + authority = runner.STATE.classify_submission_authority(run_dir) + assert authority["schema"] == "codex.chatgpt.oracle-submission-authority/v1" + assert authority["class"] == "SUBMITTED_UNKNOWN" + assert authority["owns_project"] is True + assert authority["settlement_eligibility"] == "oracle-chatgpt-session-absent/v1" + assert authority["requires_user_confirmation"] is True + assert authority["session_authority"] == "submitted_unknown" + assert authority["evidence"]["output_present"] is False + assert authority["evidence"]["conversation_url_present"] is False + assert authority["evidence"]["process_exited"] is True + assert authority["evidence"]["proven_pre_submit"] is None + assert authority["evidence"]["user_confirmed"] is False + + state = runner.STATE.load_state(state_path) + assert state["session_authority"] == "submitted_unknown" + assert state["transport_status"] == "failed" + assert state["task_outcome"] == "pending" + assert "pre_submit_failure" not in state + assert "user_confirmed_no_submission" not in state + + owners = runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) + assert len(owners) == 1 + assert owners[0]["authority_class"] == "SUBMITTED_UNKNOWN" + + # Recovery exposes the same classification without changing its semantics. + recovered = recover_run(runner, run_dir, action="harvest", dry_run=True) + assert recovered["status"] == "dry-run" + assert recovered["authority_class"] == "SUBMITTED_UNKNOWN" + assert recovered["settlement_eligibility"] == "oracle-chatgpt-session-absent/v1" + assert recovered["requires_user_confirmation"] is True + + # Without the exact confirmation token the settlement is refused. + with pytest.raises(runner.STATE.OracleStateError) as caught: + runner.settle_user_confirmed_no_submission( + run_dir, + confirmation="not-the-exact-user-confirmation", + reason="user confirmed the exact run was not submitted", + ) + assert caught.value.code == "NO_SUBMISSION_CONFIRMATION_REQUIRED" + assert runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) != [] + + # The exact confirmation settles the run as not executed. + settled = runner.settle_user_confirmed_no_submission( + run_dir, + confirmation=runner.STATE.USER_CONFIRMED_NO_SUBMISSION, + reason="user confirmed the exact run was not submitted", + ) + assert settled["ok"] is True + assert settled["status"] == "pre_submit_user_confirmed" + assert settled["safe_for_fresh_run"] is True + assert settled["result"]["session_authority"] == "pre_submit" + assert settled["result"]["transport_status"] == "not_submitted_user_confirmed" + assert settled["result"]["task_outcome"] == "not_executed" + assert settled["result"]["task_outcome_reason"] == ( + "user-confirmed-no-submission-after-session-absent" + ) + + proof = runner.STATE.proven_user_confirmed_no_submission(state_path) + assert proof is not None + assert proof["settlement_eligibility"] == "oracle-chatgpt-session-absent/v1" + assert proof["output_absent"] is True + assert proof["conversation_url_absent"] is True + + # The settlement is hash-bound: changing the mission revokes it. + mission_path = Path(runner.STATE.load_state(state_path)["mission"]["path"]) + mission_bytes = mission_path.read_bytes() + mission_path.write_bytes(mission_bytes + b"\nmutated") + assert runner.STATE.proven_user_confirmed_no_submission(state_path) is None + assert runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) != [] + mission_path.write_bytes(mission_bytes) + assert runner.STATE.proven_user_confirmed_no_submission(state_path) is not None + + # After settlement the run is proven pre-submit and owns nothing. + authority = runner.STATE.classify_submission_authority(run_dir) + assert authority["class"] == "PRE_SUBMIT_PROVEN" + assert authority["owns_project"] is False + assert authority["reason"] == "user-confirmed-no-submission" + assert authority["settlement_eligibility"] is None + assert runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) == [] + + # A fresh run on the same project proceeds without a lock error. + second = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=popen_for(0, b"answer\nTASK_OUTCOME: EXECUTED\n", {}, []), + ) + assert second["ok"] is True + + +@pytest.mark.parametrize( + ("mutation", "expected_class", "expected_eligibility", "settlement_rejected"), + [ + pytest.param( + _mutate_conversation_url_present, + "SUBMITTED_BOUND", + None, + True, + id="conversation-url-present", + ), + pytest.param( + _mutate_answer_marker_present, + "SUBMITTED_UNKNOWN", + None, + True, + id="prompt-submitted-answer-marker", + ), + pytest.param( + _mutate_output_present, + "SUBMITTED_UNKNOWN", + None, + True, + id="partial-output-present", + ), + pytest.param( + _mutate_provider_progress, + "SUBMITTED_UNKNOWN", + None, + True, + id="provider-execution-progress", + ), + pytest.param( + _mutate_process_survival, + "SUBMITTED_UNKNOWN", + None, + True, + id="process-survival-indication", + ), + pytest.param( + _mutate_utf8_decode_failure, + "SUBMITTED_BOUND", + None, + True, + id="log-truncation-utf8-decode-failure", + ), + pytest.param( + _mutate_mission_hash_changed, + "SUBMITTED_UNKNOWN", + None, + True, + id="mission-hash-changed", + ), + pytest.param( + _mutate_manifest_hash_changed, + "SUBMITTED_UNKNOWN", + None, + True, + id="manifest-hash-changed", + ), + pytest.param( + _no_mutation, + "SUBMITTED_UNKNOWN", + "oracle-chatgpt-session-absent/v1", + False, + id="user-confirmation-absent", + ), + ], +) +def test_chatgpt_session_absent_without_user_confirmation_keeps_the_project_lock( + tmp_path: Path, + mutation, + expected_class: str, + expected_eligibility: str | None, + settlement_rejected: bool, +) -> None: + """A login-screen refusal is never auto-settled: only user adjudication releases it. + + Each parameter breaks one evidence bound so the run stays locked and the + settlement is refused. The unmuted run is eligible but still requires the + exact user confirmation; without it nothing is settled (the removed + auto-settlement regression). + """ + runner = load_runner() + manifest_path = pro_devspace_manifest(tmp_path) + result = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=chatgpt_session_absent_popen, + ) + run_dir = Path(result["run_dir"]) + state_path = run_dir / "state.json" + mutation(runner, run_dir, manifest_path) + + authority = runner.STATE.classify_submission_authority(run_dir) + assert authority["class"] == expected_class + assert authority["settlement_eligibility"] == expected_eligibility + assert authority["owns_project"] is True + assert authority["requires_user_confirmation"] is (expected_eligibility is not None) + assert authority["session_authority"] == "submitted_unknown" + + state = runner.STATE.load_state(state_path) + assert state["session_authority"] == "submitted_unknown" + assert state["task_outcome"] == "pending" + assert "pre_submit_failure" not in state + assert "user_confirmed_no_submission" not in state + + owners = runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) + assert len(owners) == 1 + assert owners[0]["authority_class"] == expected_class + + if settlement_rejected: + with pytest.raises(runner.STATE.OracleStateError) as caught: + runner.settle_user_confirmed_no_submission( + run_dir, + confirmation=runner.STATE.USER_CONFIRMED_NO_SUBMISSION, + reason="user confirmed the exact run was not submitted", + ) + assert caught.value.code == "NO_SUBMISSION_EVIDENCE_INCOMPLETE" + else: + with pytest.raises(runner.STATE.OracleStateError) as caught: + runner.settle_user_confirmed_no_submission( + run_dir, + confirmation="not-the-exact-user-confirmation", + reason="user confirmed the exact run was not submitted", + ) + assert caught.value.code == "NO_SUBMISSION_CONFIRMATION_REQUIRED" + assert runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) != [] + + +def test_post_submit_browser_disconnect_keeps_lock_and_refuses_settlement( + tmp_path: Path, +) -> None: + """A browser disconnect after a visible send is exact-session evidence. + + The exact conversation URL was recorded, so the run is SUBMITTED_BOUND and + never eligible for no-submission adjudication; only exact-slug recovery of + that conversation may resolve it. + """ + runner = load_runner() + result = execute_run( + runner, + pro_devspace_manifest(tmp_path), + run_factory=version_runner, + popen_factory=post_submit_disconnect_popen, + ) + run_dir = Path(result["run_dir"]) + state_path = run_dir / "state.json" + + authority = runner.STATE.classify_submission_authority(run_dir) + assert authority["class"] == "SUBMITTED_BOUND" + assert authority["owns_project"] is True + assert authority["settlement_eligibility"] is None + assert authority["requires_user_confirmation"] is False + assert authority["session_authority"] == "submitted_unknown" + + state = runner.STATE.load_state(state_path) + assert state["session_authority"] == "submitted_unknown" + assert state["task_outcome"] == "pending" + assert "pre_submit_failure" not in state + assert "user_confirmed_no_submission" not in state + + owners = runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) + assert len(owners) == 1 + assert owners[0]["authority_class"] == "SUBMITTED_BOUND" + + with pytest.raises(runner.STATE.OracleStateError) as caught: + runner.settle_user_confirmed_no_submission( + run_dir, + confirmation=runner.STATE.USER_CONFIRMED_NO_SUBMISSION, + reason="user confirmed the exact run was not submitted", + ) + assert caught.value.code == "NO_SUBMISSION_EVIDENCE_INCOMPLETE" + assert runner.STATE.unresolved_project_sessions(run_dir.parent, tmp_path) != [] + + def test_recovery_repairs_legacy_profile_copy_ebusy_without_oracle_call(tmp_path: Path) -> None: runner = load_runner() seed = tmp_path.parent / f"{tmp_path.name}-profile" diff --git a/tests/test_chatgpt_oracle_state.py b/tests/test_chatgpt_oracle_state.py index 64b9f808..d0bf2c71 100644 --- a/tests/test_chatgpt_oracle_state.py +++ b/tests/test_chatgpt_oracle_state.py @@ -1366,3 +1366,378 @@ def test_pro_devspace_proof_additions_leave_version_sets_unchanged() -> None: assert state.ORACLE_MODEL_SWITCHER_PROOF_VERSIONS == {"0.17.2", "0.17.3"} assert state.ORACLE_COPY_PROFILE_MANUAL_LOGIN_CONFLICT_PROOF_VERSIONS == {"0.17.2", "0.17.3"} assert state.ORACLE_PROFILE_COPY_RSYNC_MISSING_PROOF_VERSIONS == {"0.17.2", "0.17.3"} + + +def test_submission_authority_class_vocabulary_is_fixed() -> None: + state = load_state() + + assert state.SUBMISSION_AUTHORITY_CLASSES == ( + "PRE_SUBMIT_PROVEN", + "SUBMITTED_BOUND", + "SUBMITTED_UNKNOWN", + "TERMINAL", + "INVALID_EVIDENCE", + ) + assert state.SUBMISSION_AUTHORITY_SCHEMA == "codex.chatgpt.oracle-submission-authority/v1" + + +def session_absent_state(tmp_path: Path, run_name: str = "run", **mutations) -> Path: + state = load_state() + run_dir = tmp_path / run_name + run_dir.mkdir(parents=True, exist_ok=True) + for name in ("output.md", "stdout.log", "stderr.log", "transcript.md"): + (run_dir / name).write_text("", encoding="utf-8") + mission_path = tmp_path / f"{run_name}-mission-source.md" + mission_path.write_text("work", encoding="utf-8") + mission_bytes = mission_path.read_bytes() + transport_path = run_dir / "mission.md" + transport_path.write_bytes(mission_bytes) + manifest_path = tmp_path / f"{run_name}-oracle-manifest.json" + manifest_path.write_text('{"schema": "codex.chatgpt.oracle-run/v1"}', encoding="utf-8") + manifest_bytes = manifest_path.read_bytes() + locator = "oracle-test-run-a3aeba967d" + (run_dir / "stdout.log").write_text( + f"Session: {locator}\n" + "ERROR: ChatGPT session not detected. Login button detected on page. " + "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies\n" + "User error (browser-automation): ChatGPT session not detected. Login button detected on page. " + "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies\n", + encoding="utf-8", + ) + payload = { + "schema": state.STATE_SCHEMA, + "run_id": "20260814T120000Z-a3aeba967d99", + "project_root": str(tmp_path.resolve()), + "mode": "browser", + "transport": "pro-devspace", + "app_name": "DevSpace", + "session_authority": "submitted_unknown", + "terminal_harvested": False, + "status": "attention_required", + "exit_code": 1, + "host_watchdog": { + "status": "process-exited", + "timeout_seconds": 5400, + "oracle_process_pid": 4242, + }, + "task_outcome": "pending", + "profile": { + "model": "gpt-5.6-sol", + "model_strategy": "select", + "thinking_time": "heavy", + }, + "oracle": { + "resolved_version": "oracle 0.17.3", + "session_locator": locator, + }, + "mission": { + "path": str(mission_path), + "transport_path": str(transport_path), + "sha256": hashlib.sha256(mission_bytes).hexdigest(), + }, + "manifest": { + "path": str(manifest_path), + "actual_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + "expected_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + }, + "artifacts": { + "output": str(run_dir / "output.md"), + "stdout": str(run_dir / "stdout.log"), + "stderr": str(run_dir / "stderr.log"), + "transcript": str(run_dir / "transcript.md"), + }, + } + payload.update(mutations) + state_path = run_dir / "state.json" + state.write_json_atomic(state_path, payload) + return state_path + + +def test_session_absent_login_refusal_is_confirmable_but_keeps_ownership(tmp_path: Path) -> None: + state = load_state() + state_path = session_absent_state(tmp_path) + locator = "oracle-test-run-a3aeba967d" + + evidence = state._user_confirmable_no_submission_evidence(state_path) + + assert evidence is not None + assert evidence["settlement_eligibility"] == "oracle-chatgpt-session-absent/v1" + assert evidence["_task_outcome_reason"] == "user-confirmed-no-submission-after-session-absent" + assert evidence["process_exited"] is True + + verdict = state.classify_submission_authority(state_path.parent) + + assert verdict["schema"] == state.SUBMISSION_AUTHORITY_SCHEMA + assert verdict["class"] == "SUBMITTED_UNKNOWN" + assert verdict["reason"] == "user-confirmable-no-submission" + assert verdict["run_id"] == "20260814T120000Z-a3aeba967d99" + assert verdict["project_root"] == str(tmp_path.resolve()) + assert verdict["session_authority"] == "submitted_unknown" + assert verdict["owns_project"] is True + assert verdict["settlement_eligibility"] == "oracle-chatgpt-session-absent/v1" + assert verdict["requires_user_confirmation"] is True + assert verdict["evidence"]["output_present"] is False + assert verdict["evidence"]["conversation_url_present"] is False + assert verdict["evidence"]["process_exited"] is True + assert verdict["evidence"]["proven_pre_submit"] is None + assert verdict["evidence"]["user_confirmed"] is False + + +def test_session_absent_user_confirmation_releases_ownership(tmp_path: Path) -> None: + state = load_state() + state_path = session_absent_state(tmp_path) + + settled = state.settle_user_confirmed_no_submission( + state_path, + confirmation="user-confirmed-no-submission", + reason="login page refusal before send", + ) + + assert settled["task_outcome"] == "not_executed" + assert settled["task_outcome_reason"] == "user-confirmed-no-submission-after-session-absent" + assert settled["session_authority"] == "pre_submit" + assert settled["transport_status"] == "not_submitted_user_confirmed" + assert state.proven_user_confirmed_no_submission(state_path) is not None + + verdict = state.classify_submission_authority(state_path.parent) + + assert verdict["class"] == "PRE_SUBMIT_PROVEN" + assert verdict["reason"] == "user-confirmed-no-submission" + assert verdict["owns_project"] is False + assert verdict["settlement_eligibility"] is None + assert verdict["requires_user_confirmation"] is False + assert verdict["evidence"]["user_confirmed"] is True + + +def test_conversation_url_binds_run_against_settlement(tmp_path: Path) -> None: + state = load_state() + locator = "oracle-test-run-a3aeba967d" + state_path = session_absent_state( + tmp_path, + oracle={ + "resolved_version": "oracle 0.17.3", + "session_locator": locator, + "conversation_url": "https://chatgpt.com/c/AbC123xyz_89", + }, + ) + + verdict = state.classify_submission_authority(state_path.parent) + + assert verdict["class"] == "SUBMITTED_BOUND" + assert verdict["reason"] == "conversation-url-bound" + assert verdict["owns_project"] is True + assert verdict["settlement_eligibility"] is None + assert verdict["requires_user_confirmation"] is False + assert verdict["evidence"]["conversation_url_present"] is True + + +@pytest.mark.parametrize( + "mutation", + [ + {"terminal_harvested": True}, + {"session_authority": "terminal"}, + ], +) +def test_terminal_evidence_releases_ownership(tmp_path: Path, mutation: dict) -> None: + state = load_state() + state_path = session_absent_state(tmp_path, **mutation) + + verdict = state.classify_submission_authority(state_path.parent) + + assert verdict["class"] == "TERMINAL" + assert verdict["owns_project"] is False + assert verdict["settlement_eligibility"] is None + + +def test_bare_local_ledger_complete_keeps_ownership(tmp_path: Path) -> None: + """A local `status: complete` is the weakest authority there is. + + `resolve_lifecycle` refuses to let it assert completion, so the classifier + must not release the project lock on it either: without an exact harvest the + web session may still be live. + """ + state = load_state() + state_path = session_absent_state(tmp_path, status="complete") + + verdict = state.classify_submission_authority(state_path.parent) + + assert verdict["class"] != "TERMINAL" + assert verdict["owns_project"] is True + + +def test_missing_state_is_invalid_evidence_and_keeps_ownership(tmp_path: Path) -> None: + state = load_state() + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True, exist_ok=True) + + verdict = state.classify_submission_authority(run_dir) + + assert verdict["class"] == "INVALID_EVIDENCE" + assert verdict["reason"] == "state-missing" + assert verdict["owns_project"] is True + assert verdict["requires_user_confirmation"] is False + + +def test_schema_mismatched_state_is_invalid_evidence(tmp_path: Path) -> None: + state = load_state() + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "state.json").write_text( + '{"schema": "codex.chatgpt.oracle-run-state/v9"}', encoding="utf-8" + ) + + verdict = state.classify_submission_authority(run_dir) + + assert verdict["class"] == "INVALID_EVIDENCE" + assert verdict["reason"] == "state-schema-mismatch" + assert verdict["owns_project"] is True + + +def test_malformed_state_json_is_invalid_evidence(tmp_path: Path) -> None: + state = load_state() + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "state.json").write_text("{", encoding="utf-8") + + verdict = state.classify_submission_authority(run_dir) + + assert verdict["class"] == "INVALID_EVIDENCE" + assert verdict["reason"] == "state-unreadable" + assert verdict["owns_project"] is True + + +def test_symlinked_state_is_invalid_evidence(tmp_path: Path) -> None: + state = load_state() + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True, exist_ok=True) + target = tmp_path / "target-state.json" + target.write_text('{"schema": "codex.chatgpt.oracle-run-state/v1"}', encoding="utf-8") + try: + (run_dir / "state.json").symlink_to(target) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + verdict = state.classify_submission_authority(run_dir) + + assert verdict["class"] == "INVALID_EVIDENCE" + assert verdict["reason"] == "state-symlink" + assert verdict["owns_project"] is True + + +def test_unresolved_project_sessions_matches_submission_classification(tmp_path: Path) -> None: + state = load_state() + locator = "oracle-test-run-a3aeba967d" + session_absent_state(tmp_path, "run-a") + state_path_b = session_absent_state( + tmp_path, + "run-b", + run_id="20260814T120000Z-bbbbbbbbbbbb", + ) + state.settle_user_confirmed_no_submission( + state_path_b, + confirmation="user-confirmed-no-submission", + reason="login page refusal before send", + ) + session_absent_state( + tmp_path, + "run-c", + run_id="20260814T120000Z-cccccccccccc", + oracle={ + "resolved_version": "oracle 0.17.3", + "session_locator": locator, + "conversation_url": "https://chatgpt.com/c/AbC123xyz_89", + }, + ) + session_absent_state( + tmp_path, + "run-d", + run_id="20260814T120000Z-dddddddddddd", + terminal_harvested=True, + ) + run_dir_e = tmp_path / "run-e" + run_dir_e.mkdir(parents=True, exist_ok=True) + (run_dir_e / "state.json").write_text( + '{"schema": "codex.chatgpt.oracle-run-state/v9"}', encoding="utf-8" + ) + + owners = state.unresolved_project_sessions(tmp_path, tmp_path.resolve()) + + by_run = {owner["run_id"]: owner for owner in owners} + assert set(by_run) == {"20260814T120000Z-a3aeba967d99", "20260814T120000Z-cccccccccccc"} + assert by_run["20260814T120000Z-a3aeba967d99"]["authority_class"] == "SUBMITTED_UNKNOWN" + assert by_run["20260814T120000Z-a3aeba967d99"]["session_locator"] == locator + assert by_run["20260814T120000Z-a3aeba967d99"]["session_authority"] == "submitted_unknown" + assert by_run["20260814T120000Z-cccccccccccc"]["authority_class"] == "SUBMITTED_BOUND" + assert all(owner["state_path"] for owner in owners) + + +def test_session_absent_refusal_is_no_longer_auto_settled(tmp_path: Path) -> None: + state = load_state() + state_path = session_absent_state(tmp_path) + + assert not hasattr(state, "proven_pre_submit_chatgpt_session_absent") + assert state.settle_proven_pre_submit_failure(state_path) is None + payload = state.load_state(state_path) + assert payload["session_authority"] == "submitted_unknown" + assert payload["status"] == "attention_required" + + +@pytest.mark.parametrize( + "variant", + [ + "single-prefix", + "answer-line", + "prompt-marker", + "stale-version", + "watchdog-armed", + "no-exit", + ], +) +def test_session_absent_refusal_stays_fail_closed(tmp_path: Path, variant: str) -> None: + state = load_state() + locator = "oracle-test-run-a3aeba967d" + refusal = ( + "ERROR: ChatGPT session not detected. Login button detected on page. " + "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies" + ) + user_error = ( + "User error (browser-automation): ChatGPT session not detected. Login button detected on page. " + "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies" + ) + if variant == "single-prefix": + state_path = session_absent_state(tmp_path) + (state_path.parent / "stdout.log").write_text( + f"Session: {locator}\n{refusal}\n", encoding="utf-8" + ) + elif variant == "answer-line": + state_path = session_absent_state(tmp_path) + (state_path.parent / "stdout.log").write_text( + f"Session: {locator}\n{refusal}\n{user_error}\nAnswer: hello\n", encoding="utf-8" + ) + elif variant == "prompt-marker": + state_path = session_absent_state(tmp_path) + (state_path.parent / "stdout.log").write_text( + f"Session: {locator}\n{refusal}\n{user_error}\n{state.ORACLE_PROMPT_NOT_OBSERVED_MARKER}\n", + encoding="utf-8", + ) + elif variant == "stale-version": + state_path = session_absent_state( + tmp_path, + oracle={"resolved_version": "oracle 0.17.1", "session_locator": locator}, + ) + elif variant == "watchdog-armed": + state_path = session_absent_state( + tmp_path, + host_watchdog={"status": "armed", "timeout_seconds": 5400, "oracle_process_pid": 4242}, + ) + else: + state_path = session_absent_state(tmp_path, exit_code=None) + + assert state._user_confirmable_no_submission_evidence(state_path) is None + + verdict = state.classify_submission_authority(state_path.parent) + + assert verdict["class"] == "SUBMITTED_UNKNOWN" + assert verdict["owns_project"] is True + assert verdict["settlement_eligibility"] is None + assert verdict["requires_user_confirmation"] is False