From 19bb057f9e3137f4782919c411b2bae568872b51 Mon Sep 17 00:00:00 2001 From: KDH Date: Fri, 14 Aug 2026 19:31:31 +0900 Subject: [PATCH] feat: adopt explicit Pro read/write policy --- AGENTS.md | 2 +- README.en.md | 55 ++- README.md | 50 ++- bin/chatgpt_oracle_comprehensive.py | 122 ++++-- bin/chatgpt_oracle_dispatch.py | 38 +- bin/chatgpt_oracle_profiles.py | 75 +++- bin/chatgpt_oracle_run.py | 37 +- bin/chatgpt_oracle_state.py | 156 ++++++- docs/CHANGELOG.md | 36 +- docs/GLOBAL_CHATGPT_ROUTING.md | 28 +- docs/RELEASE_CHECKLIST.md | 7 +- docs/VS_UPSTREAM.md | 46 +- install-manifest.json | 5 +- package-lock.json | 4 +- package.json | 2 +- skills/chatgpt-oracle-runtime/SKILL.md | 37 +- skills/chatgpt-pro-browser/SKILL.md | 12 +- skills/chatgpt-pro-browser/agents/openai.yaml | 2 +- skills/chatgpt-pro-plan-handoff/SKILL.md | 17 +- skills/chatgpt-thinking-browser/SKILL.md | 10 +- skills/devspace-oracle-router/SKILL.md | 12 +- tests/test_chatgpt_oracle_comprehensive.py | 239 ++++++++++- tests/test_chatgpt_oracle_dispatch.py | 96 +++++ tests/test_chatgpt_oracle_profiles.py | 42 +- tests/test_chatgpt_oracle_run.py | 169 ++++++++ tests/test_chatgpt_oracle_state.py | 398 ++++++++++++++++++ tests/test_global_gpt_browser_policy.py | 35 +- tests/test_install_lifecycle.py | 2 +- tests/test_release_packaging.py | 5 +- 29 files changed, 1555 insertions(+), 184 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5ded8ec6..f3d2b438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ## Comprehensive-mode ownership -- Every new ChatGPT submission uses Oracle. Regular GPT uses the manually registered DevSpace app; Pro uses Oracle attachment-only and never selects an app. +- Every new ChatGPT submission uses Oracle. Regular GPT uses the manually registered DevSpace app. Qualified Pro uses the `pro-devspace` transport: the `@DevSpace` mention plus the absolute mission path, with mission-scoped file writes and command execution confined to the exact project root. Explicit immutable-evidence Pro uses the `pro-attachment-only` transport and never selects an app. Pro runs only on an explicit user request; no route promotes itself to Pro automatically. - GPT comprehensive workflows use `codex.chatgpt.oracle-comprehensive/v1`. Retired backend state is host-only historical data and must not be submitted, recovered, or automatically deleted. - The completing web GPT stage authors the next stage's semantic prompt. Local Codex may validate UTF-8, hashes, stage identity, immutable bindings, transport, recovery, and deterministic final tests, but must not rewrite the next prompt or take over expensive exploration/implementation. - A selected Web Multi advisory uses genuine independent Oracle sessions. Provider generation is limited to at most five concurrent children; larger accepted topologies run in capacity waves without reducing their logical lane count. diff --git a/README.en.md b/README.en.md index 917418d5..c03649ba 100644 --- a/README.en.md +++ b/README.en.md @@ -6,7 +6,7 @@ A Windows automation toolkit that delegates planning, research, review, code changes, and testing to web ChatGPT while keeping local Codex work focused on transport, recovery, identity, hashes, and the final deterministic gate. -The current release is `1.8.1`. +The current release is `1.9.0`. It connects two upstream tools: @@ -17,9 +17,18 @@ It connects two upstream tools: Regular GPT runs verify `GPT-5.6 Sol` at the visible `Extra High` tier (`Power 4 of 5`), then send one line containing exactly `@DevSpace` and the absolute -UTF-8 mission-file path. -Pro runs use `gpt-5.6-sol` with the proven `Power 5 of 5` (`Pro`) effort, -exact hash-frozen attachments, and no DevSpace. +UTF-8 mission-file path. Regular work uses this `extra-high` tier by default +and never promotes to Pro automatically. Pro has a limited daily allowance, so +it is selected only when the user explicitly requests it. New qualified Pro +runs use the `pro-devspace` transport: they mention `@DevSpace` and may write +mission-directed files and run the mission's commands inside the exact project +root. Repository safety rules stay authoritative; account, ChatGPT app +settings, and external state change only when the mission explicitly +authorizes them. `pro-attachment-only` remains a separate explicit +immutable-evidence route and is not an automatic fallback. The standard +comprehensive workflow allows a plan-to-Pro transition only when its manifest +sets `allow_pro: true`, which the host writes only after an explicit user +request. Pro skills never auto-invoke (`allow_implicit_invocation: false`). ## What it provides @@ -41,7 +50,8 @@ User request -> Codex writes a UTF-8 mission and manifest -> Oracle starts a signed-in ChatGPT session |-- regular GPT: @DevSpace + mission path - `-- Pro: mission + hash-frozen attachments + |-- qualified Pro: @DevSpace + mission path (writes inside the exact root) + `-- evidence Pro: mission + hash-frozen attachments -> web GPT explores, plans, edits, and tests -> Oracle saves the answer as a local artifact -> Codex checks identity, hashes, and one deterministic final gate @@ -63,18 +73,20 @@ Host state and ChatGPT output are stored outside DevSpace projects under | Web Multi-GPT | Web Multi-GPT | Independent parallel perspectives and merger | 2-25 Oracle sessions | | Local Multi-GPT | Local Multi-GPT | Local advisory synthesis and counterexample search | Fixed `gpt-5.6-luna` + `max`, read-only | | Comprehensive | comprehensive mode | Plan, explicitly selected Pro/Web Multi, review, implementation, gate | Staged Oracle workflow | -| Pro | `pro` / Pro | Independent final judgment or design review; result only | Oracle attachments only; `gpt-5.6-sol` + `Power 5 of 5` | +| Pro | `pro` / Pro | Explicitly requested Pro judgment or design review; result only | Qualified `pro-devspace`: DevSpace mention + writes inside exact root; evidence `pro-attachment-only`: attachments only, no app; both `gpt-5.6-sol` + `Power 5 of 5` | Orchestrator mode is a single web submission. Comprehensive mode contains an orchestrator-equivalent implementation stage plus planning, independent review, optional Pro or Web Multi-GPT, and final gates. Web Multi runs only when it is explicitly selected; regular work and failures never transition to it automatically. -Standalone Pro is a one-shot review route, separate from comprehensive mode. It -reviews the attached plan, code, or document, returns the durable result, and -stops; it never transitions automatically into implementation or another stage. -Use comprehensive mode only when the work must continue from planning through -implementation and gates. +Standalone Pro is a one-shot route, separate from comprehensive mode. The +evidence route (`pro-attachment-only`) reviews the attached plan, code, or +document and returns the durable result; qualified Pro (`pro-devspace`) can +write files and run commands inside the exact project root within the +mission's scope. Neither Pro route transitions automatically into +implementation or another stage; use comprehensive mode only when the work +must continue from planning through implementation and gates. Local Multi-GPT and Web Multi-GPT are separate paths. Local Multi-GPT is an optional advisory tool that runs Codex child lanes on the PC. Every stage is @@ -173,9 +185,26 @@ top-level `oracle_manifest_sha256` in the same command as ## Pro example -Pro uses no project app. Build and validate the 1 MiB context packet described -in `skills/chatgpt-pro-browser/SKILL.md`; dispatch then revalidates its manifest, +Pro runs only on an explicit user request. Qualified Pro (`pro-devspace`) +sends no attachments: the dispatch emits the `@DevSpace` mention plus the +absolute mission path, and the web Pro session may write mission-directed +files and run the mission's commands inside the exact project root. + +```powershell +python "$env:USERPROFILE\.codex\bin\chatgpt_oracle_dispatch.py" ` + --mode pro ` + --project-root C:\project ` + --mission-path C:\project\pro.md ` + --manifest-output C:\project\.ai-bridge\pro.json ` + --dry-run +``` + +The evidence route (`pro-attachment-only`) uses no project app. Build and +validate the 1 MiB context packet described in +`skills/chatgpt-pro-browser/SKILL.md`; dispatch then revalidates its manifest, receipt, mission, packet, and evidence hashes before submission. +`--attachment` and `--context-manifest` are accepted only on this evidence +route. ```powershell python "$env:USERPROFILE\.codex\bin\chatgpt_oracle_dispatch.py" ` diff --git a/README.md b/README.md index 24347bd9..e6c86cdb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Codex가 웹 ChatGPT에 계획·리서치·검토·코드 구현을 맡기고, 로컬 Codex는 제출·복구·해시·최종 테스트만 담당하도록 만드는 Windows용 자동화 도구입니다. -현재 릴리스는 `1.8.1`입니다. +현재 릴리스는 `1.9.0`입니다. 이 프로젝트는 다음 두 도구를 연결합니다. @@ -16,9 +16,17 @@ Codex가 웹 ChatGPT에 계획·리서치·검토·코드 구현을 맡기고, 일반 GPT 작업은 Oracle이 `GPT-5.6 Sol`과 보이는 `Extra High` 등급 (`Power 4 of 5`)을 확인한 뒤 정확히 `@DevSpace`와 절대 UTF-8 미션 파일 -경로만 ChatGPT에 전달합니다. Pro 작업은 `gpt-5.6-sol`과 증명된 -`Power 5 of 5`(`Pro`) 등급, 정확한 첨부 파일만 사용하며 DevSpace를 -사용하지 않습니다. +경로만 ChatGPT에 전달합니다. 일반 작업은 이 `extra-high` 등급을 기본으로 +사용하며 자동으로 Pro로 승격하지 않습니다. Pro는 일일 할당량 제한이 있으므로 +사용자가 명시적으로 요청할 때만 선택됩니다. 새 qualified Pro 실행은 +`pro-devspace` transport를 사용하며, `@DevSpace` 멘션으로 exact project +root 안에서 미션이 지시한 파일 쓰기와 명령 실행을 수행할 수 있습니다. +저장소 안전성 규칙은 여전히 상위이며, 계정·ChatGPT 앱 설정·외부 상태 변경은 +미션이 명시적으로 승인할 때만 허용됩니다. `pro-attachment-only`는 별개의 +명시적 불변 증거 경로로 남으며 자동 fallback이 아닙니다. 표준 종합 +워크플로는 manifest에 `allow_pro: true`가 있을 때만 plan의 Pro 전환을 +허용하며, 그 값은 사용자의 명시적 요청 이후에만 넣습니다. Pro 스킬은 +자동으로 호출되지 않습니다(`allow_implicit_invocation: false`). ## 이 도구로 할 수 있는 일 @@ -41,7 +49,8 @@ Codex가 UTF-8 미션 파일과 실행 manifest 작성 ↓ Oracle이 로그인된 ChatGPT 세션 실행 ├─ 일반 GPT: @DevSpace + 미션 경로 - └─ Pro: 미션 + 고정 해시 첨부 파일 + ├─ qualified Pro: @DevSpace + 미션 경로 (exact root 안 쓰기) + └─ 증거 Pro: 미션 + 고정 해시 첨부 파일 ↓ 웹 GPT가 프로젝트 탐색·계획·구현·테스트 ↓ @@ -66,16 +75,19 @@ Codex가 해시·상태·최종 결정론적 테스트만 확인 | Web Multi-GPT | Web Multi-GPT | 여러 관점의 독립 탐색·검증 | 독립 Oracle 세션 2~25개 + merger | | Local Multi-GPT | Local Multi-GPT | 로컬 병렬 자문·반례 탐색 | `gpt-5.6-luna` + `max` 고정, 읽기 전용 | | 종합모드 | comprehensive mode | 계획부터 구현·최종 게이트까지 자동 연결 | plan → 명시적으로 선택한 Pro/Web Multi → review → implementation → gate | -| Pro | `pro` / Pro | 독립적인 최종 판단·설계 검토 후 결과만 반환 | Oracle `gpt-5.6-sol` + `Power 5 of 5`, 첨부 전용, DevSpace 없음 | +| Pro | `pro` / Pro | 사용자가 명시적으로 요청한 Pro 판단·검토만 수행 | qualified `pro-devspace`: DevSpace 멘션 + exact root 안 쓰기; 증거 `pro-attachment-only`: 첨부 전용, 앱 없음; 둘 다 `gpt-5.6-sol` + `Power 5 of 5` | 지휘는 웹 제출 한 번으로 끝나는 실행 모드입니다. 종합모드는 지휘와 같은 구현 단계를 포함하면서 계획·독립 검토·선택적 Pro/Web Multi·최종 게이트를 추가한 다단계 워크플로입니다. Web Multi는 명시적으로 선택한 경우에만 실행하며 일반 작업이나 실패한 작업에서 자동으로 전환하지 않습니다. -단순 Pro는 종합모드와 별개인 한 번짜리 검토 경로입니다. 첨부된 계획·코드·문서를 -검토하고 결과 파일을 반환하면 끝나며, 자동으로 구현이나 다음 단계로 넘어가지 -않습니다. 계획부터 구현까지 이어야 할 때만 종합모드를 사용합니다. +단순 Pro는 종합모드와 별개인 한 번짜리 경로입니다. 증거 경로 +(`pro-attachment-only`)는 첨부된 계획·코드·문서를 검토하고 결과 파일을 +반환하며, qualified Pro(`pro-devspace`)는 미션이 지시한 범위에서 exact +root 안의 파일을 쓰고 명령을 실행할 수 있습니다. 어떤 Pro 경로도 자동으로 +구현이나 다음 단계로 넘어가지 않으며, 계획부터 구현까지 이어야 할 때만 +종합모드를 사용합니다. Local Multi-GPT와 Web Multi-GPT는 서로 다른 경로입니다. Local Multi-GPT는 PC의 Codex 하위 레인을 사용하는 선택적 자문 도구이며, 모든 단계가 @@ -190,8 +202,24 @@ python "$env:USERPROFILE\.codex\bin\chatgpt_oracle_dispatch.py" ` ## Pro 실행 예시 -Pro는 프로젝트 앱을 사용하지 않습니다. 미션과 필요한 증거 파일을 정확한 -해시로 고정해 첨부합니다. +Pro는 사용자가 명시적으로 요청할 때만 실행됩니다. qualified +Pro(`pro-devspace`)는 첨부 없이 `@DevSpace` 멘션과 절대 미션 경로를 +전달하며, exact project root 안에서 미션이 지시한 파일 쓰기와 명령 실행을 +수행합니다. + +```powershell +python "$env:USERPROFILE\.codex\bin\chatgpt_oracle_dispatch.py" ` + --mode pro ` + --project-root C:\project ` + --mission-path C:\project\pro.md ` + --manifest-output C:\project\.ai-bridge\pro.json ` + --chatgpt-project-url https://chatgpt.com/g/g-p-example/project ` + --dry-run +``` + +증거 경로(`pro-attachment-only`)는 프로젝트 앱을 사용하지 않습니다. 미션과 +필요한 증거 파일을 정확한 해시로 고정해 첨부합니다. `--attachment`와 +`--context-manifest`는 이 증거 경로에서만 사용합니다. ```powershell python "$env:USERPROFILE\.codex\bin\chatgpt_oracle_dispatch.py" ` diff --git a/bin/chatgpt_oracle_comprehensive.py b/bin/chatgpt_oracle_comprehensive.py index dbff9afc..0d6e5a53 100644 --- a/bin/chatgpt_oracle_comprehensive.py +++ b/bin/chatgpt_oracle_comprehensive.py @@ -67,6 +67,7 @@ def _load(name: str, path: Path): RUNNER = _load("oracle_comprehensive_runner", BIN / "chatgpt_oracle_run.py") MULTI = _load("oracle_comprehensive_multi", BIN / "chatgpt_oracle_multi.py") +PROFILES = _load("oracle_comprehensive_profiles", BIN / "chatgpt_oracle_profiles.py") class WorkflowError(RuntimeError): @@ -221,6 +222,9 @@ def load_manifest( app_name = str(value.get("app_name") or "DevSpace").strip() if app_name != "DevSpace": raise WorkflowError("app_name must be exactly DevSpace") + allow_pro = value.get("allow_pro", False) + if not isinstance(allow_pro, bool): + raise WorkflowError("allow_pro must be a boolean explicit opt-in") chatgpt_project_url = RUNNER.STATE.normalize_chatgpt_project_url(value.get("chatgpt_project_url")) return { **value, @@ -230,6 +234,7 @@ def load_manifest( "initial_mission_path": mission, "max_stages": maximum, "app_name": app_name, + "allow_pro": allow_pro, "chatgpt_project_url": chatgpt_project_url, "model": str(value.get("model") or "gpt-5.6"), "local_gate_command": list(local_gate), @@ -519,9 +524,25 @@ def _stage_mission( "are the authority.\n" ) if stage == "plan": + if config.get("allow_pro") is True: + pro_selection_policy = ( + "pro_selection_allowed=true\n" + "next_stage=pro is the qualified Pro route; it defaults to a writable pro-devspace stage, or to " + "an attachment-bound pro-attachment-only stage when the authored next mission declares immutable " + "evidence files.\n" + ) + else: + pro_selection_policy = ( + "pro_selection_allowed=false\n" + "Do not emit next_stage=pro; continue with review or an authorized web-multi stage.\n" + ) protocol += ( - "\n[PRO_ATTACHMENT_AUTHORING_CONTRACT]\n" - "If and only if next_stage=pro requires evidence files, the authored next mission must contain exactly " + "\n[PRO_SELECTION_POLICY]\n" + + pro_selection_policy + + "\n[PRO_ATTACHMENT_AUTHORING_CONTRACT]\n" + "next_stage=pro defaults to the writable pro-devspace route when the authored next mission declares " + "no evidence files; the attachment block below is the immutable-evidence path only. If and only if " + "next_stage=pro must bind immutable evidence files, the authored next mission must contain exactly " "one closed [PRO_ATTACHMENT_CONTRACT] block. Its body must be one JSON object with " f"schema={PRO_ATTACHMENT_SCHEMA} and an attachments array. Each attachment entry contains an absolute " "path and may contain its lowercase SHA-256. Paths must name regular non-symlink files inside " @@ -575,6 +596,8 @@ def _pro_stage_mission( attempt_id: str, source_sha: str, source_bytes: bytes, + *, + writable: bool = False, ) -> tuple[Path, Path, str, str]: stage_dir = config["workflow_dir"] / "stages" / f"{index:02d}-pro-{attempt_id[:12]}" receipt = stage_dir / "stage-result.json" @@ -588,6 +611,8 @@ def _pro_stage_mission( "\n\n[HOST_STAGE_CONTRACT]\n" f"workflow_id={workflow_id}\nstage=pro\nstage_index={index}\n" f"attempt_id={attempt_id}\ninput_mission_sha256={input_sha}\n" + f"exact_project_root={config['project_root']}\n" + f"exact_input_mission_path={source}\n" "Return exactly one JSON object and no surrounding prose or Markdown fences. " f"The schema must be {PRO_OUTPUT_SCHEMA}. Include workflow_id, stage, attempt_id, " "input_mission_sha256, status, output_text, next_stage, next_mission_text, ready_for_next, blocker. " @@ -598,6 +623,14 @@ def _pro_stage_mission( "next_mission_text. Never paste a nested JSON document into either string with raw, unescaped quotes; " "encode it as string content with JSON escaping.\n" ) + if writable: + protocol += ( + "\n[PRO_DEVSPACE_WRITE_AUTHORITY]\n" + f"{PROFILES.PRO_DEVSPACE_WRITE_AUTHORITY}\n" + "All writes and command execution are permitted only within the scope this mission directs, " + "and only inside exact_project_root. Never substitute a parent root, child directory, " + "similarly named workspace, active workspace, or shell-based boundary workaround.\n" + ) mission_bytes = (body.rstrip() + protocol).encode("utf-8") target.write_bytes(mission_bytes) return target, receipt, input_sha, hashlib.sha256(mission_bytes).hexdigest() @@ -627,43 +660,46 @@ def _oracle_manifest( "archive": "auto", "parallel_parent_id": config["_parallel_parent_id"], "run_id": run_id, + "pro_selection_policy": "explicit-only", } if config.get("chatgpt_project_url"): payload["chatgpt_project_url"] = config["chatgpt_project_url"] if stage == "pro": evidence = tuple(pro_attachments) - if not evidence: - raise WorkflowError("Pro context packet requires explicit project evidence") - builder = RUNNER.PRO_CONTEXT_BUILDER - context_manifest = stage_dir / "project-context-manifest.json" - packet = stage_dir / "project-context.zip" - category = "comprehensive-stage-evidence" - _write(context_manifest, { - "schema": builder.SCHEMA, - "project_root": str(config["project_root"]), - "question": "Complete the comprehensive Pro stage using the exact attached project evidence.", - "mission_path": str(mission), - "mission_sha256": mission_sha, - "required_categories": [category], - "category_omissions": [], - "local_transport_envelope_bytes": builder.TOTAL_ENVELOPE_BYTES, - "answer_headroom_bytes": builder.TRANSPORT_ANSWER_HEADROOM_BYTES, - "metadata_reserve_bytes": builder.METADATA_RESERVE_BYTES, - "packet_path": str(packet), - "evidence": [ - {"path": str(item), "category": category, "priority": priority, "sha256": expected_sha} - for priority, (item, expected_sha) in enumerate(evidence) - ], - }) - try: - context_receipt = builder.build(context_manifest) - except builder.PacketError as exc: - raise WorkflowError(f"Pro context packet invalid: {exc}") from exc - payload["transport"] = "pro-attachment-only" - payload["attachments"] = [str(mission), str(packet)] - payload["attachment_sha256s"] = [mission_sha, str(context_receipt["packet_sha256"])] - payload["project_context_manifest_path"] = str(context_manifest) - payload["project_context_manifest_sha256"] = sha(context_manifest) + if evidence: + builder = RUNNER.PRO_CONTEXT_BUILDER + context_manifest = stage_dir / "project-context-manifest.json" + packet = stage_dir / "project-context.zip" + category = "comprehensive-stage-evidence" + _write(context_manifest, { + "schema": builder.SCHEMA, + "project_root": str(config["project_root"]), + "question": "Complete the comprehensive Pro stage using the exact attached project evidence.", + "mission_path": str(mission), + "mission_sha256": mission_sha, + "required_categories": [category], + "category_omissions": [], + "local_transport_envelope_bytes": builder.TOTAL_ENVELOPE_BYTES, + "answer_headroom_bytes": builder.TRANSPORT_ANSWER_HEADROOM_BYTES, + "metadata_reserve_bytes": builder.METADATA_RESERVE_BYTES, + "packet_path": str(packet), + "evidence": [ + {"path": str(item), "category": category, "priority": priority, "sha256": expected_sha} + for priority, (item, expected_sha) in enumerate(evidence) + ], + }) + try: + context_receipt = builder.build(context_manifest) + except builder.PacketError as exc: + raise WorkflowError(f"Pro context packet invalid: {exc}") from exc + payload["transport"] = "pro-attachment-only" + payload["attachments"] = [str(mission), str(packet)] + payload["attachment_sha256s"] = [mission_sha, str(context_receipt["packet_sha256"])] + payload["project_context_manifest_path"] = str(context_manifest) + payload["project_context_manifest_sha256"] = sha(context_manifest) + else: + payload["transport"] = "pro-devspace" + payload["app_name"] = config["app_name"] else: payload["transport"] = "devspace" payload["app_name"] = config["app_name"] @@ -1074,6 +1110,10 @@ def _validate_receipt( and not (stage == "review" and status == "FAIL") ): raise WorkflowError("stage receipt did not pass") + if stage == "plan" and next_stage == "pro" and config.get("allow_pro") is not True: + raise WorkflowError( + "PRO_EXPLICIT_OPT_IN_REQUIRED: set allow_pro=true only after an explicit Pro request" + ) output_raw = value.get("output_path") output, output_relative = _receipt_path(config["project_root"], output_raw) if not output.is_file() or not output.read_bytes().strip() or value.get("output_sha256") != sha(output): @@ -2007,7 +2047,6 @@ def _run_workflow_locked( ) < 1 and persisted_run_dir.is_dir() and _is_unambiguous_pre_submit_failure(persisted_run_dir) - and (stored_stage != "pro" or bool(stored.get("pro_attachments"))) and _user_confirmed_retry_binding_matches( persisted_run_dir, config=config, @@ -2040,7 +2079,7 @@ def _run_workflow_locked( "next_stage": str(stored["current_stage"]), "next_mission_path": str(source), "next_mission_sha256": stored_input_sha, - **({"pro_attachments": stored["pro_attachments"]} if stored_stage == "pro" else {}), + **({"pro_attachments": stored["pro_attachments"]} if stored_stage == "pro" and stored.get("pro_attachments") else {}), "next_index": int(stored["next_index"]), "records": stored_records + [retry_record], "pre_submit_retries": pre_submit_retries + 1, @@ -2188,10 +2227,9 @@ def _run_workflow_locked( pro_attachments = frozen_pro_attachments if pro_attachments is None: pro_attachments = _declared_pro_attachments(config, source, source_bytes) - if not pro_attachments: - pro_attachments = ((source, source_sha),) mission, receipt_path, input_sha, augmented_mission_sha = _pro_stage_mission( - config, workflow_id, index, source, attempt_id, source_sha, source_bytes + config, workflow_id, index, source, attempt_id, source_sha, source_bytes, + writable=not bool(pro_attachments), ) else: if _mission_contains_pro_attachment_contract(source_bytes): @@ -2220,7 +2258,7 @@ def _run_workflow_locked( "current_binding_source_sha256": input_sha, "current_augmented_mission_path": str(mission), "current_augmented_mission_sha256": augmented_mission_sha, - **({"pro_attachments": _pro_attachment_state(pro_attachments)} if stage == "pro" else {}), + **({"pro_attachments": _pro_attachment_state(pro_attachments)} if stage == "pro" and pro_attachments else {}), "oracle_run_id": attempt_id, "oracle_run_dir": str(oracle_layout.run_dir), "oracle_manifest_path": str(oracle_manifest), "oracle_manifest_sha256": oracle_manifest_sha, "next_index": index, "records": records, "pre_submit_retries": stage_pre_submit_retries, @@ -2249,7 +2287,7 @@ def _run_workflow_locked( "current_binding_source_sha256": input_sha, "current_augmented_mission_path": str(mission), "current_augmented_mission_sha256": augmented_mission_sha, - **({"pro_attachments": _pro_attachment_state(pro_attachments)} if stage == "pro" else {}), + **({"pro_attachments": _pro_attachment_state(pro_attachments)} if stage == "pro" and pro_attachments else {}), "oracle_run_dir": run.get("run_dir"), "oracle_manifest_path": str(oracle_manifest), "oracle_manifest_sha256": oracle_manifest_sha, "next_index": index, "records": records, }) @@ -2299,7 +2337,7 @@ def _run_workflow_locked( "next_stage": stage, "next_mission_path": str(source), "next_mission_sha256": input_sha, - **({"pro_attachments": _pro_attachment_state(pro_attachments)} if stage == "pro" else {}), + **({"pro_attachments": _pro_attachment_state(pro_attachments)} if stage == "pro" and pro_attachments else {}), "next_index": index, "records": records + [retry_record], "pre_submit_retries": pre_submit_retries + 1, diff --git a/bin/chatgpt_oracle_dispatch.py b/bin/chatgpt_oracle_dispatch.py index bce77dfd..438d56ca 100644 --- a/bin/chatgpt_oracle_dispatch.py +++ b/bin/chatgpt_oracle_dispatch.py @@ -24,6 +24,24 @@ def _load(name: str, path: Path): PROJECTS = _load("oracle_dispatch_projects", BIN / "chatgpt_oracle_projects.py") RUNNER = _load("oracle_dispatch_runner", BIN / "chatgpt_oracle_run.py") +# Exact launch-route to transport mapping. A route that is not listed here +# fails closed: dispatch must never invent or inherit a transport. +ROUTE_TRANSPORTS = { + "oracle-devspace": "devspace", + "oracle-pro-devspace": "pro-devspace", + "oracle-pro-attachment-only": "pro-attachment-only", +} + + +def dispatch_transport(route: str) -> str: + """Map one launch route to its exact Oracle transport; unknown routes fail closed.""" + transport = ROUTE_TRANSPORTS.get(str(route or "").strip()) + if transport is None: + raise ValueError( + f"ORACLE_ROUTE_UNSUPPORTED: Oracle route {route!r} has no dispatch transport; refusing to launch" + ) + return transport + def compile_manifest( *, @@ -41,15 +59,21 @@ def compile_manifest( mission_path=mission_path, reasoning_level=reasoning_level, attachment_paths=list(attachment_paths or ()), + project_root=project_root.expanduser().resolve(strict=False), ) - is_pro = contract["mode"] == "pro" - if is_pro and context_manifest_path is None: - raise ValueError("PRO_CONTEXT_MANIFEST_REQUIRED: --context-manifest is required for Pro mode") - if not is_pro and context_manifest_path is not None: - raise ValueError("CONTEXT_MANIFEST_FORBIDDEN: --context-manifest is only valid for Pro mode") result = {"ok": True, "contract": contract, "oracle_manifest_path": None} if not contract["oracle_launch"]: return result + transport = dispatch_transport(str(contract.get("route") or "")) + is_attachment = transport == "pro-attachment-only" + if is_attachment and context_manifest_path is None: + raise ValueError( + "PRO_CONTEXT_MANIFEST_REQUIRED: --context-manifest is required for the Pro attachment-only route" + ) + if not is_attachment and context_manifest_path is not None: + raise ValueError( + "CONTEXT_MANIFEST_FORBIDDEN: --context-manifest is only valid for the Pro attachment-only route" + ) root = project_root.expanduser().resolve(strict=True) target = output_path.expanduser().resolve() target.parent.mkdir(parents=True, exist_ok=True) @@ -60,7 +84,7 @@ def compile_manifest( "mission_sha256": RUNNER.STATE.sha256_file(Path(contract["mission_path"]).resolve(strict=True)), "mode": "browser", "task_kind": contract["task_kind"], - "transport": "pro-attachment-only" if contract["mode"] == "pro" else "devspace", + "transport": transport, "model": contract.get("model") or "gpt-5.6", "model_strategy": "select", "thinking_time": contract["thinking_time"], @@ -70,7 +94,7 @@ def compile_manifest( project_url = RUNNER.STATE.normalize_chatgpt_project_url(chatgpt_project_url) if project_url: manifest["chatgpt_project_url"] = project_url - if is_pro: + if is_attachment: assert context_manifest_path is not None raw_context_manifest = context_manifest_path.expanduser() if not raw_context_manifest.is_absolute(): diff --git a/bin/chatgpt_oracle_profiles.py b/bin/chatgpt_oracle_profiles.py index 6a6c38d6..d64dfb15 100644 --- a/bin/chatgpt_oracle_profiles.py +++ b/bin/chatgpt_oracle_profiles.py @@ -26,6 +26,16 @@ # (Power 5 of 5 proof); `heavy` is only Oracle's compatibility token for it. PRO_MODEL = "gpt-5.6-sol" PRO_COMPOSER_PROMPT = "Read the attached prompt/instructions and all attached files, then complete the task." +# The single immutable write-authority statement for the qualified Pro path. +# It lives only here; chatgpt_oracle_state.py reuses it through +# pro_devspace_composer_handoff so the wording cannot drift. +PRO_DEVSPACE_WRITE_AUTHORITY = ( + "Within that exact project root you may inspect, create, edit, and remove mission-owned files and run " + "the commands the mission requires. Use only the exact project root recorded in the mission; never " + "substitute a parent, child, similarly named, or active workspace. Repository safety rules stay " + "authoritative. Do not change accounts, ChatGPT app settings, or any external state unless the mission " + "explicitly authorizes it." +) class OracleProfileError(ValueError): @@ -56,7 +66,7 @@ class OracleModeProfile: "orchestrator": OracleModeProfile("orchestrator", "orchestrator", True, True), "deep-research": OracleModeProfile("deep-research", "deep-research", True, True, research=True), "manual": OracleModeProfile("manual", "manual", False, False), - "pro": OracleModeProfile("pro", "pro", True, False), + "pro": OracleModeProfile("pro", "pro", True, True), } _ALIASES = { "gpt": "direct", @@ -126,6 +136,25 @@ def composer_handoff(mission_path: str | Path) -> str: return f"@{DEVSPACE_APP_NAME} {mission}" +def pro_devspace_composer_handoff(mission_path: str | Path, project_root: str | Path) -> str: + """The qualified Pro-through-DevSpace composer text: exactly one line. + + It must stay on one argument line for the same reason as the regular + handoff: a literal newline truncates the prompt in the Windows `npx.cmd` + argument before Oracle receives it, which would silently drop the write + authority. The exact project root is named before the authority sentence + so the granted write and command scope has a host-recorded antecedent, and + the absolute UTF-8 mission path stays last so nothing can be swallowed + into it. + """ + mission = _absolute_mission_path(mission_path) + root = _absolute_mission_path(project_root) + return ( + f"@{DEVSPACE_APP_NAME} Read and execute the mission file inside " + f"exact_project_root={root}. {PRO_DEVSPACE_WRITE_AUTHORITY} Mission file: {mission}" + ) + + def _attachment_paths(values: list[str | Path] | tuple[str | Path, ...] | None) -> list[Path]: result: list[Path] = [] for value in values or (): @@ -146,11 +175,15 @@ def build_launch_contract( mission_path: str | Path | None = None, reasoning_level: str | None = None, attachment_paths: list[str | Path] | tuple[str | Path, ...] | None = None, + project_root: str | Path | None = None, ) -> dict[str, Any]: """Build an immutable, browser-agnostic launch contract for parent runners. `manual` intentionally produces a non-launch contract. Pro uses the same - Oracle browser engine but has a distinct attachment-only transport. + Oracle browser engine: explicit user attachments select the frozen + attachment-only transport unchanged; without attachments the qualified + Pro run routes through DevSpace with the explicit write-authority + handoff and no attachments or context packets. """ profile = resolve_profile(mode) result: dict[str, Any] = { @@ -163,6 +196,7 @@ def build_launch_contract( "attachments": [], "app_picker": False, "app_settings_automation": False, + "pro_selection_policy": "explicit-only", } if not profile.oracle_launch: result.update({ @@ -176,13 +210,44 @@ def build_launch_contract( mission = _absolute_mission_path(mission_path) if profile.mode == "pro": attachments = _attachment_paths(attachment_paths) + if not attachments: + # No user attachments: the qualified Pro path runs through + # DevSpace with an explicit write-authority handoff and zero + # attachments or context packets. A caller that forces + # attachments onto this route after the fact is rejected by the + # manifest gate with PRO_DEVSPACE_ATTACHMENTS_FORBIDDEN. The + # granted write and command scope must name an exact root, so the + # caller has to supply it. + if project_root is None or not str(project_root).strip(): + raise OracleProfileError( + "PRO_DEVSPACE_PROJECT_ROOT_REQUIRED", + "qualified Pro requires the exact project root that bounds its write authority", + ) + result.update({ + "route": "oracle-pro-devspace", + "app_policy": "prompt-mention-only", + "app_name": DEVSPACE_APP_NAME, + "devspace_required": True, + "attachment_policy": "forbidden", + "attachments": [], + "model": PRO_MODEL, + "reasoning_level": "Pro", + # `heavy` is Oracle's compatibility token for the account-visible + # Pro power tier (Power 5 of 5). Keep it explicit so parent + # runners cannot fall back to the regular Extra High default. + "thinking_time": "heavy", + "mission_path": str(mission), + "composer_prompt": pro_devspace_composer_handoff(mission, project_root), + }) + return result + # Explicit user attachments select the immutable-evidence Pro path + # unchanged, with the mission auto-inserted first. if mission not in attachments: attachments.insert(0, mission) - if not attachments: - raise OracleProfileError("PRO_ATTACHMENTS_REQUIRED", "Pro requires at least one exact attachment") result.update({ "route": "oracle-pro-attachment-only", "app_policy": "forbidden", + "devspace_required": False, "attachment_policy": "always", "attachments": [str(path) for path in attachments], "model": PRO_MODEL, @@ -222,6 +287,7 @@ def _main(argv: list[str] | None = None) -> int: parser.add_argument("--mission-path") parser.add_argument("--reasoning-level") parser.add_argument("--attachment", action="append", default=[]) + parser.add_argument("--project-root") args = parser.parse_args(argv) try: if args.command == "list": @@ -236,6 +302,7 @@ def _main(argv: list[str] | None = None) -> int: mission_path=args.mission_path, reasoning_level=args.reasoning_level, attachment_paths=args.attachment, + project_root=args.project_root, ), } except OracleProfileError as exc: diff --git a/bin/chatgpt_oracle_run.py b/bin/chatgpt_oracle_run.py index 6655a92c..babc3e16 100644 --- a/bin/chatgpt_oracle_run.py +++ b/bin/chatgpt_oracle_run.py @@ -291,7 +291,7 @@ def preflight_run( ) validate_oracle_attachment_sizes(config) validate_pro_context_preflight(config) - if config.transport == "pro-attachment-only" and devspace_hostname: + if STATE.is_attachment_transport(config.transport) and devspace_hostname: raise OracleRunError( "PRO_DEVSPACE_PREFLIGHT_FORBIDDEN", "Pro attachment-only preflight does not accept DevSpace endpoint options", @@ -340,7 +340,7 @@ def preflight_run( "ready": False, "skipped": "oracle version was not validated", }) - if config.transport == "devspace": + if config.transport in STATE.DEVSPACE_TRANSPORTS: readiness = assess_submission_readiness( config, mode="inspect", @@ -374,24 +374,8 @@ def preflight_run( } -def validate_oracle_attachment_sizes(config) -> None: - if config.transport != "pro-attachment-only": - return - oversized = [ - {"path": str(path), "size_bytes": path.stat().st_size, "limit_bytes": ORACLE_PRO_ATTACHMENT_MAX_BYTES} - for path in config.attachments - if path.stat().st_size > ORACLE_PRO_ATTACHMENT_MAX_BYTES - ] - if oversized: - raise OracleRunError( - "ORACLE_ATTACHMENT_SIZE_PRELAUNCH_FAILED", - "Oracle Pro attachments must not exceed the tested 1 MiB per-file limit", - {"limit_bytes": ORACLE_PRO_ATTACHMENT_MAX_BYTES, "attachments": oversized}, - ) - - def validate_pro_context_preflight(config) -> dict[str, Any] | None: - if config.transport != "pro-attachment-only": + if not STATE.is_attachment_transport(config.transport): return None manifest_path = config.project_context_manifest_path expected_manifest_sha256 = config.project_context_manifest_sha256 @@ -494,7 +478,8 @@ def build_oracle_argv( # explicit, bounded original-session budget. The 0.17.x compatibility # patch makes `--browser-timeout` one overall answer budget, including # recovery. The old 20m upstream default was too short for heavy Extra - # High DevSpace lanes. Pro keeps upstream timing. + # High DevSpace lanes. Every transport, including both Pro routes, uses + # this same bounded budget unless the caller pinned `--browser-timeout`. answer_timeout_args = ( [] if any( @@ -520,7 +505,7 @@ def build_oracle_argv( "--prompt", prompt, "--write-output", str(layout.output_path), ] - if config.transport == "pro-attachment-only": + if STATE.is_attachment_transport(config.transport): attachment_args: list[str] = [] for path in config.attachments: attachment_args.extend(["--file", str(path)]) @@ -529,7 +514,7 @@ def build_oracle_argv( ] if config.copy_profile is not None: command[command.index("--slug"):command.index("--slug")] = ["--copy-profile", str(config.copy_profile)] - if config.transport != "pro-attachment-only" and any( + if not STATE.is_attachment_transport(config.transport) and any( item == "--file" or item.startswith("--file=") or item == "-f" for item in command ): raise OracleRunError("FILE_TRANSPORT_FORBIDDEN", "general GPT browser runs must not use --file") @@ -546,7 +531,7 @@ def build_oracle_argv( def validate_oracle_attachment_sizes(config) -> None: """Reject Pro attachments Oracle 0.16.1 cannot submit before any launch.""" - if config.transport != "pro-attachment-only": + if not STATE.is_attachment_transport(config.transport): return oversized = [ {"path": str(path), "size_bytes": path.stat().st_size, "limit_bytes": ORACLE_0161_ATTACHMENT_MAX_BYTES} @@ -876,7 +861,7 @@ def pro_output_satisfies_required_schema(state: dict[str, Any], output_path: Pat ineligible for promotion. Both plain labels and labels wrapped in Markdown code ticks are accepted because the Pro response contract uses both forms. """ - if str(state.get("transport") or "") != "pro-attachment-only": + if not STATE.is_attachment_transport(str(state.get("transport") or "")): return True mission = state.get("mission") if isinstance(state.get("mission"), dict) else {} mission_path = Path(str(mission.get("transport_path") or mission.get("path") or "")) @@ -1063,7 +1048,7 @@ def execute_run( runtime_command=runtime_command, ) watchdog_timeout_seconds = host_watchdog_timeout_seconds(config, argv) - if config.transport == "devspace": + if config.transport in STATE.DEVSPACE_TRANSPORTS: readiness = assess_submission_readiness( config, mode="prepare", @@ -1168,7 +1153,7 @@ def execute_run( except STATE.OracleStateError as exc: raise OracleRunError(exc.code, str(exc), exc.evidence) from exc validate_pro_context_preflight(config) - if config.transport == "devspace": + if config.transport in STATE.DEVSPACE_TRANSPORTS: readiness = assess_submission_readiness( config, mode="inspect", diff --git a/bin/chatgpt_oracle_state.py b/bin/chatgpt_oracle_state.py index c08c8c89..4dc8287c 100644 --- a/bin/chatgpt_oracle_state.py +++ b/bin/chatgpt_oracle_state.py @@ -2,11 +2,13 @@ import ctypes import hashlib +import importlib.util import json import os import re import shutil import subprocess +import sys import tempfile import threading import time @@ -20,6 +22,27 @@ SCHEMA = "codex.chatgpt.oracle-run/v1" DEVSPACE_APP_NAME = "DevSpace" +# Exactly three transports. pro-devspace is the qualified Pro default route +# (DevSpace mention, exact-root write authority, no attachments or context +# packets); pro-attachment-only is the frozen immutable-evidence Pro route; +# devspace is the regular route. +PRO_TRANSPORTS = frozenset(("pro-devspace", "pro-attachment-only")) +DEVSPACE_TRANSPORTS = frozenset(("devspace", "pro-devspace")) +TRANSPORTS = frozenset(("devspace", "pro-devspace", "pro-attachment-only")) + + +def is_pro_transport(transport: str) -> bool: + return str(transport or "").strip().casefold() in PRO_TRANSPORTS + + +def is_pro_devspace_transport(transport: str) -> bool: + return str(transport or "").strip().casefold() == "pro-devspace" + + +def is_attachment_transport(transport: str) -> bool: + return str(transport or "").strip().casefold() == "pro-attachment-only" + + REGULAR_MODEL = "gpt-5.6" REGULAR_MODEL_STRATEGY = "select" REGULAR_THINKING_TIME = "extra-high" @@ -510,10 +533,16 @@ def load_manifest( if mode != "browser": raise OracleStateError("MODE_INVALID", "Oracle foundation runner supports mode=browser only") transport = str(payload.get("transport") or "devspace").strip().casefold() - if transport not in {"devspace", "pro-attachment-only"}: - raise OracleStateError("TRANSPORT_INVALID", "transport must be devspace or pro-attachment-only") + if transport not in TRANSPORTS: + raise OracleStateError( + "TRANSPORT_INVALID", + "transport must be devspace, pro-devspace, or pro-attachment-only", + ) app_name_raw = str(payload.get("app_name") or "").strip().lstrip("@").strip() - if transport == "devspace": + if transport in DEVSPACE_TRANSPORTS: + # devspace and pro-devspace share the exact DevSpace boundary: the + # mission must stay inside project_root, the app name must be exactly + # DevSpace, and attachments or context packets are forbidden. if not is_within(project_root, mission_path): raise OracleStateError("MISSION_OUTSIDE_PROJECT", "mission_path must stay inside project_root") if not app_name_raw or APP_RE.fullmatch(app_name_raw) is None: @@ -521,11 +550,16 @@ def load_manifest( if app_name_raw != DEVSPACE_APP_NAME: raise OracleStateError( "DEVSPACE_APP_REQUIRED", - f"new non-Pro Oracle runs require the exact app name {DEVSPACE_APP_NAME}", + f"new Oracle runs require the exact app name {DEVSPACE_APP_NAME}", {"app_name": app_name_raw}, ) app_name: str | None = app_name_raw if payload.get("attachments"): + if transport == "pro-devspace": + raise OracleStateError( + "PRO_DEVSPACE_ATTACHMENTS_FORBIDDEN", + "Pro DevSpace runs must not attach files", + ) raise OracleStateError("REGULAR_ATTACHMENTS_FORBIDDEN", "DevSpace runs must not attach files") if payload.get("project_context_manifest_path") not in {None, ""}: raise OracleStateError( @@ -639,11 +673,11 @@ def load_manifest( "THINKING_TIME_INVALID", "thinking_time must be light, standard, extended, heavy, or extra-high", ) - if transport == "pro-attachment-only": + if is_pro_transport(transport): if model.casefold() != "gpt-5.6-sol": raise OracleStateError( "PRO_MODEL_INVALID", - "Pro attachment-only runs require GPT-5.6 Sol with an explicitly verified Pro effort; no downgrade is allowed", + "Pro runs require GPT-5.6 Sol with an explicitly verified Pro effort; no downgrade is allowed", {"model": model}, ) if model_strategy != "select": @@ -697,8 +731,8 @@ def load_manifest( research = str(payload.get("research") or "off").strip().casefold() if research not in {"off", "deep"}: raise OracleStateError("RESEARCH_INVALID", "research must be off or deep") - if transport == "pro-attachment-only" and research != "off": - raise OracleStateError("PRO_RESEARCH_FORBIDDEN", "Pro attachment-only runs do not enable research mode") + if is_pro_transport(transport) and research != "off": + raise OracleStateError("PRO_RESEARCH_FORBIDDEN", "Pro runs do not enable research mode") archive = str(payload.get("archive") or "auto").strip().casefold() if archive not in {"auto", "always", "never"}: raise OracleStateError("ARCHIVE_INVALID", "archive must be auto, always, or never") @@ -708,7 +742,7 @@ def load_manifest( "TASK_OUTCOME_CONTRACT_INVALID", "task_outcome_contract must be legacy or v1", ) - if transport == "pro-attachment-only" and task_outcome_contract != "legacy": + if is_attachment_transport(transport) and task_outcome_contract != "legacy": raise OracleStateError( "PRO_TASK_OUTCOME_CONTRACT_FORBIDDEN", "Pro attachment-only output is not wrapped in the DevSpace task outcome contract", @@ -758,8 +792,46 @@ def load_manifest( ) +PROFILES_PATH = Path(__file__).resolve().parent / "chatgpt_oracle_profiles.py" + + +def _load_profiles_module() -> Any: + """Load the sibling profiles module with the parent-runner loader pattern. + + The pro-devspace composer text is defined exactly once in + ``chatgpt_oracle_profiles.py``; this module reuses it so the + write-authority wording cannot drift. profiles.py has no local + dependencies, so there is no import cycle. + """ + if PROFILES_PATH.is_symlink() or not PROFILES_PATH.is_file(): + raise OracleStateError( + "ORACLE_PROFILES_MODULE_MISSING", + "the installed Oracle profiles module is missing; reinstall before running a qualified Pro lane", + {"profiles_path": str(PROFILES_PATH)}, + ) + spec = importlib.util.spec_from_file_location("chatgpt_oracle_profiles_state_runtime", PROFILES_PATH) + if spec is None or spec.loader is None: + raise OracleStateError( + "ORACLE_PROFILES_MODULE_UNLOADABLE", + "the installed Oracle profiles module could not be prepared for import", + {"profiles_path": str(PROFILES_PATH)}, + ) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + except Exception as exc: + sys.modules.pop(spec.name, None) + raise OracleStateError( + "ORACLE_PROFILES_MODULE_UNLOADABLE", + "the installed Oracle profiles module could not be imported", + {"profiles_path": str(PROFILES_PATH), "error": str(exc)}, + ) from exc + return module + + def composer_prompt(config: OracleConfig, mission_path: Path | None = None) -> str: - if config.transport == "pro-attachment-only": + if is_attachment_transport(config.transport): identity_material = "\0".join(( str(config.project_root).casefold(), config.mission_sha256, @@ -772,6 +844,11 @@ def composer_prompt(config: OracleConfig, mission_path: Path | None = None) -> s f"Task identity: oracle-pro-{identity}." ) effective_path = config.mission_path if mission_path is None else mission_path + if is_pro_devspace_transport(config.transport): + # The qualified Pro route reuses the single one-line DevSpace handoff + # (app mention, explicit write authority, trailing absolute mission + # path) from the profiles module. + return _load_profiles_module().pro_devspace_composer_handoff(effective_path, config.project_root) # Single composer authority for regular GPT-5.6 Sol runs: exactly the app # mention plus the absolute UTF-8 mission path, with no task body and no # operational prose. Keep the Windows npx.cmd prompt in one argument @@ -829,7 +906,7 @@ def state_payload(config: OracleConfig, layout: RunLayout, *, status: str, resol ), "transport_status": "prepared", "task_outcome_contract": config.task_outcome_contract, - "task_outcome": "not_applicable" if config.transport == "pro-attachment-only" else "pending", + "task_outcome": "not_applicable" if is_attachment_transport(config.transport) else "pending", "task_outcome_reason": None, "mission": { "path": str(config.mission_path), @@ -1123,9 +1200,22 @@ def _comprehensive_no_submission_evidence(state_path: Path) -> dict[str, Any] | 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()} + profile = state.get("profile") if isinstance(state.get("profile"), dict) else {} + app_route_contract = ( + str(state.get("app_name") or "").casefold() == DEVSPACE_APP_NAME.casefold() + and ( + str(state.get("transport") or "").casefold() == "devspace" + or ( + # The qualified Pro route mentions the same app, so it emits the + # same pre-send rejection. Bind it to its exact Pro profile. + 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" + ) + ) + ) app_route_unconfirmed = ( - str(state.get("transport") or "").casefold() == "devspace" - and str(state.get("app_name") or "").casefold() == "devspace" + app_route_contract and normalize_oracle_version(oracle.get("resolved_version")) in ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_PROOF_VERSIONS and { @@ -1494,14 +1584,28 @@ def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] 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 str(state.get("transport") or "").casefold() != "devspace" - or str(state.get("app_name") or "").casefold() != "devspace" + or not app_route_contract ): return None run_dir = state_path.parent @@ -1798,12 +1902,19 @@ def proven_pre_submit_ui_failure(state_path: Path) -> dict[str, Any] | None: and str(profile.get("model") or "").casefold() == "gpt-5.6" and str(profile.get("thinking_time") or "").casefold() == "extra-high" ) - pro_contract = ( + pro_attachment_contract = ( str(state.get("transport") or "").casefold() == "pro-attachment-only" and state.get("app_name") is None and str(profile.get("model") or "").casefold() in {"gpt-5.5-pro", "gpt-5.6-sol"} and str(profile.get("thinking_time") or "").casefold() == "heavy" ) + pro_devspace_contract = ( + str(state.get("transport") or "").casefold() == "pro-devspace" + and str(state.get("app_name") or "").casefold() == DEVSPACE_APP_NAME.casefold() + and str(profile.get("model") or "").casefold() == "gpt-5.6-sol" + and str(profile.get("thinking_time") or "").casefold() == "heavy" + ) + pro_contract = pro_attachment_contract or pro_devspace_contract if ( str(state.get("session_authority") or "") not in {"pre_submit", "submitted_unknown"} or state.get("terminal_harvested") is True @@ -2099,7 +2210,7 @@ def proven_pre_submit_host_failure(state_path: Path) -> dict[str, Any] | None: return None normalized_error = stderr_text.lstrip() if ( - str(state.get("transport") or "") == "pro-attachment-only" + is_attachment_transport(str(state.get("transport") or "")) and "The following files exceed the 1 MB limit:" in normalized_error and attachment_limit_banner_only ): @@ -2214,12 +2325,19 @@ def proven_pre_submit_thinking_time_failure(state_path: Path) -> dict[str, Any] and str(profile.get("model") or "").casefold() == "gpt-5.6" and str(profile.get("thinking_time") or "").casefold() == "extra-high" ) - pro_contract = ( + pro_attachment_contract = ( str(state.get("transport") or "").casefold() == "pro-attachment-only" and state.get("app_name") is None and str(profile.get("model") or "").casefold() == "gpt-5.6-sol" and str(profile.get("thinking_time") or "").casefold() == "heavy" ) + pro_devspace_contract = ( + str(state.get("transport") or "").casefold() == "pro-devspace" + and str(state.get("app_name") or "").casefold() == DEVSPACE_APP_NAME.casefold() + and str(profile.get("model") or "").casefold() == "gpt-5.6-sol" + and str(profile.get("thinking_time") or "").casefold() == "heavy" + ) + pro_contract = pro_attachment_contract or pro_devspace_contract if not (regular_contract or pro_contract): return None artifacts = state.get("artifacts") if isinstance(state.get("artifacts"), dict) else {} @@ -2627,7 +2745,7 @@ def _only_bounded_reference_definitions(lines: list[str]) -> bool: def classify_task_outcome(path: Path, *, contract: str, transport: str) -> str: - if transport == "pro-attachment-only": + if is_attachment_transport(transport): return "not_applicable" try: text = path.read_text(encoding="utf-8", errors="strict") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3006d4ed..b4599e28 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,36 @@ README는 현재 제품의 목적과 사용법만 설명합니다. 구현 변경 ## 현재 릴리스 +### 1.9.0 명시적 Pro 읽기·쓰기 정책 + +- 부모 1.14.0의 명시적 Pro 읽기·쓰기 정책을 이식했습니다. 일반 웹 작업은 + `gpt-5.6`의 지원 최상위 Pro 추론 등급 `extra-high`(보이는 `Power 4 of 5`)를 + 기본으로 사용하며 자동으로 Pro로 승격하지 않습니다. Pro는 일일 할당량 + 제한이 있으므로 사용자가 명시적으로 요청할 때만 선택됩니다. +- 새 qualified Pro 실행은 `pro-devspace` transport를 사용합니다. `@DevSpace` + 멘션과 절대 미션 경로만 전송하며, exact project root 안에서 미션이 지시한 + 파일 쓰기와 명령 실행을 수행할 수 있습니다. 저장소 안전성 규칙은 여전히 + 상위이며, 계정·ChatGPT 앱 설정·외부 상태 변경은 미션이 명시적으로 승인할 + 때만 허용됩니다. +- 쓰기 경로에서도 exact root 경계(`DEVSPACE_APP_REQUIRED`, + `MISSION_OUTSIDE_PROJECT`, `HOST_STATE_OVERLAPS_PROJECT`), DevSpace + readiness preflight, 프로젝트 잠금·미션 해시 재검증, `Power 5 of 5` 증명이 + 그대로 유지됩니다. +- `pro-attachment-only`는 별개의 명시적 불변 증거 경로로 남습니다. 기존 의미와 + 복구 계약은 100% 보존되며 자동 fallback이 아닙니다. +- 표준 종합 워크플로는 manifest에 `allow_pro: true`가 있을 때만 plan의 Pro + 전환을 허용합니다. 그 값은 사용자의 명시적 요청 이후에만 넣습니다. +- `pro-devspace`에 첨부를 선언하면 `PRO_DEVSPACE_ATTACHMENTS_FORBIDDEN`, + 미승인 plan→Pro 전환은 `PRO_EXPLICIT_OPT_IN_REQUIRED`로 차단합니다. +- Pro 스킬(`chatgpt-pro-browser`) 자동 호출을 금지했습니다 + (`allow_implicit_invocation: false`). Pro는 사용자가 명시적으로 스킬을 + 요청할 때만 실행됩니다. +- 모든 launch 계약에 `pro_selection_policy: explicit-only`를 추가했고(manual + 포함), transport 집합은 `devspace`/`pro-devspace`/`pro-attachment-only` + 정확히 3개입니다. 설치 manifest routing과 릴리스를 1.9.0으로 갱신했습니다. + +## 이전 릴리스 + ### 1.8.1 Oracle 0.17.3 승격 - 프로젝트 릴리스를 1.8.1로 올리고 신규 실행을 해시 검증한 Oracle @@ -32,8 +62,6 @@ README는 현재 제품의 목적과 사용법만 설명합니다. 구현 변경 정확한 최상위 문자열 리터럴 한 쌍으로만 인정하며, 검증 하나라도 실패하면 Oracle readback을 내보내지 않습니다. -## 이전 릴리스 - ### 1.8.0 상위 런타임 갱신 - 지원 Node.js 범위를 `>=24 <27`로 올리고 프로젝트 릴리스를 1.8.0으로 @@ -115,7 +143,9 @@ README는 현재 제품의 목적과 사용법만 설명합니다. 구현 변경 - 일반 GPT, 계획, 검토, 수정, 지휘, 심층 리서치, 종합모드와 Web Multi-GPT를 Oracle + DevSpace로 통일했습니다. -- Pro는 Oracle 첨부 전용이며 DevSpace를 사용하지 않습니다. +- Pro 증거 경로(`pro-attachment-only`)는 Oracle 첨부 전용이며 DevSpace를 + 사용하지 않습니다. qualified Pro(`pro-devspace`)는 DevSpace 멘션으로 exact + project root 안에서 미션 범위 쓰기를 수행합니다. - 신규 제출은 Oracle과 DevSpace 경로만 사용합니다. ### Windows 브라우저 실행 격리 diff --git a/docs/GLOBAL_CHATGPT_ROUTING.md b/docs/GLOBAL_CHATGPT_ROUTING.md index e262d8af..6c642420 100644 --- a/docs/GLOBAL_CHATGPT_ROUTING.md +++ b/docs/GLOBAL_CHATGPT_ROUTING.md @@ -12,18 +12,25 @@ Use this routing in the Codex global `AGENTS.md` after installing the package. the manually registered DevSpace app. - Regular web work selects `GPT-5.6 Sol` with Oracle `extra-high` and verifies the visible `Extra High` tier. It does not silently fall back to High or another - model. + model, and it never promotes to Pro automatically. - The regular composer contains only `@DevSpace` and an absolute UTF-8 mission path. It does not attach the task body and does not inspect or mutate ChatGPT app settings per question. -- Pro also uses Oracle. It is attachment-only, selects the account-visible Pro - model, and uses no DevSpace or another app. +- Pro also uses Oracle and is selected only on an explicit user request. + Qualified Pro uses the `pro-devspace` transport: it mentions DevSpace, writes + mission-directed files, and runs the mission's commands inside the exact + project root. Explicit immutable-evidence Pro uses the `pro-attachment-only` + transport with hash-frozen attachments and no app. - Comprehensive stages author the next semantic mission and a bound hash receipt. Local Codex owns transport, immutable identity, host safety, and one - final deterministic gate rather than rewriting web output. + final deterministic gate rather than rewriting web output. A comprehensive + workflow schedules a Pro stage only when its manifest sets `allow_pro: true`, + a value the host writes only after an explicit user request. - An optional Oracle Pro stage returns one strict identity-bound JSON envelope; - the host materializes its output and next-mission strings byte-for-byte - because Pro does not use DevSpace. + the host materializes its output and next-mission strings byte-for-byte. + Qualified `pro-devspace` stages inherit the DevSpace exact-root and + outcome-contract rules; `pro-attachment-only` stages keep their forced + legacy `not_applicable` classification. - Genuine Web Multi-GPT uses distinct Oracle sessions. Windows lanes use independent throwaway copies of the signed-in Oracle profile, run in waves of at most five, and hand compact files to one merger. @@ -34,9 +41,12 @@ Use this routing in the Codex global `AGENTS.md` after installing the package. ## Standalone Pro versus comprehensive `chatgpt-pro-browser` is the visible standalone Pro skill. It submits one -attachment-only Oracle Pro session, saves the durable result, returns it to the -calling Codex task, and stops. It never starts implementation or another web -stage automatically. +explicit immutable-evidence Oracle Pro session (`pro-attachment-only`), saves +the durable result, returns it to the calling Codex task, and stops. Qualified +Pro (`pro-devspace`) is the companion write route: it uses the `@DevSpace` +mention and the absolute mission path and performs mission-scoped writes and +commands inside the exact project root. Neither route starts implementation or +another web stage automatically. `chatgpt-pro-plan-handoff` owns comprehensive mode. Only that staged runner may place an optional Pro decision between plan and review and continue afterward diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 599f9d10..f6216570 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,10 +1,11 @@ # Release checklist -- Confirm package metadata remains `1.8.1` with Node.js `>=24 <27`. +- Confirm package metadata remains `1.9.0` with Node.js `>=24 <27`. - Confirm new runs pin Oracle `0.17.3`; exact recovery accepts only `0.16.1`, `0.17.0`, `0.17.1`, `0.17.2`, and `0.17.3`. - Confirm DevSpace setup pins `1.0.7` and both npm integrities match `install-manifest.json`. -- Confirm regular routes use `GPT-5.6 Sol` and `extra-high` with the visible `Power 4 of 5` proof; Pro remains attachment-only `gpt-5.6-sol` and `heavy` with the `Power 5 of 5` proof. -- Confirm no route enters Web Multi automatically or uses another backend as a fallback. +- Confirm regular routes use `GPT-5.6 Sol` and `extra-high` with the visible `Power 4 of 5` proof and never promote to Pro automatically; both Pro transports use `gpt-5.6-sol` and `heavy` with the `Power 5 of 5` proof — qualified `pro-devspace` (DevSpace mention, mission-scoped writes inside the exact root) and evidence `pro-attachment-only` (hash-frozen attachments, no app). +- Confirm every launch contract carries `pro_selection_policy: explicit-only`, including `manual`, and that the comprehensive manifest requires `allow_pro: true` for any plan-to-Pro transition. +- Confirm no route enters Web Multi automatically or uses another backend as a fallback, and that `pro-attachment-only` is never an automatic fallback. - Confirm WAL v3 and receipt v4 removal migration, crash recovery, rollback, and uninstall in a temporary `CODEX_HOME`. - Confirm modified, symlinked, unowned, active-run, and ambiguous-receipt inputs fail before mutation and preserve user bytes. - Confirm removed paths have zero imports, callers, manifest/package/workflow entries, fixture/schema references, and tests. diff --git a/docs/VS_UPSTREAM.md b/docs/VS_UPSTREAM.md index ef427200..bf6289da 100644 --- a/docs/VS_UPSTREAM.md +++ b/docs/VS_UPSTREAM.md @@ -5,7 +5,7 @@ This file binds package compatibility to exact public artifacts. The local fork ## A. Fork versus parent project - Local ref: the exact commit containing this document in `1Morganmore/DevSpace-Oracle`. -- Parent main observed and audited on 2026-08-13: `ventianima-lab/codex-web-gpt-automation@9bd6843ee9424b260cdc6968feace2bb46ef1ceb`. +- Parent main observed and audited on 2026-08-14: `ventianima-lab/codex-web-gpt-automation@66c23f170661e36bfc9d0f970c01dd4234863d6c` (parent 1.14.0). Previous audited head: `9bd6843ee9424b260cdc6968feace2bb46ef1ceb` (2026-08-13). - Last direct integration baseline: `9542abeef6aa544f4ee6af03bab61cef3474f9e4`. - Merge-base before this release commit: `250b839a559cb61442feeb64bff6d49dfa185169`. - Observed ancestry immediately before this release commit: 47 commits ahead, 61 behind. @@ -13,12 +13,41 @@ This file binds package compatibility to exact public artifacts. The local fork The fork keeps Oracle-only browser submission, DevSpace workspace transport, exact-slug recovery, per-project locks, receipt-owned installation, Windows process/profile isolation, and bounded Web Multi sessions. Parent sync must preserve monotonic terminal authority, exact package/hash gating, host-only state, mutex/schema/receipt identities, and the absence of automatic Web Multi or alternate-backend fallback. -The nine commits after `9542abee` were reviewed individually. The selector-proof changes in `51675967` and `d8f8fac1` are self-developed on the active fork Power patch: only visible picker candidates count, all three independent Pro signals are required, and two consecutive observations are required before an already-selected result. The existing final diagnostic-race fallback remains intact. The recovered-lane and merger-resume changes in `39f750f4` and `916aeffb` are deferred because they require adaptation to this fork's manifest hashes, parent lock, terminal seal, and monotonic exact-session authority. The persisted `allowedRoots` doctor readback from `69ad58c2` is selected. The `075b3719` change is intentionally rejected: making read-only DevSpace the default Pro route violates this fork's Pro contract, which is Oracle attachment-only with no app. The later `6d24cce` Funnel recovery is self-developed rather than cherry-picked: this fork's explicit `ensure` command waits for the exact local `/healthz` identity, creates only an absent exact mapping, refuses conflicts, reads back any change, and proves the public `/healthz` identity. It does not accept permissive `/mcp` status codes or claim automatic login recovery without a supervised startup path. The `ae3b3caf` OAuth fix is also self-developed on the pinned 1.0.6 launch path: all four managed `serve` entries advertise `devspace,offline_access`, while ChatGPT reconnect or recreation remains a manual settings action. The `77bb79af` task-outcome change is not applied because it governs the same rejected `pro-devspace-readonly` transport; this fork has no such route, keeps Pro attachment-only with the legacy non-DevSpace outcome contract, and already requires v1 task outcomes for regular DevSpace runs. +The nine commits after `9542abee` were reviewed individually. The selector-proof changes in `51675967` and `d8f8fac1` are self-developed on the active fork Power patch: only visible picker candidates count, all three independent Pro signals are required, and two consecutive observations are required before an already-selected result. The existing final diagnostic-race fallback remains intact. The recovered-lane and merger-resume changes in `39f750f4` and `916aeffb` are deferred because they require adaptation to this fork's manifest hashes, parent lock, terminal seal, and monotonic exact-session authority. The persisted `allowedRoots` doctor readback from `69ad58c2` is selected. The `075b3719` read-only-DevSpace Pro default remains rejected: it was audited against the then attachment-only Pro contract, and the parent 1.14.0 adoption below creates a DevSpace-based Pro *write* route (`pro-devspace`), not a read-only one. The later `6d24cce` Funnel recovery is self-developed rather than cherry-picked: this fork's explicit `ensure` command waits for the exact local `/healthz` identity, creates only an absent exact mapping, refuses conflicts, reads back any change, and proves the public `/healthz` identity. It does not accept permissive `/mcp` status codes or claim automatic login recovery without a supervised startup path. The `ae3b3caf` OAuth fix is also self-developed on the pinned 1.0.6 launch path: all four managed `serve` entries advertise `devspace,offline_access`, while ChatGPT reconnect or recreation remains a manual settings action. The `77bb79af` task-outcome change is not applied: it governs the `pro-devspace-readonly` transport this fork does not have and did not create. This fork's `pro-devspace` inherits the `devspace` outcome policy (the caller chooses `legacy` or `v1`; dispatch uses `v1` and comprehensive uses `legacy` like every other stage), while `pro-attachment-only` keeps its forced legacy `not_applicable` classification. The 30 commits between `77bb79af` and `54a6b6e` were audited on 2026-08-13. Selected: the manual-login pre-submit proof (`b9cdf382` releases the proven Oracle pre-submit profile locks) and the bounded reference-footer classification (`07ca3394`). Deferred until receipt-bound absolute runtimes, minimal-authority environment, live root/runtime attestation, and a hard private first-init boundary exist: the explicit post-register stabilization with exact slot recycle (`63a61fad` settles DevSpace only after ChatGPT app registration and `8ea49f2b` recovers the stale Funnel onto the exact slot after reconnect), the auth-preserving existing-DevSpace-config persistence and CDP bootstrap fragments of `fc454da1`, and the `MULTI_GPT_CODEX_CLI_PATH`/`CODEX_CLI_PATH` runtime override fragment of `e8105e6d`. Also deferred: the drive-root persistence hygiene of `94f7dc70`, the fail-closed ultra-economy mode (`26595b76`), and the onboarding/global-agent installs and the Local Multi-GPT optional-install flip (`0f4dcb36`, `f75c9cc6`, `e8105e6d`) — Local Multi remains an always-installed manifest member. Rejected: the POSIX command-line launcher identity of `51d2e415` as-is (including its DevSpace 1.0.4/hash-downgrade fragment) — a command-line-shaped launcher identity must not carry launch authority; Oracle/DevSpace downgrade acceptance (`4c3938a9`), configurable workspace app names (`b7b1766a`), `pro-devspace-readonly` (`eb423a53`), and the macOS/Cloudflare/rebrand/legacy-installer work (`9da21bbc`, `6af6d17e`, `c78302c7`, `84f3e027`) which targets platforms and packaging this fork does not ship. The single commit after `54a6b6e`, `9bd6843e` ("feat: complete safe first-install onboarding"), was audited on 2026-08-13. Selected: the strict UTF-8 Tailscale status decoding — `encoding="utf-8"` with `errors="strict"` on the `tailscale status` and hostname-discovery captures — which only controls deterministic JSON decoding of the Tailscale status fragment and adds no launch authority. Deferred: the native-runtime probe (`--check-native-runtime`/`DEVSPACE_NATIVE_BINDING_UNAVAILABLE`) until it loads better-sqlite3 through a receipt-bound Node runtime and fully verified package extraction instead of ambient `shutil.which("node")` resolution against candidate package roots. Rejected: the TTY-as-human-boundary for first init (`DEVSPACE_FIRST_INIT_REQUIRES_INTERACTIVE_TTY`) because an agent-owned PTY can still capture the Owner password that the follow-on review prints; the owner-password review concept remains human-only/manual, run by the person in their own terminal (`owner-password`), and is not adopted as an automation step. Rejected/N-A: the ultra-economy one-time conversational handshake (it replaces the exact runtime-identity proof with an unverifiable conversation acknowledgment), the configurable app name (`--app-name` relaxing the fixed `codex` identity), and upstream versioning (the install-manifest/package `1.12.1` → `1.13.0` bump is upstream's own versioning and is not adopted). +The parent head was advanced and re-audited on 2026-08-14 at +`66c23f17` (parent 1.14.0), and its explicit Pro read/write policy was +adopted as release 1.9.0: + +(i) The explicit Pro read/write policy is adopted. Regular web work stays on the +supported top-tier `extra-high` (`Power 4 of 5`) and never promotes to Pro +automatically; Pro has a limited daily allowance and is selected only on an +explicit user request. Qualified Pro runs use the new `pro-devspace` transport +with mission-scoped writes and commands inside the exact project root, and the +standard comprehensive workflow schedules a Pro stage only when its manifest +sets `allow_pro: true` (written only after an explicit user request). + +(ii) This fork has never had a `pro-devspace-readonly` transport and does not +create one. This fork's legacy Pro history is `pro-attachment-only`; its +meaning and exact recovery contract are preserved as the explicit +immutable-evidence route, and it is never an automatic fallback. + +(iii) `task_outcome_contract` intentionally differs from the parent: on this +fork `pro-devspace` inherits the same policy as `devspace` — the caller chooses +`legacy` or `v1` (dispatch uses `v1`; comprehensive uses `legacy` like every +other stage) — because our comprehensive workflow binds every stage to the +legacy contract. Only `pro-attachment-only` keeps the forced legacy +`not_applicable` classification. + +(iv) Parent 1.13.1's long-running state-checkpoint change is not mirrored: the +fork's existing structure — one overall `--browser-timeout` answer budget, the +host watchdog's `attention_required` outcome, and monotonic exact-session +ownership — already delivers the same user-visible result. + ```powershell git fetch https://github.com/ventianima-lab/codex-web-gpt-automation main git rev-parse HEAD @@ -112,11 +141,14 @@ the strict patch is applied; unknown bytes always fail closed. Oracle 0.17.3 treats GPT-5.6 Sol effort as a visible Power slider. Regular runs are the single supported `extra-high` tier and require the visible `Power 4 of 5` proof before send; misleading `Medium` or `High` -aliases are rejected without silent downgrade. Pro remains attachment-only -with no DevSpace or app: it uses the same account-visible `gpt-5.6-sol` model -with `heavy` (Oracle's internal token) and requires the full `Power 5 of 5` -(`Pro`) proof including the hidden-stale-picker and Unicode-label handling -from the donor. A proven model-switcher, profile-flag, or effort +aliases are rejected without silent downgrade. Both Pro transports use the +same account-visible `gpt-5.6-sol` model with `heavy` (Oracle's internal +token) and require the full `Power 5 of 5` (`Pro`) proof including the +hidden-stale-picker and Unicode-label handling from the donor. The evidence +route (`pro-attachment-only`) is attachment-only with no DevSpace or app; the +qualified write route (`pro-devspace`) sends the DevSpace mention plus the +mission path and adds no attachments. A proven model-switcher, profile-flag, +or effort selection-unverified failure settles only while the conversation URL and any durable output are absent, and monotonic exact-session authority is never regressed. diff --git a/install-manifest.json b/install-manifest.json index 73e7df63..e8a82bca 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -1,6 +1,6 @@ { "schema": "codexpro.install-manifest/v1", - "version": "1.8.1", + "version": "1.9.0", "include": [ "bin/chatgpt_oracle_run.py", "bin/chatgpt_devspace_compat.py", @@ -124,7 +124,8 @@ "routing": { "new_work_engine": "oracle", "regular_workspace_transport": "devspace", - "pro_transport": "oracle-attachment-only" + "pro_transport": "oracle-pro-devspace", + "pro_evidence_transport": "oracle-pro-attachment-only" }, "external": { "oracle": { diff --git a/package-lock.json b/package-lock.json index ec5dd289..2efeaeea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexpro-automation", - "version": "1.8.1", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexpro-automation", - "version": "1.8.1", + "version": "1.9.0", "license": "MIT", "engines": { "node": ">=24 <27" diff --git a/package.json b/package.json index 574494e1..01337560 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexpro-automation", - "version": "1.8.1", + "version": "1.9.0", "private": false, "description": "Windows orchestrator that delegates Codex planning, research, implementation, and review to web ChatGPT through Oracle and DevSpace", "license": "MIT", diff --git a/skills/chatgpt-oracle-runtime/SKILL.md b/skills/chatgpt-oracle-runtime/SKILL.md index 1544eea2..f7b69ef7 100644 --- a/skills/chatgpt-oracle-runtime/SKILL.md +++ b/skills/chatgpt-oracle-runtime/SKILL.md @@ -1,12 +1,16 @@ --- name: chatgpt-oracle-runtime -description: "Current Oracle runtime path for new ChatGPT work: regular modes use the manually registered DevSpace app, Pro is attachment-only, and it includes recovery, comprehensive relay, and genuine multi-session Web Multi-GPT." +description: "Current Oracle runtime path for new ChatGPT work: regular modes use the manually registered DevSpace app, qualified Pro uses the pro-devspace write transport, evidence Pro uses pro-attachment-only, and it includes recovery, comprehensive relay, and genuine multi-session Web Multi-GPT." --- # ChatGPT Oracle Runtime -This is the only active browser path for all GPT work. Regular modes use DevSpace; -Pro uses Oracle attachment transport without any app. New runs pin Oracle +This is the only active browser path for all GPT work. Regular modes use +DevSpace. Qualified Pro uses the `pro-devspace` transport — the DevSpace +mention plus the absolute mission path, with mission-scoped file writes and +command execution confined to the exact project root. Explicit +immutable-evidence Pro uses the `pro-attachment-only` transport without any +app. New runs pin Oracle `0.17.3`; Oracle `0.16.1`, `0.17.0`, `0.17.1`, and `0.17.2` are accepted only when recovering an exact run already persisted with that version. Oracle 0.17.3's upstream answer-placeholder bounding, manual-login reattach @@ -24,8 +28,12 @@ path — no task body and no extra operational prose. The web GPT must use only the exact project root recorded in that mission, read the mission and applicable `AGENTS.md` completely first, and may retry that same root once after a timeout. It must not substitute a parent, child, active workspace, or shell boundary -workaround. Pro selects `gpt-5.6-sol` with `heavy` and sends one short -instruction plus exact attachment files; it never mentions DevSpace. +workaround. Evidence Pro (`pro-attachment-only`) selects `gpt-5.6-sol` with +`heavy` and sends one short +instruction plus exact attachment files; it never mentions DevSpace. Qualified +Pro (`pro-devspace`) selects the same model and effort, mentions DevSpace, and +sends the absolute mission path with no attachments. Pro runs only on an +explicit user request; no route promotes itself to Pro automatically. Regular routes use the single supported `extra-high` tier and require Oracle evidence for the visible `Extra High` (`Power 4 of 5`). Never invent xhigh, use `Medium`/`High`, or silently downgrade. @@ -40,13 +48,20 @@ Require schema `codex.chatgpt.oracle-run/v1` with: - `mission_path` plus caller-pinned `mission_sha256`: absolute UTF-8 regular file inside the project and its exact bytes. - `app_name`: one-line app name, without a leading `@`, for regular routes. -- `task_kind: pro` plus one or more exact `attachments`, ordered - `attachment_sha256s`, `project_context_manifest_path`, and - `project_context_manifest_sha256` for Pro. +- `task_kind: pro` for Pro. The evidence route (`pro-attachment-only`) adds + one or more exact `attachments`, ordered `attachment_sha256s`, + `project_context_manifest_path`, and `project_context_manifest_sha256`; the + qualified route (`pro-devspace`) forbids attachments and + `project_context_manifest_*` fields + (`PRO_DEVSPACE_ATTACHMENTS_FORBIDDEN`). - `mode`: `browser`. - Optional `run_root`, `oracle_command`, `oracle_args`, `thinking_time`, hash-validated `copy_profile`, and mutex timeout. -- Regular direct/orchestrator manifests use `task_outcome_contract: "v1"`. +- Regular direct/orchestrator manifests use `task_outcome_contract: "v1"`; + qualified `pro-devspace` manifests follow the same caller-chosen `legacy` or + `v1` rule (dispatch uses `v1`, comprehensive uses `legacy`). Evidence + `pro-attachment-only` keeps the forced legacy contract with a + `not_applicable` classification. ## Run @@ -106,7 +121,9 @@ Preflight is advisory evidence, not a reusable authorization. A DevSpace `run` repeats the volatile hostname, Funnel, and strict local/public `/healthz` checks inside the existing project submit mutex immediately before Oracle launch. A failure persists structured readiness evidence and settles as a proven -pre-submit failure without opening a browser or conversation. Pro and exact-run +pre-submit failure without opening a browser or conversation. Qualified Pro +(`pro-devspace`) runs this DevSpace readiness path like regular DevSpace work. +Evidence Pro and exact-run recovery do not use this DevSpace readiness path. Use `chatgpt_oracle_diagnose.py triage --run-dir ` for a bounded next diff --git a/skills/chatgpt-pro-browser/SKILL.md b/skills/chatgpt-pro-browser/SKILL.md index 5de16f8d..d4c6adcc 100644 --- a/skills/chatgpt-pro-browser/SKILL.md +++ b/skills/chatgpt-pro-browser/SKILL.md @@ -13,7 +13,10 @@ and stops. It never starts a review-to-implementation chain, authors a follow-on implementation stage, or invokes `chatgpt-pro-plan-handoff` on its own. If the user asks for comprehensive mode, use `chatgpt-pro-plan-handoff` instead; an optional Pro stage inside that workflow remains owned by the -comprehensive runner. +comprehensive runner. Qualified Pro (`pro-devspace`) is the companion write +route for mission-scoped file writes and commands inside the exact project +root; this skill is the explicit immutable-evidence route +(`pro-attachment-only`) and never mentions DevSpace. Oracle is the only backend for a new Pro run. It owns model selection, exact file attachment, submission, durable output, exact-slug recovery, and one-shot @@ -22,6 +25,13 @@ CDP/Playwright, or `@chrome` fallback. ## Non-negotiable Pro contract +The contract below binds the explicit immutable-evidence route +(`pro-attachment-only`). It runs only on an explicit user request; no route +promotes itself to Pro automatically, and this evidence route is never an +automatic fallback. Qualified Pro (`pro-devspace`) is governed by the DevSpace +exact-root rules in `chatgpt-oracle-runtime` instead of the attachment rules +below. + - `task_kind: pro`. - Select `gpt-5.6-sol` with `heavy` through Oracle; never downgrade to a regular GPT model or silently change the effort. Oracle must prove the diff --git a/skills/chatgpt-pro-browser/agents/openai.yaml b/skills/chatgpt-pro-browser/agents/openai.yaml index bec52beb..25d9036c 100644 --- a/skills/chatgpt-pro-browser/agents/openai.yaml +++ b/skills/chatgpt-pro-browser/agents/openai.yaml @@ -3,4 +3,4 @@ interface: short_description: "Run one-shot attachment-only Pro review through Oracle" default_prompt: "$chatgpt-pro-browser C:\\project\\pro-mission.md C:\\project\\packet.zip" policy: - allow_implicit_invocation: true + allow_implicit_invocation: false diff --git a/skills/chatgpt-pro-plan-handoff/SKILL.md b/skills/chatgpt-pro-plan-handoff/SKILL.md index 76fe6faa..a0f75f7e 100644 --- a/skills/chatgpt-pro-plan-handoff/SKILL.md +++ b/skills/chatgpt-pro-plan-handoff/SKILL.md @@ -1,14 +1,20 @@ --- name: chatgpt-pro-plan-handoff -description: Run staged comprehensive GPT work through Oracle, with optional attachment-only Pro and independent Oracle Web Multi advisory stages, then one deterministic local gate. +description: Run staged comprehensive GPT work through Oracle, with an optional explicitly gated Pro stage and independent Oracle Web Multi advisory stages, then one deterministic local gate. --- # Oracle comprehensive workflow Use `codex.chatgpt.oracle-comprehensive/v1` for new comprehensive work. The active entry point is `%USERPROFILE%\.codex\bin\chatgpt_oracle_comprehensive.py`. -Every regular web stage uses Oracle plus the manually registered DevSpace app; -an optional Pro stage is Oracle attachment-only and selects no app. +Every regular web stage uses Oracle plus the manually registered DevSpace app. +The optional Pro stage runs only when the manifest sets `allow_pro: true` — a +value the host writes only after an explicit user request. It uses the +qualified `pro-devspace` transport by default (`@DevSpace` mention, mission- +scoped writes and commands inside the exact project root, legacy outcome +contract like every other comprehensive stage) or the explicit +`pro-attachment-only` evidence transport when the user asks for attachment +evidence. The stage order is plan -> optional Pro or Oracle Web Multi -> review -> implementation -> final web gate -> one deterministic local gate. @@ -31,7 +37,10 @@ Create an absolute UTF-8 initial mission and a `codex.chatgpt.oracle-comprehensive/v1` manifest inside the exact project root. The manifest binds `workflow_id`, `project_root`, `workflow_dir`, `initial_mission_path`, `initial_mission_sha256`, `app_name`, `model`, and -`local_gate_command`. It may also contain an exact `chatgpt_project_url`. +`local_gate_command`. It may also contain an exact `chatgpt_project_url`. A +Pro stage is scheduled only when the manifest sets `allow_pro: true`; the host +writes that value only after an explicit user request, and no comprehensive +workflow inserts a Pro stage without it. Preview it without submitting: ```powershell diff --git a/skills/chatgpt-thinking-browser/SKILL.md b/skills/chatgpt-thinking-browser/SKILL.md index 7bd37d93..4663a12b 100644 --- a/skills/chatgpt-thinking-browser/SKILL.md +++ b/skills/chatgpt-thinking-browser/SKILL.md @@ -65,9 +65,15 @@ compatibility layer is hash-gated and fails closed on an unknown version or third-party file. Never invent xhigh or silently downgrade. On the current Power-slider UI, Oracle verifies `Power 4 of 5` for regular -`extra-high`; attachment-only Pro uses the same verified `GPT-5.6 Sol` model -with `Power 5 of 5` (the visible `Pro` choice). `heavy` is only Oracle's +`extra-high`; both Pro transports use the same verified `GPT-5.6 Sol` model +with `Power 5 of 5` (the visible `Pro` choice). The evidence route +(`pro-attachment-only`) adds hash-frozen attachments and never mentions +DevSpace; qualified Pro (`pro-devspace`) mentions DevSpace and writes +mission-scoped files and commands inside the exact project root instead. +`heavy` is only Oracle's internal compatibility token for that latter choice, never a claimed UI label. +Regular work never promotes to Pro automatically; Pro runs only on an explicit +user request. Every new run copies the manually signed-in Oracle profile into a throwaway per-run profile and asks Oracle to hide its owned window. This isolates diff --git a/skills/devspace-oracle-router/SKILL.md b/skills/devspace-oracle-router/SKILL.md index 41bbe04d..d522750a 100644 --- a/skills/devspace-oracle-router/SKILL.md +++ b/skills/devspace-oracle-router/SKILL.md @@ -19,8 +19,12 @@ temporary `CODEX_HOME`. On POSIX use `$CODEX_HOME/bin` when set, otherwise - 수정, edit: `--mode edit` - 지휘, 지휘모드, orchestrator: `--mode orchestrator` - 심층 리서치, 딥 리서치, deep research: `--mode deep-research` -- Pro, 프로: `--mode pro`; require the context manifest and attachments, use - attachment-only Oracle, and never select DevSpace or a ChatGPT Project. +- Pro, 프로: `--mode pro`; run only when the user explicitly requests Pro. + Without `--attachment`, it is qualified Pro (`pro-devspace`): the `@DevSpace` + mention plus the mission path, with mission-scoped writes and commands inside + the exact project root. With `--context-manifest` and `--attachment`, it is + the evidence route (`pro-attachment-only`): attachment-only Oracle, and never + select DevSpace or a ChatGPT Project. - 웹 멀티 GPT, Web Multi-GPT: `chatgpt_oracle_multi.py`; require the explicit parallel policy below. - 로컬 멀티 GPT, Local Multi-GPT: use the local `multi_gpt_start`, @@ -35,7 +39,9 @@ temporary `CODEX_HOME`. On POSIX use `$CODEX_HOME/bin` when set, otherwise `$env:USERPROFILE\.codex\skills\chatgpt-workspace-setup\scripts\devspace_tailscale_setup.py`. Regular web modes always select GPT-5.6 Sol and verify visible Extra High. Pro -selects the account-visible Pro tier. Never silently downgrade either route. +selects the account-visible Pro tier and runs only on an explicit user +request. Never silently downgrade either route, and never promote a regular +route to Pro automatically. ## Author and preview diff --git a/tests/test_chatgpt_oracle_comprehensive.py b/tests/test_chatgpt_oracle_comprehensive.py index aa9a79dc..ac812dba 100644 --- a/tests/test_chatgpt_oracle_comprehensive.py +++ b/tests/test_chatgpt_oracle_comprehensive.py @@ -23,12 +23,12 @@ def load(): return module -def manifest(tmp_path: Path) -> Path: +def manifest(tmp_path: Path, *, allow_pro: bool | None = None) -> Path: os.environ["CODEX_ORACLE_STATE_ROOT"] = str((tmp_path.parent / f"{tmp_path.name}-host-state").resolve()) mission = tmp_path / "initial.md" mission.write_text("Plan the work broadly.", encoding="utf-8") path = tmp_path / "workflow.json" - path.write_text(json.dumps({ + payload = { "schema": "codex.chatgpt.oracle-comprehensive/v1", "workflow_id": "a" * 32, "project_root": str(tmp_path.resolve()), @@ -38,7 +38,10 @@ def manifest(tmp_path: Path) -> Path: "app_name": "DevSpace", "model": "gpt-5.6", "local_gate_command": ["python", "-c", "raise SystemExit(0)"], - }), encoding="utf-8") + } + if allow_pro is not None: + payload["allow_pro"] = allow_pro + path.write_text(json.dumps(payload), encoding="utf-8") return path @@ -98,6 +101,22 @@ def test_manifest_rejects_non_devspace_app_before_workflow_creation(tmp_path: Pa module.load_manifest(path) +def test_manifest_requires_allow_pro_to_be_a_boolean_explicit_opt_in(tmp_path: Path) -> None: + module = load() + path = manifest(tmp_path) + assert module.load_manifest(path)["allow_pro"] is False + for invalid in (1, 0, "true", "yes", None, [], {}): + payload = json.loads(path.read_text(encoding="utf-8")) + payload["allow_pro"] = invalid + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(module.WorkflowError, match="allow_pro must be a boolean explicit opt-in"): + module.load_manifest(path) + payload = json.loads(path.read_text(encoding="utf-8")) + payload["allow_pro"] = True + path.write_text(json.dumps(payload), encoding="utf-8") + assert module.load_manifest(path)["allow_pro"] is True + + def test_manifest_snapshot_remains_authoritative_across_mutex_entry(monkeypatch, tmp_path: Path) -> None: module = load() path = manifest(tmp_path) @@ -344,7 +363,7 @@ def fake_execute(path: Path, *, dry_run: bool, **kwargs): if stage == "pro": assert payload["transport"] == "pro-attachment-only" assert payload["model"] == "gpt-5.6-sol" - assert_pro_context(module, payload, mission, (tmp_path / "next-plan.md",)) + assert_pro_context(module, payload, mission, (tmp_path / "plan-evidence.zip",)) assert "app_name" not in payload attempt = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("attempt_id=")) input_sha = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("input_mission_sha256=")) @@ -361,13 +380,135 @@ def fake_execute(path: Path, *, dry_run: bool, **kwargs): "plan": "pro", "review": "implementation", "implementation": "final-web-gate", "final-web-gate": "complete", }[stage] + if stage == "plan": + packet = tmp_path / "plan-evidence.zip" + packet.write_bytes(b"plan evidence packet") + next_mission = _pro_attachment_mission( + module, tmp_path, [{"path": str(packet), "sha256": module.sha(packet)}] + ) + else: + next_mission = tmp_path / f"next-{stage}.md" + next_mission.write_text(f"mission after {stage}", encoding="utf-8") + regular_receipt(mission, stage, next_stage, next_mission) + return {"ok": True, "run_dir": str(mission.parent / "run")} + + result = run_workflow(module, + manifest(tmp_path, allow_pro=True), + oracle_execute=fake_execute, + local_gate_runner=lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, "", ""), + ) + assert result["ok"] is True, result + assert stages == ["plan", "pro", "review", "implementation", "final-web-gate"] + pro_stage = next((tmp_path / "workflow" / "stages").glob("01-pro-*")) + assert (pro_stage / "output.md").read_bytes() == b"Pro decision\nsecond line\n" + assert (pro_stage / "next-mission.md").read_bytes() == b"Review the Pro decision independently.\nPreserve LF.\n" + receipt = json.loads((pro_stage / "stage-result.json").read_text(encoding="utf-8")) + assert receipt["stage"] == "pro" + assert receipt["next_stage"] == "review" + + +@pytest.mark.parametrize("status", ["PASS", "completed"], ids=["plan-ready", "legacy-completed"]) +def test_plan_pro_transition_requires_explicit_opt_in(tmp_path: Path, status: str) -> None: + module = load() + workflow = manifest(tmp_path) + calls = [] + pro_next = tmp_path / "pro-next.md" + + def fake_execute(path: Path, *, dry_run: bool, **kwargs): + payload = json.loads(path.read_text(encoding="utf-8")) + mission = Path(payload["mission_path"]) + text = mission.read_text(encoding="utf-8") + stage = next(item for item in ("plan", "pro") if f"stage={item}\n" in text) + calls.append(stage) + if stage != "plan": + raise AssertionError("Pro stage must never be submitted without explicit opt-in") + attempt = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("attempt_id=")) + input_sha = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("input_mission_sha256=")) + output = mission.parent / "plan-output.md" + output.write_text("plan", encoding="utf-8") + pro_next.write_text("Pro transition without opt-in", encoding="utf-8") + (mission.parent / "stage-result.json").write_text(json.dumps({ + "schema": module.RECEIPT_SCHEMA, "workflow_id": "a" * 32, "stage": "plan", + "attempt_id": attempt, "input_mission_sha256": input_sha, "status": status, + "output_path": str(output), "output_sha256": module.sha(output), + "next_stage": "pro", "next_mission_path": str(pro_next), + "next_mission_sha256": module.sha(pro_next), "ready_for_next": True, "blocker": "", + }), encoding="utf-8") + return {"ok": True, "run_dir": str(mission.parent / "run")} + + with pytest.raises(module.WorkflowError, match="PRO_EXPLICIT_OPT_IN_REQUIRED"): + run_workflow(module, workflow, oracle_execute=fake_execute) + assert calls == ["plan"] + + +def test_pro_devspace_submits_writable_transport_without_attachments_and_materializes_receipt( + monkeypatch, tmp_path: Path +) -> None: + module = load() + stages = [] + real_load_manifest = module.RUNNER.STATE.load_manifest + + def exact_child_load(path: Path, **kwargs): + if path.name == "oracle.json": + assert kwargs.get("expected_manifest_sha256") == module.sha(path) + return real_load_manifest(path, **kwargs) + + monkeypatch.setattr(module.RUNNER.STATE, "load_manifest", exact_child_load) + + def regular_receipt(mission: Path, stage: str, next_stage: str, next_mission: Path) -> None: + text = mission.read_text(encoding="utf-8") + attempt = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("attempt_id=")) + input_sha = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("input_mission_sha256=")) + output = mission.parent / "regular-output.md" + output.write_text(stage, encoding="utf-8") + (mission.parent / "stage-result.json").write_text(json.dumps({ + "schema": module.RECEIPT_SCHEMA, "workflow_id": "a" * 32, "stage": stage, + "attempt_id": attempt, "input_mission_sha256": input_sha, "status": "PASS", + "output_path": str(output), "output_sha256": module.sha(output), + "next_stage": next_stage, "next_mission_path": str(next_mission), + "next_mission_sha256": module.sha(next_mission), "ready_for_next": True, "blocker": "", + }), encoding="utf-8") + + def fake_execute(path: Path, *, dry_run: bool, **kwargs): + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["pro_selection_policy"] == "explicit-only" + mission = Path(payload["mission_path"]) + text = mission.read_text(encoding="utf-8") + stage = next(item for item in ("plan", "pro", "review", "implementation", "final-web-gate") if f"stage={item}\n" in text) + stages.append(stage) + if stage == "pro": + assert payload["transport"] == "pro-devspace" + assert payload["app_name"] == "DevSpace" + assert payload["model"] == "gpt-5.6-sol" + assert payload["thinking_time"] == "heavy" + assert not (set(payload) & { + "attachments", "attachment_sha256s", + "project_context_manifest_path", "project_context_manifest_sha256", + }) + assert "[PRO_DEVSPACE_WRITE_AUTHORITY]" in text + assert "only within the scope this mission directs" in text + attempt = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("attempt_id=")) + input_sha = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("input_mission_sha256=")) + oracle_output = mission.parent / "oracle-output.json" + oracle_output.write_text(json.dumps({ + "schema": module.PRO_OUTPUT_SCHEMA, "workflow_id": "a" * 32, "stage": "pro", + "attempt_id": attempt, "input_mission_sha256": input_sha, "status": "PASS", + "output_text": "Pro decision\nsecond line\n", "next_stage": "review", + "next_mission_text": "Review the Pro decision independently.\nPreserve LF.\n", + "ready_for_next": True, "blocker": "", + }), encoding="utf-8") + return {"ok": True, "run_dir": str(mission.parent / "run"), "output_path": str(oracle_output)} + next_stage = { + "plan": "pro", "review": "implementation", + "implementation": "final-web-gate", "final-web-gate": "complete", + }[stage] next_mission = tmp_path / f"next-{stage}.md" next_mission.write_text(f"mission after {stage}", encoding="utf-8") regular_receipt(mission, stage, next_stage, next_mission) return {"ok": True, "run_dir": str(mission.parent / "run")} result = run_workflow(module, - manifest(tmp_path), + manifest(tmp_path, allow_pro=True), oracle_execute=fake_execute, local_gate_runner=lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, "", ""), ) @@ -377,10 +518,36 @@ def fake_execute(path: Path, *, dry_run: bool, **kwargs): assert (pro_stage / "output.md").read_bytes() == b"Pro decision\nsecond line\n" assert (pro_stage / "next-mission.md").read_bytes() == b"Review the Pro decision independently.\nPreserve LF.\n" receipt = json.loads((pro_stage / "stage-result.json").read_text(encoding="utf-8")) + assert set(receipt) == { + "schema", "workflow_id", "stage", "attempt_id", "input_mission_sha256", "status", + "output_path", "output_sha256", "next_stage", "next_mission_path", + "next_mission_sha256", "ready_for_next", "blocker", + } assert receipt["stage"] == "pro" assert receipt["next_stage"] == "review" +def test_pro_mission_states_write_authority_only_for_the_writable_route(tmp_path: Path) -> None: + module = load() + config = module.load_manifest(manifest(tmp_path)) + source = tmp_path / "pro-source.md" + source.write_text("Pro review request", encoding="utf-8") + source_sha = module.sha(source) + writable_mission, _, _, _ = module._pro_stage_mission( + config, "a" * 32, 1, source, "b" * 32, source_sha, source.read_bytes(), writable=True, + ) + text = writable_mission.read_text(encoding="utf-8") + assert "[PRO_DEVSPACE_WRITE_AUTHORITY]" in text + assert f"exact_project_root={config['project_root']}" in text + assert "exact project root" in text + assert "only inside exact_project_root" in text + assert "Never substitute a parent root, child directory" in text + read_only_mission, _, _, _ = module._pro_stage_mission( + config, "a" * 32, 1, source, "c" * 32, source_sha, source.read_bytes(), + ) + assert "[PRO_DEVSPACE_WRITE_AUTHORITY]" not in read_only_mission.read_text(encoding="utf-8") + + def test_pro_exact_recovery_materializes_output_without_resubmission(tmp_path: Path) -> None: module = load() workflow_path = manifest(tmp_path) @@ -2645,6 +2812,42 @@ def test_pro_attachment_contract_rejects_hash_mismatch_before_submission(tmp_pat module._declared_pro_attachments(config, uppercase, uppercase.read_bytes()) +def test_pro_declared_evidence_hash_mismatch_is_rejected_before_pro_submission(tmp_path: Path) -> None: + module = load() + workflow = manifest(tmp_path, allow_pro=True) + calls = [] + packet = tmp_path / "evidence.zip" + packet.write_bytes(b"evidence packet") + + def fake_execute(path: Path, *, dry_run: bool, **kwargs): + payload = json.loads(path.read_text(encoding="utf-8")) + mission = Path(payload["mission_path"]) + text = mission.read_text(encoding="utf-8") + stage = next(item for item in ("plan", "pro") if f"stage={item}\n" in text) + calls.append(stage) + if stage != "plan": + raise AssertionError("Pro submission must not happen with a mismatched evidence hash") + attempt = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("attempt_id=")) + input_sha = next(line.split("=", 1)[1] for line in text.splitlines() if line.startswith("input_mission_sha256=")) + output = mission.parent / "plan-output.md" + output.write_text("plan", encoding="utf-8") + next_mission = _pro_attachment_mission( + module, tmp_path, [{"path": str(packet), "sha256": "0" * 64}] + ) + (mission.parent / "stage-result.json").write_text(json.dumps({ + "schema": module.RECEIPT_SCHEMA, "workflow_id": "a" * 32, "stage": "plan", + "attempt_id": attempt, "input_mission_sha256": input_sha, "status": "PASS", + "output_path": str(output), "output_sha256": module.sha(output), + "next_stage": "pro", "next_mission_path": str(next_mission), + "next_mission_sha256": module.sha(next_mission), "ready_for_next": True, "blocker": "", + }), encoding="utf-8") + return {"ok": True, "run_dir": str(mission.parent / "run")} + + with pytest.raises(module.WorkflowError, match="hash mismatch"): + run_workflow(module, workflow, oracle_execute=fake_execute) + assert calls == ["plan"] + + def test_pro_attachment_contract_rejects_outside_project_and_symlink(tmp_path: Path) -> None: module = load() config = module.load_manifest(manifest(tmp_path)) @@ -2929,6 +3132,30 @@ def test_plan_mission_teaches_declared_packet_contract(tmp_path: Path) -> None: assert "output_path and next_mission_path MUST be absolute paths" in text +def test_plan_mission_declares_pro_selection_policy(tmp_path: Path) -> None: + module = load() + config = module.load_manifest(manifest(tmp_path)) + initial = config["initial_mission_path"] + mission, _, _, _ = module._stage_mission( + config, "a" * 32, 0, "plan", initial, "b" * 32, + module.sha(initial), initial.read_bytes(), + ) + text = mission.read_text(encoding="utf-8") + assert "[PRO_SELECTION_POLICY]" in text + assert "pro_selection_allowed=false" in text + assert "Do not emit next_stage=pro; continue with review or an authorized web-multi stage." in text + + opted_in = module.load_manifest(manifest(tmp_path, allow_pro=True)) + initial = opted_in["initial_mission_path"] + mission, _, _, _ = module._stage_mission( + opted_in, "a" * 32, 0, "plan", initial, "b" * 32, + module.sha(initial), initial.read_bytes(), + ) + text = mission.read_text(encoding="utf-8") + assert "pro_selection_allowed=true" in text + assert "Do not emit next_stage=pro" not in text + + def test_receipt_compatibly_resolves_project_relative_paths_with_hash_binding(tmp_path: Path) -> None: module = load() config = module.load_manifest(manifest(tmp_path)) @@ -2980,7 +3207,7 @@ def test_receipt_relative_path_escape_remains_fail_closed(tmp_path: Path) -> Non def test_completed_plan_receipt_is_compatibly_normalized_only_when_fully_valid(tmp_path: Path) -> None: module = load() - config = module.load_manifest(manifest(tmp_path)) + config = module.load_manifest(manifest(tmp_path, allow_pro=True)) output = tmp_path / "plan-output.md" next_mission = tmp_path / "pro-next.md" output.write_text("plan", encoding="utf-8") diff --git a/tests/test_chatgpt_oracle_dispatch.py b/tests/test_chatgpt_oracle_dispatch.py index b818dab0..15710c09 100644 --- a/tests/test_chatgpt_oracle_dispatch.py +++ b/tests/test_chatgpt_oracle_dispatch.py @@ -106,6 +106,14 @@ def test_context_manifest_is_required_for_pro_and_forbidden_for_regular_modes(tm output_path=tmp_path / "direct.json", context_manifest_path=context_manifest, ) + with pytest.raises(ValueError, match="CONTEXT_MANIFEST_FORBIDDEN"): + module.compile_manifest( + mode="pro", + project_root=tmp_path, + mission_path=mission, + output_path=tmp_path / "pro-devspace.json", + context_manifest_path=context_manifest, + ) def test_project_url_is_bound_into_a_regular_manifest(tmp_path: Path) -> None: @@ -201,3 +209,91 @@ def validate_only(path: Path, *, expected_manifest_sha256: str, dry_run: bool): assert "does not match the current Oracle manifest" in json.loads( capsys.readouterr().out )["error"]["message"] + + +def test_attachment_free_pro_dry_run_is_pro_devspace_with_write_authority( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + module = load() + mission = tmp_path / "mission.md" + mission.write_text("pro work", encoding="utf-8") + target = tmp_path / "pro-devspace.json" + + assert module.main([ + "--mode", "pro", + "--project-root", str(tmp_path), + "--mission-path", str(mission), + "--manifest-output", str(target), + "--dry-run", + ]) == 0 + value = json.loads(capsys.readouterr().out) + + assert value["contract"]["route"] == "oracle-pro-devspace" + assert value["contract"]["pro_selection_policy"] == "explicit-only" + run = value["run"] + assert run["status"] == "dry-run" + assert run["transport"] == "pro-devspace" + argv = run["argv"] + assert "--file" not in argv + assert "--browser-attachments" not in argv + assert run["contains_file_flag"] is False + assert argv[argv.index("--model") + 1] == "gpt-5.6-sol" + assert argv[argv.index("--browser-model-strategy") + 1] == "select" + assert argv[argv.index("--browser-thinking-time") + 1] == "heavy" + assert "--browser-hide-window" in argv + + compiled = json.loads(target.read_text(encoding="utf-8")) + assert compiled["transport"] == "pro-devspace" + assert compiled["app_name"] == "DevSpace" + assert compiled["task_outcome_contract"] == "v1" + assert compiled["model"] == "gpt-5.6-sol" + assert compiled["thinking_time"] == "heavy" + assert "attachments" not in compiled + assert "attachment_sha256s" not in compiled + assert "project_context_manifest_path" not in compiled + assert "project_context_manifest_sha256" not in compiled + + config = module.RUNNER.STATE.load_manifest( + target, expected_manifest_sha256=value["oracle_manifest_sha256"] + ) + prompt = module.RUNNER.STATE.composer_prompt(config, config.mission_path) + assert prompt == ( + f"@{module.PROFILES.DEVSPACE_APP_NAME} Read and execute the mission file inside " + f"exact_project_root={tmp_path.resolve()}. " + f"{module.PROFILES.PRO_DEVSPACE_WRITE_AUTHORITY} Mission file: {mission.resolve()}" + ) + assert "create, edit, and remove mission-owned files" in prompt + assert prompt.splitlines() == [prompt] + assert run["prompt_first_line"] == prompt + + +def test_unknown_launch_route_fails_closed(monkeypatch, tmp_path: Path) -> None: + module = load() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + + def unknown_route(mode, **kwargs): + return { + "mode": mode, + "task_kind": "direct", + "oracle_launch": True, + "devspace_required": True, + "research": False, + "route": "oracle-unknown-route", + "attachments": [], + "model": "gpt-5.6", + "thinking_time": "extra-high", + "mission_path": str(mission), + "composer_prompt": f"@DevSpace {mission}", + } + + monkeypatch.setattr(module.PROFILES, "build_launch_contract", unknown_route) + with pytest.raises(ValueError, match="ORACLE_ROUTE_UNSUPPORTED"): + module.compile_manifest( + mode="direct", + project_root=tmp_path, + mission_path=mission, + output_path=tmp_path / "unknown.json", + ) + assert not (tmp_path / "unknown.json").exists() diff --git a/tests/test_chatgpt_oracle_profiles.py b/tests/test_chatgpt_oracle_profiles.py index 47d2566e..a994a206 100644 --- a/tests/test_chatgpt_oracle_profiles.py +++ b/tests/test_chatgpt_oracle_profiles.py @@ -33,6 +33,7 @@ def test_regular_modes_use_plain_devspace_handoff_and_high_only(tmp_path: Path, assert contract["attachments"] == [] assert contract["app_picker"] is False assert contract["app_settings_automation"] is False + assert contract["pro_selection_policy"] == "explicit-only" assert contract["composer_prompt"] == f"@DevSpace {mission}" assert "\n" not in contract["composer_prompt"] @@ -96,15 +97,52 @@ def test_pro_is_oracle_attachment_only_and_manual_launches_nothing(tmp_path: Pat assert pro["attachments"] == [str(mission), str(packet)] assert pro["composer_prompt"] == "Read the attached prompt/instructions and all attached files, then complete the task." assert "@DevSpace" not in pro["composer_prompt"] + assert pro["pro_selection_policy"] == "explicit-only" assert manual["route"] == "manual-no-launch" assert manual["composer_prompt"] is None assert manual["oracle_launch"] is False + assert manual["pro_selection_policy"] == "explicit-only" -def test_pro_includes_mission_once_and_regular_rejects_attachments(tmp_path: Path) -> None: +def test_pro_without_attachments_routes_through_devspace_with_write_authority(tmp_path: Path) -> None: profiles = load_profiles() mission = (tmp_path / "prompt.txt").resolve() - pro = profiles.build_launch_contract("pro", mission_path=mission, attachment_paths=[]) + pro = profiles.build_launch_contract("pro", mission_path=mission, project_root=tmp_path.resolve()) + assert pro["route"] == "oracle-pro-devspace" + assert pro["app_policy"] == "prompt-mention-only" + assert pro["app_name"] == "DevSpace" + assert pro["devspace_required"] is True + assert pro["attachment_policy"] == "forbidden" + assert pro["attachments"] == [] + assert pro["model"] == "gpt-5.6-sol" + assert pro["reasoning_level"] == "Pro" + assert pro["thinking_time"] == "heavy" + assert pro["mission_path"] == str(mission) + assert pro["pro_selection_policy"] == "explicit-only" + prompt = pro["composer_prompt"] + assert prompt.splitlines() == [prompt] + assert prompt.startswith( + f"@DevSpace Read and execute the mission file inside exact_project_root={tmp_path.resolve()}. " + ) + assert "create, edit, and remove mission-owned files" in prompt + assert prompt.endswith(f"Mission file: {mission}") + + +def test_pro_devspace_requires_the_exact_project_root(tmp_path: Path) -> None: + profiles = load_profiles() + mission = (tmp_path / "prompt.txt").resolve() + with pytest.raises(profiles.OracleProfileError) as excinfo: + profiles.build_launch_contract("pro", mission_path=mission) + assert excinfo.value.code == "PRO_DEVSPACE_PROJECT_ROOT_REQUIRED" + + +def test_pro_attachment_paths_select_immutable_evidence_contract_and_regular_rejects_attachments( + tmp_path: Path, +) -> None: + profiles = load_profiles() + mission = (tmp_path / "prompt.txt").resolve() + pro = profiles.build_launch_contract("pro", mission_path=mission, attachment_paths=[mission]) + assert pro["route"] == "oracle-pro-attachment-only" assert pro["attachments"] == [str(mission)] with pytest.raises(profiles.OracleProfileError) as exc: profiles.build_launch_contract("review", mission_path=mission, attachment_paths=[mission]) diff --git a/tests/test_chatgpt_oracle_run.py b/tests/test_chatgpt_oracle_run.py index cf4452b8..9cb5ddf9 100644 --- a/tests/test_chatgpt_oracle_run.py +++ b/tests/test_chatgpt_oracle_run.py @@ -122,6 +122,21 @@ def pro_manifest(tmp_path: Path, prompt_text: str = "pro instructions", **extra) ) +def pro_devspace_manifest(tmp_path: Path, prompt_text: str = "pro instructions", **extra) -> Path: + prompt = tmp_path / "pro-mission.md" + prompt.write_text(prompt_text, encoding="utf-8") + return manifest( + tmp_path, + transport="pro-devspace", + app_name="DevSpace", + model="gpt-5.6-sol", + model_strategy="select", + thinking_time="heavy", + mission_path=str(prompt.resolve()), + **extra, + ) + + def version_runner(command, **kwargs): return subprocess.CompletedProcess(command, 0, stdout="oracle 0.17.3\n", stderr="") @@ -284,6 +299,77 @@ def forbidden(*args, **kwargs): assert result["ok"] is True +def test_pro_devspace_preflight_runs_devspace_readiness_with_exact_root(tmp_path: Path) -> None: + runner = load_runner() + profile = tmp_path.parent / f"{tmp_path.name}-profile" + profile.mkdir() + job = pro_devspace_manifest(tmp_path, copy_profile=str(profile.resolve())) + expected = hashlib.sha256(job.read_bytes()).hexdigest() + doctor_calls = [] + + result = runner.preflight_run( + job, + expected_manifest_sha256=expected, + devspace_hostname="device.tailnet.ts.net", + run_factory=version_runner, + oracle_inspector=lambda version: {"ok": True, "ready": True, "version": version}, + devspace_inspector=lambda **kwargs: {"ok": True, "ready": True}, + devspace_doctor=lambda config: doctor_calls.append(config) or {"next_action": "READY"}, + ) + + assert result["ok"] is True + assert result["status"] == "ready" + assert result["failed_checks"] == [] + assert {"tailscale_hostname", "devspace_compatibility", "devspace_endpoint"}.issubset( + [check["name"] for check in result["checks"]] + ) + assert len(doctor_calls) == 1 + assert doctor_calls[0].registration_url == "https://device.tailnet.ts.net/mcp" + assert doctor_calls[0].roots == (tmp_path.resolve(),) + + +def test_pro_devspace_live_submission_runs_devspace_readiness_before_submit(tmp_path: Path) -> None: + runner = load_runner() + doctor_calls = [] + + result = runner.execute_run( + pro_devspace_manifest(tmp_path), + run_factory=version_runner, + popen_factory=popen_for(0, b"answer\nTASK_OUTCOME: EXECUTED\n", {}, []), + compat_factory=lambda version: {"ok": True, "version": version}, + runtime_command_factory=lambda compatibility, version: ("node", "validated-oracle-cli.js"), + devspace_compat_factory=lambda: {"ok": True, "changed": [], "service_restart_required": False}, + devspace_hostname="device.tailnet.ts.net", + devspace_doctor=lambda config: doctor_calls.append(config) or {"next_action": "READY"}, + ) + + assert result["ok"] is True + assert result["result"]["status"] == "complete" + assert result["result"]["task_outcome"] == "executed" + assert len(doctor_calls) == 1 + assert doctor_calls[0].roots == (tmp_path.resolve(),) + + +def test_pro_devspace_dry_run_never_attaches_files(tmp_path: Path) -> None: + runner = load_runner() + result = execute_run(runner, pro_devspace_manifest(tmp_path), dry_run=True) + argv = result["argv"] + assert result["transport"] == "pro-devspace" + assert result["contains_file_flag"] is False + assert "--file" not in argv + assert "--browser-attachments" not in argv + assert argv[argv.index("--model") + 1] == "gpt-5.6-sol" + assert argv[argv.index("--browser-model-strategy") + 1] == "select" + assert argv[argv.index("--browser-thinking-time") + 1] == "heavy" + assert "--browser-hide-window" in argv + prompt = argv[argv.index("--prompt") + 1] + assert prompt.splitlines() == [prompt] + assert prompt.startswith("@DevSpace Read and execute the mission file inside exact_project_root=") + assert "create, edit, and remove mission-owned files" in prompt + assert result["attachments"] == [] + assert result["project_context_manifest"] is None + + def test_version_resolution_allows_a_bounded_slow_valid_oracle_0173() -> None: runner = load_runner() captured = {} @@ -3023,6 +3109,89 @@ def app_route_unconfirmed(command, **kwargs): assert runner.STATE.proven_user_confirmed_no_submission(run_dir / "state.json") is not None +def test_pro_devspace_app_route_unconfirmed_can_be_user_settled_and_releases_project( + tmp_path: Path, +) -> None: + """The qualified Pro route mentions the same app, so it must settle too. + + Without this the new write-capable transport would hold an unreleasable + project lock after a pre-send app-route rejection. + """ + runner = load_runner() + manifest_path = pro_devspace_manifest(tmp_path) + + def app_route_unconfirmed(command, **kwargs): + slug = command[command.index("--slug") + 1] + kwargs["stdout"].write( + ( + f"Session: {slug}\n" + "ERROR: APP_MENTION_ROUTE_UNCONFIRMED\n" + "User error (browser-automation): APP_MENTION_ROUTE_UNCONFIRMED\n" + ).encode() + ) + kwargs["stdout"].flush() + return Process(1, []) + + failed = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=app_route_unconfirmed, + ) + run_dir = Path(failed["run_dir"]) + state = runner.STATE.load_state(run_dir / "state.json") + assert state["transport"] == "pro-devspace" + settled = runner.settle_user_confirmed_no_submission( + run_dir, + confirmation=runner.STATE.USER_CONFIRMED_NO_SUBMISSION, + reason="user confirmed the exact qualified Pro run was not submitted", + ) + proof = runner.STATE.proven_user_confirmed_no_submission(run_dir / "state.json") + + assert settled["ok"] is True + assert settled["safe_for_fresh_run"] is True + assert settled["result"]["session_authority"] == "pre_submit" + assert settled["result"]["task_outcome_reason"] == ( + "user-confirmed-no-submission-after-app-route-unconfirmed" + ) + assert proof is not None + assert proof["settlement_eligibility"] == "oracle-direct-app-route-unconfirmed/v1" + assert runner.STATE.unresolved_project_sessions( + run_dir.parent, Path(state["project_root"]) + ) == [] + + +def test_pro_devspace_app_route_settlement_requires_the_exact_pro_profile( + tmp_path: Path, +) -> None: + runner = load_runner() + manifest_path = pro_devspace_manifest(tmp_path) + + def app_route_unconfirmed(command, **kwargs): + slug = command[command.index("--slug") + 1] + kwargs["stdout"].write( + ( + f"Session: {slug}\n" + "ERROR: APP_MENTION_ROUTE_UNCONFIRMED\n" + "User error (browser-automation): APP_MENTION_ROUTE_UNCONFIRMED\n" + ).encode() + ) + kwargs["stdout"].flush() + return Process(1, []) + + failed = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=app_route_unconfirmed, + ) + state_path = Path(failed["run_dir"]) / "state.json" + state = runner.STATE.load_state(state_path) + state["profile"]["thinking_time"] = "extra-high" + runner.STATE.write_json_atomic(state_path, state) + assert runner.STATE.proven_user_confirmed_no_submission(state_path) is None + + def test_direct_app_route_unconfirmed_with_stored_0172_settles_and_releases_project( tmp_path: Path, ) -> None: diff --git a/tests/test_chatgpt_oracle_state.py b/tests/test_chatgpt_oracle_state.py index d1bfd8f9..64b9f808 100644 --- a/tests/test_chatgpt_oracle_state.py +++ b/tests/test_chatgpt_oracle_state.py @@ -10,6 +10,7 @@ import pytest STATE_PATH = Path(__file__).resolve().parents[1] / "bin" / "chatgpt_oracle_state.py" +PROFILES_PATH = Path(__file__).resolve().parents[1] / "bin" / "chatgpt_oracle_profiles.py" REFERENCE_FOOTER_FIXTURE = ( Path(__file__).resolve().parent / "fixtures" / "oracle-task-outcome-reference-footer.md" ) @@ -25,6 +26,16 @@ def load_state(): return module +def load_profiles(): + name = "chatgpt_oracle_profiles_test" + spec = importlib.util.spec_from_file_location(name, PROFILES_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + def test_v1_task_outcome_accepts_exact_provider_reference_footer(tmp_path: Path) -> None: state = load_state() output = tmp_path / "output.md" @@ -95,6 +106,40 @@ def test_pro_attachment_output_is_never_marker_classified(tmp_path: Path) -> Non ) == "not_applicable" +def test_pro_devspace_output_follows_marker_contract_and_only_attachment_is_not_applicable( + tmp_path: Path, +) -> None: + state = load_state() + output = tmp_path / "output.md" + output.write_text( + "TASK_OUTCOME: EXECUTED\n", + encoding="utf-8", + ) + unmarked = tmp_path / "unmarked.md" + unmarked.write_text("no marker here\n", encoding="utf-8") + + assert state.classify_task_outcome( + output, + contract="v1", + transport="pro-devspace", + ) == "executed" + assert state.classify_task_outcome( + unmarked, + contract="v1", + transport="pro-devspace", + ) == "unknown" + assert state.classify_task_outcome( + unmarked, + contract="legacy", + transport="pro-devspace", + ) == "legacy_unclassified" + assert state.classify_task_outcome( + output, + contract="legacy", + transport="pro-attachment-only", + ) == "not_applicable" + + def manifest(tmp_path: Path, mission_path: Path | str, **extra) -> Path: os.environ["CODEX_ORACLE_STATE_ROOT"] = str((tmp_path.parent / f"{tmp_path.name}-host-state").resolve()) value = { @@ -649,6 +694,204 @@ def test_regular_manifest_requires_exact_devspace_app(tmp_path: Path) -> None: assert exc.value.code == "DEVSPACE_APP_REQUIRED" +def test_pro_devspace_manifest_loads_with_devspace_boundary_and_pending_outcome(tmp_path: Path) -> None: + state = load_state() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + config = state.load_manifest(manifest( + tmp_path, + mission.resolve(), + transport="pro-devspace", + app_name="DevSpace", + model="gpt-5.6-sol", + thinking_time="heavy", + )) + assert config.transport == "pro-devspace" + assert config.app_name == "DevSpace" + assert config.attachments == () + assert config.project_context_manifest_path is None + assert config.project_context_manifest_sha256 is None + assert config.model == "gpt-5.6-sol" + assert config.thinking_time == "heavy" + assert config.research == "off" + layout = state.create_layout(config, run_id="20260814T120000Z-a3aeba967d99") + payload = state.state_payload(config, layout, status="prepared", resolved_version="oracle 0.17.3") + assert payload["transport"] == "pro-devspace" + assert payload["app_name"] == "DevSpace" + assert payload["task_outcome"] == "pending" + assert payload["task_outcome_contract"] == "legacy" + assert payload["attachments"] == [] + assert payload["project_context_manifest"] is None + + +@pytest.mark.parametrize("contract", ["legacy", "v1"]) +def test_pro_devspace_manifest_accepts_legacy_or_v1_task_outcome_contract( + tmp_path: Path, contract: str +) -> None: + state = load_state() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + config = state.load_manifest(manifest( + tmp_path, + mission.resolve(), + transport="pro-devspace", + app_name="DevSpace", + model="gpt-5.6-sol", + thinking_time="heavy", + task_outcome_contract=contract, + )) + assert config.task_outcome_contract == contract + + +def test_pro_devspace_manifest_rejects_mission_outside_project(tmp_path: Path) -> None: + state = load_state() + outside = tmp_path.parent / f"{tmp_path.name}-outside.md" + outside.write_text("work", encoding="utf-8") + + with pytest.raises(state.OracleStateError) as exc: + state.load_manifest(manifest( + tmp_path, + outside.resolve(), + transport="pro-devspace", + app_name="DevSpace", + model="gpt-5.6-sol", + thinking_time="heavy", + )) + + assert exc.value.code == "MISSION_OUTSIDE_PROJECT" + + +def test_pro_devspace_manifest_rejects_attachments_and_unknown_transports(tmp_path: Path) -> None: + state = load_state() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + + with pytest.raises(state.OracleStateError) as exc: + state.load_manifest(manifest( + tmp_path, + mission.resolve(), + transport="pro-devspace", + app_name="DevSpace", + model="gpt-5.6-sol", + thinking_time="heavy", + attachments=[str(mission.resolve())], + )) + assert exc.value.code == "PRO_DEVSPACE_ATTACHMENTS_FORBIDDEN" + + with pytest.raises(state.OracleStateError) as exc: + state.load_manifest(manifest(tmp_path, mission.resolve(), attachments=[str(mission.resolve())])) + assert exc.value.code == "REGULAR_ATTACHMENTS_FORBIDDEN" + + with pytest.raises(state.OracleStateError) as exc: + state.load_manifest(manifest(tmp_path, mission.resolve(), transport="pro-devspace-readonly")) + assert exc.value.code == "TRANSPORT_INVALID" + + +def test_pro_devspace_manifest_requires_exact_devspace_app(tmp_path: Path) -> None: + state = load_state() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + + with pytest.raises(state.OracleStateError) as exc: + state.load_manifest(manifest( + tmp_path, + mission.resolve(), + transport="pro-devspace", + app_name="", + model="gpt-5.6-sol", + thinking_time="heavy", + )) + assert exc.value.code == "APP_NAME_INVALID" + + with pytest.raises(state.OracleStateError) as exc: + state.load_manifest(manifest( + tmp_path, + mission.resolve(), + transport="pro-devspace", + app_name="OtherWorkspace", + model="gpt-5.6-sol", + thinking_time="heavy", + )) + assert exc.value.code == "DEVSPACE_APP_REQUIRED" + + +@pytest.mark.parametrize( + ("extra", "code"), + [ + ({"model": "gpt-5.6"}, "PRO_MODEL_INVALID"), + ({"model_strategy": "current"}, "PRO_MODEL_STRATEGY_INVALID"), + ({"thinking_time": "extended"}, "PRO_THINKING_TIME_INVALID"), + ({"research": "deep"}, "PRO_RESEARCH_FORBIDDEN"), + ], +) +def test_pro_devspace_manifest_fails_closed_without_exact_pro_contract( + tmp_path: Path, extra: dict, code: str +) -> None: + state = load_state() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + value = { + "transport": "pro-devspace", + "app_name": "DevSpace", + "model": "gpt-5.6-sol", + "thinking_time": "heavy", + } + value.update(extra) + with pytest.raises(state.OracleStateError) as exc: + state.load_manifest(manifest(tmp_path, mission.resolve(), **value)) + assert exc.value.code == code + + +def test_pro_devspace_composer_reuses_the_single_profiles_handoff(tmp_path: Path) -> None: + state = load_state() + profiles = load_profiles() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + config = state.load_manifest(manifest( + tmp_path, + mission.resolve(), + transport="pro-devspace", + app_name="DevSpace", + model="gpt-5.6-sol", + thinking_time="heavy", + )) + prompt = state.composer_prompt(config) + assert prompt == profiles.pro_devspace_composer_handoff(mission.resolve(), tmp_path.resolve()) + assert prompt == state.composer_prompt(config, mission.resolve()) + assert prompt.splitlines() == [prompt] + assert prompt.startswith( + f"@DevSpace Read and execute the mission file inside exact_project_root={tmp_path.resolve()}. " + ) + assert "create, edit, and remove mission-owned files" in prompt + assert prompt.endswith(f"Mission file: {mission.resolve()}") + + +def test_pro_devspace_composer_fails_closed_on_a_degraded_install( + monkeypatch, tmp_path: Path +) -> None: + """A partially installed CODEX_HOME must produce a coded state failure. + + `spec_from_file_location` happily returns a loader for a missing path, so + the composer would otherwise raise a bare FileNotFoundError before the + runner records any state. + """ + state = load_state() + mission = tmp_path / "mission.md" + mission.write_text("work", encoding="utf-8") + config = state.load_manifest(manifest( + tmp_path, + mission.resolve(), + transport="pro-devspace", + app_name="DevSpace", + model="gpt-5.6-sol", + thinking_time="heavy", + )) + monkeypatch.setattr(state, "PROFILES_PATH", tmp_path / "absent_profiles.py") + with pytest.raises(state.OracleStateError) as excinfo: + state.composer_prompt(config) + assert excinfo.value.code == "ORACLE_PROFILES_MODULE_MISSING" + + def test_oracle_commands_pin_the_active_and_recoverable_versions() -> None: state = load_state() @@ -968,3 +1211,158 @@ def test_ledger_completion_without_a_durable_artifact_is_not_complete() -> None: verdict = state.resolve_lifecycle({"status": "complete"}, output_is_present=False) assert verdict == {"lifecycle": "needs_attention", "authority_source": "local-ledger"} + + +def proof_state(tmp_path: Path, **mutations) -> Path: + state = load_state() + run_dir = tmp_path / "run" + 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") + 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, + "task_outcome": "pending", + "profile": { + "model": "gpt-5.6-sol", + "model_strategy": "select", + "thinking_time": "heavy", + }, + "oracle": { + "resolved_version": "oracle 0.17.3", + "session_locator": "oracle-test-run-a3aeba967d", + }, + "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 + + +@pytest.mark.parametrize( + ("marker", "requested_level"), + [ + ( + "Thinking time: selection unverified (requested Heavy); " + "refusing to submit without confirmed Power 5 of 5 (Pro).", + "Heavy", + ), + ( + "Thinking time: unknown outcome selecting Heavy; " + "refusing to submit without confirmed Power 5 of 5 (Pro).", + "Heavy", + ), + ], +) +def test_pro_devspace_strict_thinking_time_failure_is_proven( + tmp_path: Path, marker: str, requested_level: str +) -> None: + state = load_state() + state_path = proof_state(tmp_path) + locator = "oracle-test-run-a3aeba967d" + (state_path.parent / "stdout.log").write_text( + f"Session: {locator}\n{marker}\n", + encoding="utf-8", + ) + + evidence = state.proven_pre_submit_thinking_time_failure(state_path) + + assert evidence is not None + assert evidence["code"] == "ORACLE_THINKING_TIME_PRE_SUBMIT_FAILED" + assert evidence["requested_level"] == requested_level + assert evidence["failure_reason"] == "oracle-thinking-time-selection-unverified" + assert evidence["oracle_locator"] == locator + + +@pytest.mark.parametrize( + ("mutation",), + [ + ({"app_name": None},), + ({"app_name": "OtherWorkspace"},), + ({"transport": "devspace"},), + ({"transport": "pro-attachment-only"},), + ({"profile": {"model": "gpt-5.6", "model_strategy": "select", "thinking_time": "heavy"}},), + ({"profile": {"model": "gpt-5.6-sol", "model_strategy": "select", "thinking_time": "extra-high"}},), + ({"oracle": {"resolved_version": "oracle 0.17.1", "session_locator": "oracle-test-run-a3aeba967d"}},), + ], +) +def test_pro_devspace_strict_thinking_time_failure_stays_fail_closed( + tmp_path: Path, mutation: dict +) -> None: + state = load_state() + state_path = proof_state(tmp_path, **mutation) + locator = "oracle-test-run-a3aeba967d" + (state_path.parent / "stdout.log").write_text( + f"Session: {locator}\n" + "Thinking time: selection unverified (requested Heavy); " + "refusing to submit without confirmed Power 5 of 5 (Pro).\n", + encoding="utf-8", + ) + + assert state.proven_pre_submit_thinking_time_failure(state_path) is None + + +def test_pro_devspace_legacy_heavy_ui_failure_is_proven(tmp_path: Path) -> None: + state = load_state() + state_path = proof_state(tmp_path, session_authority="pre_submit") + locator = "oracle-test-run-a3aeba967d" + marker = ( + "Thinking time: option not found for pro (requested Heavy); " + "refusing to submit without confirmed Pro Heavy." + ) + (state_path.parent / "stdout.log").write_text( + f"Session: {locator}\n" + f"ERROR: {marker}\n" + f"User error (browser-automation): {marker}\n", + encoding="utf-8", + ) + + evidence = state.proven_pre_submit_ui_failure(state_path) + + assert evidence is not None + assert evidence["code"] == "ORACLE_PRO_HEAVY_UNCONFIRMED_PRE_SUBMIT" + assert evidence["failure_reason"] == "pro-heavy-ui-option-unconfirmed" + assert evidence["oracle_locator"] == locator + + +def test_pro_shapes_do_not_cross_settle_between_transports(tmp_path: Path) -> None: + state = load_state() + # A pro-devspace record with the attachment shape (app_name None) is not + # either contract shape and must never settle. + state_path = proof_state(tmp_path, app_name=None) + locator = "oracle-test-run-a3aeba967d" + marker = ( + "Thinking time: option not found for pro (requested Heavy); " + "refusing to submit without confirmed Pro Heavy." + ) + (state_path.parent / "stdout.log").write_text( + f"Session: {locator}\n" + f"ERROR: {marker}\n" + f"User error (browser-automation): {marker}\n", + encoding="utf-8", + ) + + assert state.proven_pre_submit_ui_failure(state_path) is None + + +def test_pro_devspace_proof_additions_leave_version_sets_unchanged() -> None: + state = load_state() + + assert state.ORACLE_THINKING_TIME_STRICT_PROOF_VERSIONS == {"0.17.2", "0.17.3"} + assert state.ORACLE_UI_FAILURE_SETTLEMENT_VERSIONS == {"0.17.1", "0.17.2", "0.17.3"} + assert state.ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_PROOF_VERSIONS == {"0.17.2", "0.17.3"} + 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"} diff --git a/tests/test_global_gpt_browser_policy.py b/tests/test_global_gpt_browser_policy.py index 807d4e68..1decff82 100644 --- a/tests/test_global_gpt_browser_policy.py +++ b/tests/test_global_gpt_browser_policy.py @@ -26,12 +26,42 @@ def test_regular_modes_route_only_through_oracle_and_devspace() -> None: assert "Oracle `0.17.3`" in value and "`Extra High`" in value -def test_pro_is_oracle_attachment_only_heavy_and_has_no_app_fallback() -> None: +def test_pro_evidence_route_is_oracle_attachment_only_heavy_and_has_no_app_fallback() -> None: value = text(PRO) assert "Oracle is the only backend for a new Pro run" in value assert "There is no DevSpace, alternate app, in-app Browser" in value assert "gpt-5.6-sol" in value and "heavy" in value assert "never downgrade" in value + assert "pro-attachment-only" in value and "pro-devspace" in value + + +def test_qualified_pro_is_explicit_only_and_writes_inside_the_exact_root() -> None: + korean = text(ROOT / "README.md") + english = text(ROOT / "README.en.md") + for value in (korean, english): + assert "pro-devspace" in value and "pro-attachment-only" in value + assert "exact project root" in value + assert "allow_pro: true" in value + assert "자동으로 Pro로 승격하지 않습니다" in korean + assert "never promotes to Pro automatically" in english + assert "명시적으로 요청할 때만" in korean + assert "only when the user explicitly requests it" in english + assert "명시적 불변 증거 경로" in korean and "immutable-evidence route" in english + assert "자동 fallback이 아닙니다" in korean and "not an automatic fallback" in english + + +def test_comprehensive_pro_stage_requires_allow_pro_opt_in() -> None: + value = text(HANDOFF) + assert "allow_pro" in value and "allow_pro: true" in value + assert "explicit user request" in value + assert "pro-devspace" in value and "pro-attachment-only" in value + + +def test_pro_skill_auto_invocation_is_disabled() -> None: + assert "allow_implicit_invocation: false" in text(ROOT / "skills/chatgpt-pro-browser/agents/openai.yaml") + assert "allow_implicit_invocation: false" in text(ROOT / "skills/chatgpt-thinking-browser/agents/openai.yaml") + assert "allow_implicit_invocation: false" in text(ROOT / "README.md") + assert "allow_implicit_invocation: false" in text(ROOT / "README.en.md") def test_deep_research_and_web_multi_use_the_active_oracle_entry_points() -> None: @@ -65,7 +95,8 @@ def test_manifest_exposes_only_active_routing_authorities() -> None: assert manifest["routing"] == { "new_work_engine": "oracle", "regular_workspace_transport": "devspace", - "pro_transport": "oracle-attachment-only", + "pro_transport": "oracle-pro-devspace", + "pro_evidence_transport": "oracle-pro-attachment-only", } assert set(manifest["external"]) == {"oracle", "devspace"} diff --git a/tests/test_install_lifecycle.py b/tests/test_install_lifecycle.py index d0bc9dcf..20c68696 100644 --- a/tests/test_install_lifecycle.py +++ b/tests/test_install_lifecycle.py @@ -1283,7 +1283,7 @@ def test_temp_codex_home_install_and_rollback_is_exact_inverse() -> None: assert installed_pro_metadata.read_bytes() == ( ROOT / 'skills' / 'chatgpt-pro-browser' / 'agents' / 'openai.yaml' ).read_bytes() - assert b'allow_implicit_invocation: true' in installed_pro_metadata.read_bytes() + assert b'allow_implicit_invocation: false' in installed_pro_metadata.read_bytes() assert installed_oracle_patch.is_file() assert installed_devspace_patch.is_file() diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py index 8d6b27c8..9b0f7f78 100644 --- a/tests/test_release_packaging.py +++ b/tests/test_release_packaging.py @@ -37,7 +37,8 @@ def test_manifest_and_package_cover_the_active_release_surface() -> None: assert manifest["routing"] == { "new_work_engine": "oracle", "regular_workspace_transport": "devspace", - "pro_transport": "oracle-attachment-only", + "pro_transport": "oracle-pro-devspace", + "pro_evidence_transport": "oracle-pro-attachment-only", } assert {"bin/", "skills/", "bin/*.py", "skills/**/scripts/*.py"}.isdisjoint(include | set(package["files"])) @@ -89,7 +90,7 @@ def test_package_metadata_is_publishable_and_lockfile_matches() -> None: lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8")) assert package["private"] is False assert package["name"] == lock["name"] == lock["packages"][""]["name"] - assert package["version"] == lock["version"] == lock["packages"][""]["version"] == "1.8.1" + assert package["version"] == lock["version"] == lock["packages"][""]["version"] == "1.9.0" assert package["engines"]["node"] == lock["packages"][""]["engines"]["node"] == ">=24 <27" assert package["repository"]["url"] == "git+https://github.com/1Morganmore/DevSpace-Oracle.git"