diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 4a832f0448..32834d26e6 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -472,6 +472,14 @@ The result contains `native_config.hooks` and its existing developer instructions. Preserve other configuration. Review and trust only these exact hooks through Codex's native hook trust surface; the CLI does not install files, approve hashes, enable diagnostics or run a guard. +An already loaded native thread may keep its earlier hook configuration even +when `hooks/list` discovers the new handlers as enabled and trusted. That readback +does not deliver `SessionStart` or initialize its context journal. After active +work reaches its normal safe boundary, use the host's start/resume path with the +resulting configuration and verify actual lifecycle delivery before qualifying +live use. Do not restart an active host merely to manufacture a sample. Until +then, preserve full delivery and the original capture; do not fabricate events +or borrow another session's journal. Use one configuration for the declared Goal/Agent. A shared private root keeps different native sessions in separate journals, including interleaved sessions. For an interactive SSH Goal, use `--runtime-profile codex_app_ssh_goal` in place diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 1e56a486f3..8ec24fb470 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -267,7 +267,13 @@ with `--runtime-profile codex_app_ssh_goal` in setup and the thin/bootstrap prom Its generated guard keeps the native Goal identity and visible-Goal settlement; it does not become a hosted heartbeat. It does not install or trust hooks, change host configuration or restart the running App. Configure and trust its -exact hooks for the next native session. The native path preserves authentication, +exact hooks for the next native session. Existing loaded threads may retain +their earlier hook configuration despite enabled/trusted `hooks/list` readback; +discovery does not deliver `SessionStart` or initialize a context journal. Use +the host's start/resume path after active work reaches its normal safe boundary, +then verify lifecycle delivery. Keep full delivery and the original capture +until then; do not fabricate events, borrow another session's journal or restart +an active host to manufacture adoption. The native path preserves authentication, endpoints, provider selection and connection mode, including implicit history via `previous_response_id`. It neither enables input diagnostics nor scans model-input history. Benchmark preparation stages those same hooks in its isolated trial home. @@ -453,7 +459,11 @@ exec stdout;把整包重打进哈希工具不构成原始字节已被保留的 在失效事件发生前仍可用;这不是实际输入保留证明或模型 ACK。共享 codec 仍校验精确基线 摘要、作用域、本轮控制与完整重建。显式 final-input Host 客户端保留独立的确认输入契约。 -为下次原生会话配置并信任精确 hooks,不重启当前 App。原生路径保留认证、端点、provider +为下次原生会话配置并信任精确 hooks,不重启当前 App。已加载线程可能继续沿用旧 hook +配置;`hooks/list` 的 enabled/trusted 读回不等于向该线程交付 `SessionStart` 或初始化 +context journal。当前工作到达正常安全边界后,沿宿主正常启动/恢复入口加载配置,再 +核验实际生命周期交付。此前保持全量与原捕获,不伪造事件、借用其他会话 journal 或为 +制造采用样本重启活动宿主。原生路径保留认证、端点、provider 和连接方式,包括带 `previous_response_id` 的隐式历史;不启用输入诊断,也不逐轮扫描 模型完整输入。Benchmark 在各自隔离的 trial home 内自动准备同一组 hooks。 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index cafd14fdd1..e8967f1824 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -483,7 +483,10 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca inherit_machine=bool(machine_runtime)) payload = {"ok": True, "context_delivery": activation, "native_config": config, "activation": "Merge hooks and append developer_instructions to the existing native configuration; " - "review and trust only these exact hooks in Codex. Restart/resume with the resulting configuration. " + "review and trust only these exact hooks in Codex. " + "Existing loaded threads may retain their original hook configuration; enabled/trusted " + "discovery is not session initialization or live adoption. Start/resume through the host " + "with the resulting configuration after active work reaches its normal safe boundary. " + ("Machine defaults are re-read on each invocation; generated hooks inherit the same machine runtime. " "Omit per-command activation and location flags to retain that inheritance. " if machine_runtime else "Pass --context-delivery on with the same declared host mode, context location and a decision-output-root to heartbeat-prompt/should-run. ") diff --git a/tests/control_plane/test_codex_context_boundary.py b/tests/control_plane/test_codex_context_boundary.py index e9aada455f..51ac9d96ac 100644 --- a/tests/control_plane/test_codex_context_boundary.py +++ b/tests/control_plane/test_codex_context_boundary.py @@ -78,7 +78,7 @@ def _input_text(request): def _app_server(binary, env, workspace, home, trusted, additional_context=None, *, sandbox="read-only", resume=None, - turn_timeout_sec=30): + turn_timeout_sec=30, before_turn=None): with (home / "stderr.txt").open("w") as stderr, StdioNativeGoalTransport.spawn( [binary, "app-server", "--listen", "stdio://"], cwd=str(workspace), env=env, stderr=stderr, response_timeout_sec=20, @@ -102,6 +102,8 @@ def _app_server(binary, env, workspace, home, trusted, additional_context=None, if resume: params["threadId"] = resume source = native.request("thread/resume" if resume else "thread/start", params)["thread"]["id"] + if before_turn is not None: + before_turn(native) turn_params = {"threadId": source, "input": [{"type": "text", "text": "Synthetic context-delivery fixture."}]} if additional_context is not None: @@ -144,6 +146,76 @@ def _configure_provider(home, url): 'wire_api="responses"\nrequires_openai_auth=false\n') +@pytest.mark.parametrize("source", ["inline", "file"]) +def test_native_late_hook_discovery_does_not_deliver_session_context(tmp_path, source): + """Discovery/trust after start is not SessionStart delivery to that thread. + + A real process resume is the positive control. Do not freeze whether future + hosts hot-load tool hooks; only actual lifecycle delivery qualifies context. + """ + binary, env, workspace, home = _native_fixture(tmp_path) + marker = "Synthetic late-config requirement: stop before publishing." + hook = home / "late-hook.py" + hook.write_text( + "import json,pathlib,sys\nevent=json.load(sys.stdin)\n" + "with pathlib.Path(__file__).with_name('events.jsonl').open('a') as f:\n" + " f.write(json.dumps({'event':event['hook_event_name'],'source':event.get('source')})+'\\n')\n" + "print(json.dumps({'hookSpecificOutput':{'hookEventName':'SessionStart'," + f"'additionalContext':{marker!r}" + "}}) if event['hook_event_name']=='SessionStart' else '{}')\n") + + def events(): + path = home / "events.jsonl" + return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] + + def configure_after_start(native): + command = shlex.join([sys.executable, str(hook)]) + if source == "inline": + with (home / "config.toml").open("a") as config: + for name in ("SessionStart", "PostToolUse"): + config.write(f"\n[[hooks.{name}]]\n[[hooks.{name}.hooks]]\n" + f"type=\"command\"\ncommand={json.dumps(command)}\ntimeout=10\n") + else: + handler = {"type": "command", "command": command, "timeout": 10} + (home / "hooks.json").write_text(json.dumps({"hooks": { + name: [{"hooks": [handler]}] for name in ("SessionStart", "PostToolUse")}})) + listed = native.request("hooks/list", {"cwds": [str(workspace)]}) + handlers = [handler for entry in listed["data"] for handler in entry["hooks"]] + assert len(handlers) == 2 + expected_path = home / ("config.toml" if source == "inline" else "hooks.json") + assert all(handler["sourcePath"] == str(expected_path.resolve()) for handler in handlers) + with (home / "config.toml").open("a") as config: + for handler in handlers: + config.write(f"\n[hooks.state.{json.dumps(handler['key'])}]\n" + f"trusted_hash={json.dumps(handler['currentHash'])}\n") + trusted = native.request("hooks/list", {"cwds": [str(workspace)]}) + assert all(handler["enabled"] and handler["trustStatus"] == "trusted" + for entry in trusted["data"] for handler in entry["hooks"]) + + def response(request, number, message): + if number % 2: + return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", + "name": "exec_command", "arguments": json.dumps({ + "cmd": "printf synthetic-late-hook-result", "max_output_tokens": 100})} + return message + + with local_inference_provider(response) as (url, calls, requests): + _configure_provider(home, url) + config = home / "config.toml" + config.write_text(config.read_text().replace("[features]\n", "[features]\ncode_mode_host=true\n")) + thread = _app_server(binary, env, workspace, home, False, before_turn=configure_after_start) + assert len(calls) == 2 + assert all(marker not in _input_text(request) for request in requests) + assert not any(event["event"] == "SessionStart" for event in events()) + configured = config.read_bytes() + assert _app_server(binary, env, workspace, home, False, resume=thread) == thread + assert len(calls) == 4 + assert all(marker in _input_text(request) for request in requests[2:]) + assert {"event": "SessionStart", "source": "resume"} in events() + assert any(event["event"] == "PostToolUse" for event in events()) + assert config.read_bytes() == configured + + @pytest.mark.parametrize("host", ["exec", "app_server"]) @pytest.mark.parametrize("failed_primary", [False, True]) @pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"])