From 5a99c39a83a77e2c71a03dad7c997a3c7a079de0 Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Tue, 27 Oct 2026 15:40:38 -0400 Subject: [PATCH 1/4] refactor(integrations): ship the lifecycle helpers to Claude Code and Codex as a package Claude Code and Codex now ship the whole integration lifecycle package as powercontext_lifecycle/ in their plugin directories, generated from src/powercontext_integration_lifecycle/. Their hooks and settings import the client configuration, prepared-context, prompt-privacy, and bootstrap-context helpers from it, and their single-file copies are gone. WorkBuddy and Hermes keep their single-file copies where their hooks and installers expect them. A module added to the source now reaches both plugins without a new entry in the generator, which prepares moving their hook flows into the module. The copies use another name than the source, so a process that has already imported the source, such as the test suite, still loads the plugin's own copy. Ruff checks the Codex package copy against Python 3.9, like the single-file copies it replaces. `make integration-lifecycle` deletes the generated modules in a package copy that the source does not have, and names each one; it leaves any other file in a package copy for `make check` to report. `make check` also rejects a subpackage in the source, which the copies would leave out. Elsewhere in the plugins it reports a module named like a single-module copy, as before, a module whose generated-file comment marks it as a copy, such as a single-module copy left behind, and a module identical to a source module, such as one copied straight from the source. A host module that only shares a package module's name is not a copy. Both plugins install their whole directory, so installation is unchanged, and the Claude Code and Codex characterization fixtures pass as recorded. The plugin tests that copy or purge plugin modules now handle the package. --- e2e/bub/tests/test_harbor_agent.py | 9 +- .../powercontext/claude_code_settings.py | 2 +- .../powercontext/hooks/session_start.py | 2 +- .../powercontext/hooks/user_prompt_submit.py | 4 +- .../powercontext_lifecycle/__init__.py | 30 ++++ .../bootstrap_context.py | 0 .../client_config.py} | 0 .../prepared_context.py | 0 .../prompt_privacy.py | 0 .../plugins/powercontext/hooks/recall.py | 4 +- .../powercontext/hooks/session_binding.py | 4 +- .../powercontext_lifecycle/__init__.py | 30 ++++ .../bootstrap_context.py | 0 .../client_config.py} | 0 .../prepared_context.py | 0 .../prompt_privacy.py | 0 .../codex/plugins/powercontext/settings.py | 2 +- pyproject.toml | 3 +- scripts/generate_integration_lifecycle.py | 153 ++++++++++++------ .../__init__.py | 10 +- tests/claude_code_plugin/conftest.py | 8 +- tests/codex_plugin/test_mcp_headers.py | 7 +- tests/test_integration_lifecycle.py | 8 +- 23 files changed, 205 insertions(+), 71 deletions(-) create mode 100644 integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py rename integrations/claude-code/plugins/powercontext/{hooks => powercontext_lifecycle}/bootstrap_context.py (100%) rename integrations/claude-code/plugins/powercontext/{powercontext_client_config.py => powercontext_lifecycle/client_config.py} (100%) rename integrations/claude-code/plugins/powercontext/{hooks => powercontext_lifecycle}/prepared_context.py (100%) rename integrations/claude-code/plugins/powercontext/{hooks => powercontext_lifecycle}/prompt_privacy.py (100%) create mode 100644 integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py rename integrations/codex/plugins/powercontext/{hooks => powercontext_lifecycle}/bootstrap_context.py (100%) rename integrations/codex/plugins/powercontext/{powercontext_client_config.py => powercontext_lifecycle/client_config.py} (100%) rename integrations/codex/plugins/powercontext/{hooks => powercontext_lifecycle}/prepared_context.py (100%) rename integrations/codex/plugins/powercontext/{hooks => powercontext_lifecycle}/prompt_privacy.py (100%) diff --git a/e2e/bub/tests/test_harbor_agent.py b/e2e/bub/tests/test_harbor_agent.py index 6c47b0e45b..61bb64bc03 100644 --- a/e2e/bub/tests/test_harbor_agent.py +++ b/e2e/bub/tests/test_harbor_agent.py @@ -74,8 +74,13 @@ def test_acp_server_install_names_the_pre_release_sdk_and_keeps_the_harness_bub( def test_codex_plugin_accepts_the_server_url_the_harness_writes(monkeypatch, tmp_path: Path) -> None: # The plugin validates its .mcp.json strictly, so load the rewritten copy with the plugin's own settings. - for name in ("settings.py", "powercontext_client_config.py", ".mcp.json"): + for name in ("settings.py", ".mcp.json"): shutil.copy2(_CODEX_PLUGIN / name, tmp_path / name) + shutil.copytree( + _CODEX_PLUGIN / "powercontext_lifecycle", + tmp_path / "powercontext_lifecycle", + ignore=shutil.ignore_patterns("__pycache__"), + ) server_url = "http://host-gateway:8000" subprocess.run( # noqa: S603 - fixed interpreter and arguments [sys.executable, "-c", MCP_URL_REWRITE, str(tmp_path / ".mcp.json"), plugin_mcp_url(server_url)], @@ -88,7 +93,7 @@ def test_codex_plugin_accepts_the_server_url_the_harness_writes(monkeypatch, tmp monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) monkeypatch.setenv("POWERCONTEXT_CODEX_ALLOW_INSECURE_HTTP", "true") monkeypatch.syspath_prepend(str(tmp_path)) - for module in ("settings", "powercontext_client_config"): + for module in ("settings", "powercontext_lifecycle", "powercontext_lifecycle.client_config"): monkeypatch.delitem(sys.modules, module, raising=False) plugin_settings = importlib.import_module("settings") diff --git a/integrations/claude-code/plugins/powercontext/claude_code_settings.py b/integrations/claude-code/plugins/powercontext/claude_code_settings.py index 2daca616e7..3b8fc04323 100644 --- a/integrations/claude-code/plugins/powercontext/claude_code_settings.py +++ b/integrations/claude-code/plugins/powercontext/claude_code_settings.py @@ -24,7 +24,7 @@ from pathlib import Path from urllib.parse import urlsplit, urlunsplit -from powercontext_client_config import load_client_settings, parse_boolean, resolve_allow_insecure_http +from powercontext_lifecycle.client_config import load_client_settings, parse_boolean, resolve_allow_insecure_http # Kept in lockstep with powercontext.transport.LOOPBACK_HOSTS; the plugin ships # isolated and cannot import powercontext. diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py index 2ce270cd72..11491cd9b2 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/session_start.py +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -40,7 +40,7 @@ save_receipt, ) from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 -from hooks.bootstrap_context import ( # noqa: E402 +from powercontext_lifecycle.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, validate_bootstrap_context, validate_delivery_receipt, diff --git a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py index aaee2f622b..eecbf14ed6 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -36,9 +36,9 @@ from bootstrap_state import clear_receipt, load_receipt # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 -from hooks import prepared_context as _prepared_context # noqa: E402 from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 -from hooks.prompt_privacy import scrub_secrets # noqa: E402 +from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 +from powercontext_lifecycle.prompt_privacy import scrub_secrets # noqa: E402 from scope_binding_errors import ( # noqa: E402 ScopeBindingRejectedError, ScopeBindingStatusError, diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py new file mode 100644 index 0000000000..365fd06322 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/__init__.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Standard-library helpers that the PowerContext host integrations share. + +Claude Code, Codex, WorkBuddy, and Hermes install their plugin directories without the PowerContext package, so each +plugin ships generated copies of these modules. Claude Code and Codex ship the whole package as +``powercontext_lifecycle``; WorkBuddy and Hermes ship single modules where they already import them. Edit the modules +in ``src/powercontext_integration_lifecycle/`` and run ``make integration-lifecycle``; ``make check`` rejects a copy +that is missing or has drifted. + +Claude Code runs its hooks with the ``python3`` on ``PATH``, often the macOS system Python 3.9, and Hermes installs no +dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. A +single-module copy cannot reach its siblings, so a module may not import them either. The package sits outside +``powercontext`` so that importing it loads nothing from the SDK. +""" diff --git a/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/bootstrap_context.py similarity index 100% rename from integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py rename to integrations/claude-code/plugins/powercontext/powercontext_lifecycle/bootstrap_context.py diff --git a/integrations/claude-code/plugins/powercontext/powercontext_client_config.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/client_config.py similarity index 100% rename from integrations/claude-code/plugins/powercontext/powercontext_client_config.py rename to integrations/claude-code/plugins/powercontext/powercontext_lifecycle/client_config.py diff --git a/integrations/claude-code/plugins/powercontext/hooks/prepared_context.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/prepared_context.py similarity index 100% rename from integrations/claude-code/plugins/powercontext/hooks/prepared_context.py rename to integrations/claude-code/plugins/powercontext/powercontext_lifecycle/prepared_context.py diff --git a/integrations/claude-code/plugins/powercontext/hooks/prompt_privacy.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/prompt_privacy.py similarity index 100% rename from integrations/claude-code/plugins/powercontext/hooks/prompt_privacy.py rename to integrations/claude-code/plugins/powercontext/powercontext_lifecycle/prompt_privacy.py diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index 5ddc5bace1..ba935d83fe 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -37,10 +37,10 @@ sys.path.insert(0, str(_SCRIPTS_ROOT)) from bootstrap_state import clear_receipt, load_receipt # noqa: E402 -from hooks import prepared_context as _prepared_context # noqa: E402 from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 -from hooks.prompt_privacy import scrub_secrets # noqa: E402 from plugin_version import PLUGIN_VERSION # noqa: E402 +from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 +from powercontext_lifecycle.prompt_privacy import scrub_secrets # noqa: E402 from scope_binding import ( # noqa: E402 ScopeBindingRejectedError, ScopeBindingStatusError, diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 29b2e1f7da..55dce27b02 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -39,12 +39,12 @@ load_receipt_state, save_receipt, ) -from hooks.bootstrap_context import ( # noqa: E402 +from plugin_version import PLUGIN_VERSION # noqa: E402 +from powercontext_lifecycle.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, validate_bootstrap_context, validate_delivery_receipt, ) -from plugin_version import PLUGIN_VERSION # noqa: E402 from scope_binding import ( # noqa: E402 ScopeBindingError, ScopeBindingRejectedError, diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py new file mode 100644 index 0000000000..365fd06322 --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/__init__.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Standard-library helpers that the PowerContext host integrations share. + +Claude Code, Codex, WorkBuddy, and Hermes install their plugin directories without the PowerContext package, so each +plugin ships generated copies of these modules. Claude Code and Codex ship the whole package as +``powercontext_lifecycle``; WorkBuddy and Hermes ship single modules where they already import them. Edit the modules +in ``src/powercontext_integration_lifecycle/`` and run ``make integration-lifecycle``; ``make check`` rejects a copy +that is missing or has drifted. + +Claude Code runs its hooks with the ``python3`` on ``PATH``, often the macOS system Python 3.9, and Hermes installs no +dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. A +single-module copy cannot reach its siblings, so a module may not import them either. The package sits outside +``powercontext`` so that importing it loads nothing from the SDK. +""" diff --git a/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/bootstrap_context.py similarity index 100% rename from integrations/codex/plugins/powercontext/hooks/bootstrap_context.py rename to integrations/codex/plugins/powercontext/powercontext_lifecycle/bootstrap_context.py diff --git a/integrations/codex/plugins/powercontext/powercontext_client_config.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/client_config.py similarity index 100% rename from integrations/codex/plugins/powercontext/powercontext_client_config.py rename to integrations/codex/plugins/powercontext/powercontext_lifecycle/client_config.py diff --git a/integrations/codex/plugins/powercontext/hooks/prepared_context.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/prepared_context.py similarity index 100% rename from integrations/codex/plugins/powercontext/hooks/prepared_context.py rename to integrations/codex/plugins/powercontext/powercontext_lifecycle/prepared_context.py diff --git a/integrations/codex/plugins/powercontext/hooks/prompt_privacy.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/prompt_privacy.py similarity index 100% rename from integrations/codex/plugins/powercontext/hooks/prompt_privacy.py rename to integrations/codex/plugins/powercontext/powercontext_lifecycle/prompt_privacy.py diff --git a/integrations/codex/plugins/powercontext/settings.py b/integrations/codex/plugins/powercontext/settings.py index b902ddde0a..a79f9bf86e 100644 --- a/integrations/codex/plugins/powercontext/settings.py +++ b/integrations/codex/plugins/powercontext/settings.py @@ -24,7 +24,7 @@ from typing import Any, Literal from urllib.parse import urlsplit, urlunsplit -from powercontext_client_config import parse_boolean, resolve_allow_insecure_http +from powercontext_lifecycle.client_config import parse_boolean, resolve_allow_insecure_http from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict diff --git a/pyproject.toml b/pyproject.toml index 25cb182327..5b7396951f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -292,8 +292,7 @@ fix = true # The plugins ship generated copies of these modules. Ruff must check each copy like its source, or a fix that it # applies to a copy makes the copy drift. "src/powercontext_integration_lifecycle/**/*.py" = "py39" -"integrations/codex/plugins/powercontext/powercontext_client_config.py" = "py39" -"integrations/codex/plugins/powercontext/hooks/{bootstrap_context,prepared_context,prompt_privacy}.py" = "py39" +"integrations/codex/plugins/powercontext/powercontext_lifecycle/**/*.py" = "py39" "integrations/hermes/plugins/powercontext/powercontext_client_config.py" = "py39" "integrations/workbuddy/plugins/powercontext/hooks/{powercontext_client_config,prepared_context,prompt_privacy}.py" = "py39" diff --git a/scripts/generate_integration_lifecycle.py b/scripts/generate_integration_lifecycle.py index b24c227dfd..ff340b30ba 100644 --- a/scripts/generate_integration_lifecycle.py +++ b/scripts/generate_integration_lifecycle.py @@ -28,36 +28,37 @@ _CODEX = "integrations/codex/plugins/powercontext" _HERMES = "integrations/hermes/plugins/powercontext" _WORKBUDDY = "integrations/workbuddy/plugins/powercontext" -# Each copy stays where its host's hooks and installer already expect it. +# Claude Code and Codex ship the whole package, so a module added to the source reaches both without an entry here. +# The copies use another name than the source, so a process that has already imported the source, such as the test +# suite, still loads the plugin's own copy. +PACKAGE_COPIES = (f"{_CLAUDE_CODE}/powercontext_lifecycle", f"{_CODEX}/powercontext_lifecycle") +# WorkBuddy and Hermes ship single modules where their hooks and installers already expect them. COPIES = { "client_config.py": ( - f"{_CLAUDE_CODE}/powercontext_client_config.py", - f"{_CODEX}/powercontext_client_config.py", f"{_HERMES}/powercontext_client_config.py", f"{_WORKBUDDY}/hooks/powercontext_client_config.py", ), - "prepared_context.py": ( - f"{_CLAUDE_CODE}/hooks/prepared_context.py", - f"{_CODEX}/hooks/prepared_context.py", - f"{_WORKBUDDY}/hooks/prepared_context.py", - ), - "prompt_privacy.py": ( - f"{_CLAUDE_CODE}/hooks/prompt_privacy.py", - f"{_CODEX}/hooks/prompt_privacy.py", - f"{_WORKBUDDY}/hooks/prompt_privacy.py", - ), - "bootstrap_context.py": ( - f"{_CLAUDE_CODE}/hooks/bootstrap_context.py", - f"{_CODEX}/hooks/bootstrap_context.py", - ), + "prepared_context.py": (f"{_WORKBUDDY}/hooks/prepared_context.py",), + "prompt_privacy.py": (f"{_WORKBUDDY}/hooks/prompt_privacy.py",), } +# Every copy's generated-file comment starts with this. +GENERATED_FROM = f"# Generated from {SOURCE.relative_to(ROOT).as_posix()}/" DRIFT_MESSAGE = ( "Generated integration lifecycle helpers drifted; edit src/powercontext_integration_lifecycle/, " "run 'make integration-lifecycle', and review the result." ) UNLISTED_MESSAGE = ( - "Plugin files share a name with a generated integration lifecycle helper; add each copy to COPIES in " - "scripts/generate_integration_lifecycle.py, or rename or delete the file." + "Plugin files look like generated integration lifecycle helpers, by name, generated-file comment, or content, but " + "scripts/generate_integration_lifecycle.py does not write them; add each copy to COPIES there, or rename or " + "delete the file." +) +EXTRA_MESSAGE = ( + "Package copies of the integration lifecycle helpers hold files that the source does not have; " + "'make integration-lifecycle' deletes the generated ones, and other files belong outside the package." +) +NESTED_MESSAGE = ( + "The plugins get only the top-level modules of src/powercontext_integration_lifecycle/; move these modules to " + "the top level:" ) @@ -68,17 +69,32 @@ def render(module: str) -> str: lines = source.read_text(encoding="utf-8").splitlines(keepends=True) header = next((index for index, line in enumerate(lines) if not line.startswith("#")), len(lines)) marker = ( - f"# Generated from {source.relative_to(ROOT).as_posix()}; do not edit.\n" - "# Edit the source and run `make integration-lifecycle` instead.\n" + f"{GENERATED_FROM}{module}; do not edit.\n# Edit the source and run `make integration-lifecycle` instead.\n" ) return "".join([*lines[:header], "\n" if header else "", marker, *lines[header:]]) -def unlisted_copies() -> list[Path]: - """Return committable plugin files named like a generated copy that no entry in COPIES writes.""" +def expected_copies() -> dict[Path, str]: + """Return every copy the generator writes, with its content.""" + + # The package copies hold the top-level modules only, so a subpackage would be left out. + if nested := sorted(SOURCE.glob("*/**/*.py")): + raise SystemExit("\n".join([NESTED_MESSAGE, *(f" {path.relative_to(ROOT)}" for path in nested)])) + copies = {ROOT / target: render(module) for module, targets in COPIES.items() for target in targets} + modules = sorted(path.name for path in SOURCE.glob("*.py")) + copies.update({ROOT / package / module: render(module) for package in PACKAGE_COPIES for module in modules}) + return copies + + +def has_generated_comment(content: bytes) -> bool: + """Return whether a file has a line that starts like the generated-file comment.""" + + return any(line.startswith(GENERATED_FROM.encode()) for line in content.splitlines()) + + +def committable_files(*pathspecs: str) -> set[Path]: + """Return the files that match the pathspecs and that Git tracks or would commit.""" - listed = {ROOT / target for targets in COPIES.values() for target in targets} - pathspecs = sorted({f":(glob){PLUGINS}/**/{path.name}" for path in listed}) # Git leaves out ignored build output and installed dependencies, which are not plugin sources. # Git writes its own errors, such as a missing checkout, to the terminal. result = subprocess.run( # noqa: S603 -- fixed arguments, no shell @@ -88,33 +104,78 @@ def unlisted_copies() -> list[Path]: cwd=ROOT, ) found = {ROOT / os.fsdecode(path) for path in result.stdout.split(b"\0") if path} - # A tracked copy already deleted from the working tree needs no entry. - return sorted(path for path in found - listed if path.is_file()) + # A tracked file already deleted from the working tree is not there to report. + return {path for path in found if path.is_file()} + + +def package_extras(listed: set[Path]) -> list[Path]: + """Return the files in the package copies that the generator does not write.""" + + return sorted(committable_files(*(f":(glob){package}/**" for package in PACKAGE_COPIES)) - listed) + + +def unlisted_copies(listed: set[Path]) -> list[Path]: + """Return the plugin modules outside the package copies that look like a copy but are not one.""" + + # A host's own module may share a package module's name, so only the names of the single-module copies count. + # Other copies show themselves by their generated-file comment or, when taken straight from the source, by their + # content. An empty module matches no source. + names = {Path(target).name for targets in COPIES.values() for target in targets} + sources = {path.read_bytes() for path in SOURCE.glob("*.py")} - {b""} + packages = [ROOT / package for package in PACKAGE_COPIES] + unlisted = [] + for path in sorted(committable_files(f":(glob){PLUGINS}/**/*.py") - listed): + if any(path.is_relative_to(package) for package in packages): + continue + content = path.read_bytes() + if path.name in names or has_generated_comment(content) or content in sources: + unlisted.append(path) + return unlisted + + +def check(expected: dict[Path, str]) -> list[str]: + """Return the messages for copies that are missing, stale, or kept by hand.""" + + stale = [] + for path, text in expected.items(): + if not path.is_file(): + stale.append(f" {path.relative_to(ROOT)} is missing") + elif path.read_text(encoding="utf-8") != text: + stale.append(f" {path.relative_to(ROOT)} has drifted") + unlisted = [f" {path.relative_to(ROOT)}" for path in unlisted_copies(set(expected))] + extras = [f" {path.relative_to(ROOT)}" for path in package_extras(set(expected))] + problems = [] + if stale: + problems += [DRIFT_MESSAGE, *stale] + if unlisted: + problems += [UNLISTED_MESSAGE, *unlisted] + if extras: + problems += [EXTRA_MESSAGE, *extras] + return problems + + +def write(expected: dict[Path, str]) -> None: + """Write every copy, and delete the generated modules in the package copies that the source does not have.""" + + for path, text in expected.items(): + path.parent.mkdir(exist_ok=True) + path.write_text(text, encoding="utf-8", newline="\n") + for path in package_extras(set(expected)): + # Only a module that this generator wrote goes; `make check` reports any other file. + if path.suffix == ".py" and has_generated_comment(path.read_bytes()): + path.unlink() + print(f"Deleted {path.relative_to(ROOT)}; the source has no such module.") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="Fail when a copy is missing, stale, or kept by hand.") args = parser.parse_args() - expected = {ROOT / target: render(module) for module, targets in COPIES.items() for target in targets} - if args.check: - stale = [] - for path, text in expected.items(): - if not path.is_file(): - stale.append(f" {path.relative_to(ROOT)} is missing") - elif path.read_text(encoding="utf-8") != text: - stale.append(f" {path.relative_to(ROOT)} has drifted") - unlisted = [f" {path.relative_to(ROOT)}" for path in unlisted_copies()] - problems = [] - if stale: - problems += [DRIFT_MESSAGE, *stale] - if unlisted: - problems += [UNLISTED_MESSAGE, *unlisted] - if problems: - raise SystemExit("\n".join(problems)) - return - for path, text in expected.items(): - path.write_text(text, encoding="utf-8", newline="\n") + expected = expected_copies() + if not args.check: + write(expected) + elif problems := check(expected): + raise SystemExit("\n".join(problems)) if __name__ == "__main__": diff --git a/src/powercontext_integration_lifecycle/__init__.py b/src/powercontext_integration_lifecycle/__init__.py index 3efa01b4ef..fa2ead29ea 100644 --- a/src/powercontext_integration_lifecycle/__init__.py +++ b/src/powercontext_integration_lifecycle/__init__.py @@ -15,11 +15,13 @@ """Standard-library helpers that the PowerContext host integrations share. Claude Code, Codex, WorkBuddy, and Hermes install their plugin directories without the PowerContext package, so each -plugin ships a generated copy of these modules where it already imports them. Edit them here and run -``make integration-lifecycle``; ``make check`` rejects a copy that is missing or has drifted. +plugin ships generated copies of these modules. Claude Code and Codex ship the whole package as +``powercontext_lifecycle``; WorkBuddy and Hermes ship single modules where they already import them. Edit the modules +in ``src/powercontext_integration_lifecycle/`` and run ``make integration-lifecycle``; ``make check`` rejects a copy +that is missing or has drifted. Claude Code runs its hooks with the ``python3`` on ``PATH``, often the macOS system Python 3.9, and Hermes installs no -dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. The -copies do not keep this package's layout, so a module may not import its siblings either. The package sits outside +dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. A +single-module copy cannot reach its siblings, so a module may not import them either. The package sits outside ``powercontext`` so that importing it loads nothing from the SDK. """ diff --git a/tests/claude_code_plugin/conftest.py b/tests/claude_code_plugin/conftest.py index 0b918468d7..0dea0411ac 100644 --- a/tests/claude_code_plugin/conftest.py +++ b/tests/claude_code_plugin/conftest.py @@ -29,9 +29,11 @@ "bootstrap_state", "claude_code_settings", "hooks", - "hooks.bootstrap_context", - "hooks.prepared_context", - "hooks.prompt_privacy", + "powercontext_lifecycle", + "powercontext_lifecycle.bootstrap_context", + "powercontext_lifecycle.client_config", + "powercontext_lifecycle.prepared_context", + "powercontext_lifecycle.prompt_privacy", "scripts", "scripts.workspace_scope", "workspace_scope", diff --git a/tests/codex_plugin/test_mcp_headers.py b/tests/codex_plugin/test_mcp_headers.py index 0b58cc81e1..1334293368 100644 --- a/tests/codex_plugin/test_mcp_headers.py +++ b/tests/codex_plugin/test_mcp_headers.py @@ -32,8 +32,13 @@ def installed_helper(tmp_path): plugin = tmp_path / "installed plugin 中文" plugin.mkdir() - for name in ("settings.py", "powercontext_client_config.py", "mcp_headers.py", ".mcp.json"): + for name in ("settings.py", "mcp_headers.py", ".mcp.json"): shutil.copyfile(PLUGIN_ROOT / name, plugin / name) + shutil.copytree( + PLUGIN_ROOT / "powercontext_lifecycle", + plugin / "powercontext_lifecycle", + ignore=shutil.ignore_patterns("__pycache__"), + ) credential = tmp_path / "separate codex home" / "powercontext" / "credentials.json" credential.parent.mkdir(parents=True) credential.write_text( diff --git a/tests/test_integration_lifecycle.py b/tests/test_integration_lifecycle.py index 8e6146a006..2d7eea0c96 100644 --- a/tests/test_integration_lifecycle.py +++ b/tests/test_integration_lifecycle.py @@ -14,9 +14,9 @@ """The integration lifecycle helpers run inside plugins that are installed without the PowerContext package. -Claude Code runs its hooks with the ``python3`` on ``PATH``, and Hermes installs no dependency for its plugin. Each -plugin also puts its copy of a helper where it imports it. A helper may therefore import only the standard library, -and no sibling. +Claude Code runs its hooks with the ``python3`` on ``PATH``, and Hermes installs no dependency for its plugin. +WorkBuddy and Hermes also ship single modules, which cannot reach their siblings. A helper may therefore import only +the standard library, and no sibling. """ from __future__ import annotations @@ -45,7 +45,7 @@ def test_lifecycle_helper_imports_only_the_standard_library(module: Path) -> Non def _top_level_names(node: ast.Import | ast.ImportFrom) -> set[str]: if isinstance(node, ast.Import): return {alias.name.partition(".")[0] for alias in node.names} - # A relative import names a sibling, which a plugin's copy cannot reach. + # A relative import names a sibling, which a single-module copy cannot reach. return {""} if node.level else {(node.module or "").partition(".")[0]} From d02954a3fac9d06500383a205d7f782e14550413 Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Tue, 27 Oct 2026 21:13:33 -0400 Subject: [PATCH 2/4] refactor(integrations): move hook diagnostics and bootstrap receipts into the lifecycle package Claude Code and Codex each kept their own diagnostic throttling (hooks/diagnostics.py) and bootstrap receipt state (bootstrap_state.py). The copies differed only in the name of the diagnostics state file and in the two environment variables that locate the receipt directory. Both now live once in src/powercontext_integration_lifecycle/ and reach the plugins through the generated powercontext_lifecycle package. The modules take the host differences as values: DiagnosticThrottle names the state file, and BootstrapState names the two variables. Each plugin declares its values once, in claude_code_host.py and codex_host.py, and its hooks use those instances. State files, environment variables, locking, and failure handling are unchanged, and the Claude Code and Codex characterization fixtures pass as recorded. The plugin tests reach the receipt state through the hooks' declarations. They also set the host's plugin data directory, so a declaration that swaps the two variables fails them. The lock-contention test runs against the source module, which the copies must match. --- .../plugins/powercontext/bootstrap_state.py | 174 ---------------- .../plugins/powercontext/claude_code_host.py | 24 +++ .../plugins/powercontext/hooks/diagnostics.py | 110 ----------- .../powercontext/hooks/session_start.py | 16 +- .../powercontext/hooks/user_prompt_submit.py | 9 +- .../powercontext_lifecycle/bootstrap_state.py | 186 ++++++++++++++++++ .../powercontext_lifecycle/diagnostics.py | 124 ++++++++++++ .../plugins/powercontext/bootstrap_state.py | 174 ---------------- .../codex/plugins/powercontext/codex_host.py | 24 +++ .../plugins/powercontext/hooks/diagnostics.py | 110 ----------- .../plugins/powercontext/hooks/recall.py | 9 +- .../powercontext/hooks/session_binding.py | 16 +- .../powercontext/hooks/token_savings.py | 4 +- .../powercontext_lifecycle/bootstrap_state.py | 186 ++++++++++++++++++ .../powercontext_lifecycle/diagnostics.py | 124 ++++++++++++ .../bootstrap_state.py | 183 +++++++++++++++++ .../diagnostics.py | 121 ++++++++++++ tests/claude_code_plugin/conftest.py | 7 +- tests/claude_code_plugin/test_bootstrap.py | 18 +- tests/claude_code_plugin/test_hook.py | 4 +- tests/codex_plugin/conftest.py | 2 + tests/codex_plugin/test_bootstrap.py | 18 +- tests/codex_plugin/test_recall.py | 4 +- tests/test_hook_diagnostics.py | 33 +--- 24 files changed, 1027 insertions(+), 653 deletions(-) delete mode 100644 integrations/claude-code/plugins/powercontext/bootstrap_state.py create mode 100644 integrations/claude-code/plugins/powercontext/claude_code_host.py delete mode 100644 integrations/claude-code/plugins/powercontext/hooks/diagnostics.py create mode 100644 integrations/claude-code/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py create mode 100644 integrations/claude-code/plugins/powercontext/powercontext_lifecycle/diagnostics.py delete mode 100644 integrations/codex/plugins/powercontext/bootstrap_state.py create mode 100644 integrations/codex/plugins/powercontext/codex_host.py delete mode 100644 integrations/codex/plugins/powercontext/hooks/diagnostics.py create mode 100644 integrations/codex/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py create mode 100644 integrations/codex/plugins/powercontext/powercontext_lifecycle/diagnostics.py create mode 100644 src/powercontext_integration_lifecycle/bootstrap_state.py create mode 100644 src/powercontext_integration_lifecycle/diagnostics.py diff --git a/integrations/claude-code/plugins/powercontext/bootstrap_state.py b/integrations/claude-code/plugins/powercontext/bootstrap_state.py deleted file mode 100644 index 4ab9f062c5..0000000000 --- a/integrations/claude-code/plugins/powercontext/bootstrap_state.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Persist only a hashed Session key and the latest bootstrap receipt identity.""" - -from __future__ import annotations - -import json -import os -import stat -from collections.abc import Iterator -from contextlib import contextmanager, suppress -from hashlib import sha256 -from pathlib import Path -from uuid import uuid4 - - -def load_receipt(session_id: str | None, scope_id: str) -> str | None: - state = load_receipt_state(session_id) - return state[1] if state is not None and state[0] == scope_id else None - - -def load_receipt_state(session_id: str | None) -> tuple[str, str] | None: - path = _state_path(session_id) - if path is None: - return None - return _read_receipt_state(path) - - -def _read_receipt_state(path: Path) -> tuple[str, str] | None: - try: - if path.is_symlink() or not path.is_file() or (os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077): - return None - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict) or set(value) != {"version", "scope_id", "receipt_id"}: - return None - scope_id = value.get("scope_id") - receipt_id = value.get("receipt_id") - if ( - value.get("version") != 1 - or not isinstance(scope_id, str) - or not scope_id.strip() - or len(scope_id) > 256 - or not isinstance(receipt_id, str) - or not 1 <= len(receipt_id) <= 64 - or not all("\x21" <= character <= "\x7e" for character in receipt_id) - ): - return None - except (OSError, ValueError, TypeError, json.JSONDecodeError): - return None - return scope_id, receipt_id - - -def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None: - path = _state_path(session_id) - if path is None: - raise OSError - root = path.parent - root.mkdir(mode=0o700, parents=True, exist_ok=True) - if root.is_symlink() or not root.is_dir(): - raise OSError - if os.name != "nt": - root.chmod(0o700) - payload = json.dumps( - {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, - separators=(",", ":"), - ).encode() - with _locked(path): - temporary = root / f".{path.name}.{uuid4().hex}.tmp" - flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor = os.open(temporary, flags, 0o600) - try: - try: - with os.fdopen(descriptor, "wb", closefd=False) as output: - output.write(payload) - output.flush() - os.fsync(output.fileno()) - finally: - # Windows cannot replace the temporary file while its descriptor is open. - os.close(descriptor) - os.replace(temporary, path) - if os.name != "nt": - path.chmod(0o600) - finally: - with suppress(FileNotFoundError): - temporary.unlink() - - -def clear_receipt(session_id: str | None) -> None: - path = _state_path(session_id) - if path is None or not path.parent.exists(): - return - try: - with _locked(path): - if not path.is_symlink(): - path.unlink(missing_ok=True) - except OSError: - return - - -def clear_receipt_if_matches( - session_id: str | None, - scope_id: str, - receipt_id: str, -) -> None: - path = _state_path(session_id) - if path is None or not path.parent.exists(): - return - try: - with _locked(path): - if _read_receipt_state(path) == (scope_id, receipt_id): - path.unlink(missing_ok=True) - except OSError: - return - - -@contextmanager -def _locked(path: Path) -> Iterator[None]: - lock_path = path.with_name(f".{path.name}.lock") - with lock_path.open("a+b") as lock_file: - if os.name == "nt": - import msvcrt - - lock_file.seek(0, os.SEEK_END) - if lock_file.tell() == 0: - lock_file.write(b"\0") - lock_file.flush() - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - try: - yield - finally: - if os.name == "nt": - import msvcrt - - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - -def _state_path(session_id: str | None) -> Path | None: - if session_id is None or not session_id.strip(): - return None - configured = os.environ.get("POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR") - plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA") - if configured is None and plugin_data is None: - return None - root = Path(configured or plugin_data or "").expanduser() - if not root.is_absolute(): - return None - digest = sha256(session_id.strip().encode()).hexdigest() - return root / "powercontext-bootstrap" / f"{digest}.json" diff --git a/integrations/claude-code/plugins/powercontext/claude_code_host.py b/integrations/claude-code/plugins/powercontext/claude_code_host.py new file mode 100644 index 0000000000..b77d9b29ce --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/claude_code_host.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""What the shared lifecycle modules need to know about Claude Code.""" + +from powercontext_lifecycle.bootstrap_state import BootstrapState +from powercontext_lifecycle.diagnostics import DiagnosticThrottle + +BOOTSTRAP_STATE = BootstrapState( + directory_variable="POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR", + plugin_data_variable="CLAUDE_PLUGIN_DATA", +) +DIAGNOSTICS = DiagnosticThrottle(state_file="claude-code-diagnostics.json") diff --git a/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py b/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py deleted file mode 100644 index 11ce9151f4..0000000000 --- a/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Best-effort cross-process throttling for host-visible hook diagnostics.""" - -from __future__ import annotations - -import json -import os -import time -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path - -_FAILURE_OUTCOMES = frozenset({ - "authentication_failed", - "authorization_denied", - "delivery_failed", - "invalid_response", - "server_unavailable", - "version_mismatch", -}) -_COOLDOWN_SECONDS = 60.0 - - -def _state_path() -> Path: - configured = os.environ.get("POWERCONTEXT_DIAGNOSTIC_STATE_FILE") - if configured and configured.strip(): - return Path(configured) - if os.name == "nt": - root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") - else: - root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") - return root / "powercontext" / "claude-code-diagnostics.json" - - -@contextmanager -def _locked(lock_path: Path) -> Iterator[None]: - lock_path.parent.mkdir(parents=True, exist_ok=True) - with lock_path.open("a+b") as lock_file: - if os.name == "nt": - import msvcrt - - lock_file.seek(0, os.SEEK_END) - if lock_file.tell() == 0: - lock_file.write(b"\0") - lock_file.flush() - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - try: - yield - finally: - if os.name == "nt": - import msvcrt - - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - -def should_emit(outcome: str) -> bool: - """Return whether a diagnostic should be shown to the host user.""" - - if outcome not in _FAILURE_OUTCOMES: - return True - - try: - now = time.time() - state_path = _state_path() - with _locked(state_path.with_name(f"{state_path.name}.lock")): - try: - state = json.loads(state_path.read_text(encoding="utf-8")) - except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): - state = {} - if not isinstance(state, dict): - state = {} - previous = state.get(outcome) - if ( - isinstance(previous, (int, float)) - and not isinstance(previous, bool) - and 0 <= now - previous < _COOLDOWN_SECONDS - ): - return False - - state[outcome] = now - temporary_path = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") - temporary_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8") - os.replace(temporary_path, state_path) - except (OSError, TypeError, ValueError): - # Diagnostics must never make a hook invocation fail. - return True - return True diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py index 11491cd9b2..92af57e56d 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/session_start.py +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -34,11 +34,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import ( # noqa: E402 - clear_receipt_if_matches, - load_receipt_state, - save_receipt, -) +from claude_code_host import BOOTSTRAP_STATE # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from powercontext_lifecycle.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, @@ -103,7 +99,7 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: or source not in _LIFECYCLES ): return 0 - previous_receipt = load_receipt_state(session_id) + previous_receipt = BOOTSTRAP_STATE.load_receipt_state(session_id) try: settings = ClaudeCodePluginSettings.from_environment() if settings is None else settings except Exception: @@ -152,7 +148,7 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: ) return 0 try: - save_receipt(session_id, scope_id, receipt_id) + BOOTSTRAP_STATE.save_receipt(session_id, scope_id, receipt_id) json.dump( { "hookSpecificOutput": { @@ -297,16 +293,16 @@ def _clear_stale_receipt_after_failed_claim( previous_receipt: tuple[str, str] | None, ) -> None: if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): - clear_receipt_if_matches(session_id, *previous_receipt) + BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) def _clear_observed_receipt(session_id: str, previous_receipt: tuple[str, str] | None) -> None: if previous_receipt is not None: - clear_receipt_if_matches(session_id, *previous_receipt) + BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, error: Exception) -> None: - clear_receipt_if_matches(session_id, scope_id, receipt_id) + BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, scope_id, receipt_id) if isinstance(error, BrokenPipeError): try: stdout = sys.stdout.fileno() diff --git a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py index eecbf14ed6..da8052371d 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -34,9 +34,8 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import clear_receipt, load_receipt # noqa: E402 +from claude_code_host import BOOTSTRAP_STATE, DIAGNOSTICS # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 -from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 from powercontext_lifecycle.prompt_privacy import scrub_secrets # noqa: E402 from scope_binding_errors import ( # noqa: E402 @@ -167,8 +166,8 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: # noqa: C901 ) _write_hook_output(diagnostic_events=diagnostic_events) return 0 - bootstrap_receipt_id = load_receipt(session_id, scope_id) - clear_receipt(session_id) + bootstrap_receipt_id = BOOTSTRAP_STATE.load_receipt(session_id, scope_id) + BOOTSTRAP_STATE.clear_receipt(session_id) safe_prompt = scrub_secrets(prompt, settings.authorization) query = _bounded_recall_query(safe_prompt) context = None @@ -502,7 +501,7 @@ def _emit_context_event( if key in emitted_diagnostics: return emitted_diagnostics.add(key) - if not _should_emit_diagnostic(outcome): + if not DIAGNOSTICS.should_emit(outcome): return event: dict[str, object] = { "component": "powercontext.claude_code.recall", diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py new file mode 100644 index 0000000000..861f995e3f --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py @@ -0,0 +1,186 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/bootstrap_state.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Persist only a hashed Session key and the latest bootstrap receipt identity.""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + + +@dataclass(frozen=True) +class BootstrapState: + """Where one host keeps its bootstrap receipts. + + Receipts are kept in ``powercontext-bootstrap/`` under the value of ``directory_variable`` or, when that is unset + or empty, of ``plugin_data_variable``, the host's plugin data directory. ``~`` is expanded; without an absolute + directory, no receipt is kept. + """ + + directory_variable: str + plugin_data_variable: str + + def load_receipt(self, session_id: str | None, scope_id: str) -> str | None: + state = self.load_receipt_state(session_id) + return state[1] if state is not None and state[0] == scope_id else None + + def load_receipt_state(self, session_id: str | None) -> tuple[str, str] | None: + path = self._state_path(session_id) + if path is None: + return None + return _read_receipt_state(path) + + def save_receipt(self, session_id: str | None, scope_id: str, receipt_id: str) -> None: + path = self._state_path(session_id) + if path is None: + raise OSError + root = path.parent + root.mkdir(mode=0o700, parents=True, exist_ok=True) + if root.is_symlink() or not root.is_dir(): + raise OSError + if os.name != "nt": + root.chmod(0o700) + payload = json.dumps( + {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, + separators=(",", ":"), + ).encode() + with _locked(path): + temporary = root / f".{path.name}.{uuid4().hex}.tmp" + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary, flags, 0o600) + try: + try: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + finally: + # Windows cannot replace the temporary file while its descriptor is open. + os.close(descriptor) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + with suppress(FileNotFoundError): + temporary.unlink() + + def clear_receipt(self, session_id: str | None) -> None: + path = self._state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if not path.is_symlink(): + path.unlink(missing_ok=True) + except OSError: + return + + def clear_receipt_if_matches( + self, + session_id: str | None, + scope_id: str, + receipt_id: str, + ) -> None: + path = self._state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if _read_receipt_state(path) == (scope_id, receipt_id): + path.unlink(missing_ok=True) + except OSError: + return + + def _state_path(self, session_id: str | None) -> Path | None: + if session_id is None or not session_id.strip(): + return None + configured = os.environ.get(self.directory_variable) + plugin_data = os.environ.get(self.plugin_data_variable) + if configured is None and plugin_data is None: + return None + root = Path(configured or plugin_data or "").expanduser() + if not root.is_absolute(): + return None + digest = sha256(session_id.strip().encode()).hexdigest() + return root / "powercontext-bootstrap" / f"{digest}.json" + + +def _read_receipt_state(path: Path) -> tuple[str, str] | None: + try: + if path.is_symlink() or not path.is_file() or (os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or set(value) != {"version", "scope_id", "receipt_id"}: + return None + scope_id = value.get("scope_id") + receipt_id = value.get("receipt_id") + if ( + value.get("version") != 1 + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(receipt_id, str) + or not 1 <= len(receipt_id) <= 64 + or not all("\x21" <= character <= "\x7e" for character in receipt_id) + ): + return None + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + return scope_id, receipt_id + + +@contextmanager +def _locked(path: Path) -> Iterator[None]: + lock_path = path.with_name(f".{path.name}.lock") + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/diagnostics.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/diagnostics.py new file mode 100644 index 0000000000..9a0e17ed99 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/diagnostics.py @@ -0,0 +1,124 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/diagnostics.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Best-effort cross-process throttling for host-visible hook diagnostics.""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) +_COOLDOWN_SECONDS = 60.0 + + +@dataclass(frozen=True) +class DiagnosticThrottle: + """Limits how often one host shows its user the same failure diagnostic. + + ``state_file`` names the host's file in ``powercontext/`` under the user state directory: ``XDG_STATE_HOME``, by + default ``~/.local/state``, or ``LOCALAPPDATA`` on Windows. ``POWERCONTEXT_DIAGNOSTIC_STATE_FILE`` replaces the + whole path. + """ + + state_file: str + + def should_emit(self, outcome: str) -> bool: + """Return whether a diagnostic should be shown to the host user.""" + + if outcome not in _FAILURE_OUTCOMES: + return True + + try: + now = time.time() + state_path = self._state_path() + with _locked(state_path.with_name(f"{state_path.name}.lock")): + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): + state = {} + if not isinstance(state, dict): + state = {} + previous = state.get(outcome) + if ( + isinstance(previous, (int, float)) + and not isinstance(previous, bool) + and 0 <= now - previous < _COOLDOWN_SECONDS + ): + return False + + state[outcome] = now + temporary_path = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") + temporary_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8") + os.replace(temporary_path, state_path) + except (OSError, TypeError, ValueError): + # Diagnostics must never make a hook invocation fail. + return True + return True + + def _state_path(self) -> Path: + configured = os.environ.get("POWERCONTEXT_DIAGNOSTIC_STATE_FILE") + if configured and configured.strip(): + return Path(configured) + if os.name == "nt": + root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") + else: + root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") + return root / "powercontext" / self.state_file + + +@contextmanager +def _locked(lock_path: Path) -> Iterator[None]: + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/integrations/codex/plugins/powercontext/bootstrap_state.py b/integrations/codex/plugins/powercontext/bootstrap_state.py deleted file mode 100644 index 029b06899c..0000000000 --- a/integrations/codex/plugins/powercontext/bootstrap_state.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Persist only a hashed Session key and the latest bootstrap receipt identity.""" - -from __future__ import annotations - -import json -import os -import stat -from collections.abc import Iterator -from contextlib import contextmanager, suppress -from hashlib import sha256 -from pathlib import Path -from uuid import uuid4 - - -def load_receipt(session_id: str | None, scope_id: str) -> str | None: - state = load_receipt_state(session_id) - return state[1] if state is not None and state[0] == scope_id else None - - -def load_receipt_state(session_id: str | None) -> tuple[str, str] | None: - path = _state_path(session_id) - if path is None: - return None - return _read_receipt_state(path) - - -def _read_receipt_state(path: Path) -> tuple[str, str] | None: - try: - if path.is_symlink() or not path.is_file() or (os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077): - return None - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict) or set(value) != {"version", "scope_id", "receipt_id"}: - return None - scope_id = value.get("scope_id") - receipt_id = value.get("receipt_id") - if ( - value.get("version") != 1 - or not isinstance(scope_id, str) - or not scope_id.strip() - or len(scope_id) > 256 - or not isinstance(receipt_id, str) - or not 1 <= len(receipt_id) <= 64 - or not all("\x21" <= character <= "\x7e" for character in receipt_id) - ): - return None - except (OSError, ValueError, TypeError, json.JSONDecodeError): - return None - return scope_id, receipt_id - - -def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None: - path = _state_path(session_id) - if path is None: - raise OSError - root = path.parent - root.mkdir(mode=0o700, parents=True, exist_ok=True) - if root.is_symlink() or not root.is_dir(): - raise OSError - if os.name != "nt": - root.chmod(0o700) - payload = json.dumps( - {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, - separators=(",", ":"), - ).encode() - with _locked(path): - temporary = root / f".{path.name}.{uuid4().hex}.tmp" - flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor = os.open(temporary, flags, 0o600) - try: - try: - with os.fdopen(descriptor, "wb", closefd=False) as output: - output.write(payload) - output.flush() - os.fsync(output.fileno()) - finally: - # Windows cannot replace the temporary file while its descriptor is open. - os.close(descriptor) - os.replace(temporary, path) - if os.name != "nt": - path.chmod(0o600) - finally: - with suppress(FileNotFoundError): - temporary.unlink() - - -def clear_receipt(session_id: str | None) -> None: - path = _state_path(session_id) - if path is None or not path.parent.exists(): - return - try: - with _locked(path): - if not path.is_symlink(): - path.unlink(missing_ok=True) - except OSError: - return - - -def clear_receipt_if_matches( - session_id: str | None, - scope_id: str, - receipt_id: str, -) -> None: - path = _state_path(session_id) - if path is None or not path.parent.exists(): - return - try: - with _locked(path): - if _read_receipt_state(path) == (scope_id, receipt_id): - path.unlink(missing_ok=True) - except OSError: - return - - -@contextmanager -def _locked(path: Path) -> Iterator[None]: - lock_path = path.with_name(f".{path.name}.lock") - with lock_path.open("a+b") as lock_file: - if os.name == "nt": - import msvcrt - - lock_file.seek(0, os.SEEK_END) - if lock_file.tell() == 0: - lock_file.write(b"\0") - lock_file.flush() - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - try: - yield - finally: - if os.name == "nt": - import msvcrt - - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - -def _state_path(session_id: str | None) -> Path | None: - if session_id is None or not session_id.strip(): - return None - configured = os.environ.get("POWERCONTEXT_BOOTSTRAP_STATE_DIR") - plugin_data = os.environ.get("PLUGIN_DATA") - if configured is None and plugin_data is None: - return None - root = Path(configured or plugin_data or "").expanduser() - if not root.is_absolute(): - return None - digest = sha256(session_id.strip().encode()).hexdigest() - return root / "powercontext-bootstrap" / f"{digest}.json" diff --git a/integrations/codex/plugins/powercontext/codex_host.py b/integrations/codex/plugins/powercontext/codex_host.py new file mode 100644 index 0000000000..dd15be1e9f --- /dev/null +++ b/integrations/codex/plugins/powercontext/codex_host.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""What the shared lifecycle modules need to know about Codex.""" + +from powercontext_lifecycle.bootstrap_state import BootstrapState +from powercontext_lifecycle.diagnostics import DiagnosticThrottle + +BOOTSTRAP_STATE = BootstrapState( + directory_variable="POWERCONTEXT_BOOTSTRAP_STATE_DIR", + plugin_data_variable="PLUGIN_DATA", +) +DIAGNOSTICS = DiagnosticThrottle(state_file="codex-diagnostics.json") diff --git a/integrations/codex/plugins/powercontext/hooks/diagnostics.py b/integrations/codex/plugins/powercontext/hooks/diagnostics.py deleted file mode 100644 index ad36fc6a2f..0000000000 --- a/integrations/codex/plugins/powercontext/hooks/diagnostics.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Best-effort cross-process throttling for host-visible hook diagnostics.""" - -from __future__ import annotations - -import json -import os -import time -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path - -_FAILURE_OUTCOMES = frozenset({ - "authentication_failed", - "authorization_denied", - "delivery_failed", - "invalid_response", - "server_unavailable", - "version_mismatch", -}) -_COOLDOWN_SECONDS = 60.0 - - -def _state_path() -> Path: - configured = os.environ.get("POWERCONTEXT_DIAGNOSTIC_STATE_FILE") - if configured and configured.strip(): - return Path(configured) - if os.name == "nt": - root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") - else: - root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") - return root / "powercontext" / "codex-diagnostics.json" - - -@contextmanager -def _locked(lock_path: Path) -> Iterator[None]: - lock_path.parent.mkdir(parents=True, exist_ok=True) - with lock_path.open("a+b") as lock_file: - if os.name == "nt": - import msvcrt - - lock_file.seek(0, os.SEEK_END) - if lock_file.tell() == 0: - lock_file.write(b"\0") - lock_file.flush() - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - try: - yield - finally: - if os.name == "nt": - import msvcrt - - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl - - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - -def should_emit(outcome: str) -> bool: - """Return whether a diagnostic should be shown to the host user.""" - - if outcome not in _FAILURE_OUTCOMES: - return True - - try: - now = time.time() - state_path = _state_path() - with _locked(state_path.with_name(f"{state_path.name}.lock")): - try: - state = json.loads(state_path.read_text(encoding="utf-8")) - except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): - state = {} - if not isinstance(state, dict): - state = {} - previous = state.get(outcome) - if ( - isinstance(previous, (int, float)) - and not isinstance(previous, bool) - and 0 <= now - previous < _COOLDOWN_SECONDS - ): - return False - - state[outcome] = now - temporary_path = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") - temporary_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8") - os.replace(temporary_path, state_path) - except (OSError, TypeError, ValueError): - # Diagnostics must never make a hook invocation fail. - return True - return True diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index ba935d83fe..fbc2abcc16 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -36,8 +36,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import clear_receipt, load_receipt # noqa: E402 -from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 +from codex_host import BOOTSTRAP_STATE, DIAGNOSTICS # noqa: E402 from plugin_version import PLUGIN_VERSION # noqa: E402 from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 from powercontext_lifecycle.prompt_privacy import scrub_secrets # noqa: E402 @@ -174,8 +173,8 @@ def main(settings: CodexPluginSettings | None = None) -> int: ) _write_hook_output(diagnostic_events=diagnostic_events) return 0 - bootstrap_receipt_id = load_receipt(session_id, scope_id) - clear_receipt(session_id) + bootstrap_receipt_id = BOOTSTRAP_STATE.load_receipt(session_id, scope_id) + BOOTSTRAP_STATE.clear_receipt(session_id) authorization = settings.authorization.get_secret_value() if settings.authorization is not None else None safe_prompt = scrub_secrets(prompt, authorization) query = _bounded_recall_query(safe_prompt) @@ -583,7 +582,7 @@ def _emit_context_event( if key in emitted_diagnostics: return emitted_diagnostics.add(key) - if not _should_emit_diagnostic(outcome): + if not DIAGNOSTICS.should_emit(outcome): return event: dict[str, object] = { "component": "powercontext.codex.recall", diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 55dce27b02..bbd00661c3 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -34,11 +34,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import ( # noqa: E402 - clear_receipt_if_matches, - load_receipt_state, - save_receipt, -) +from codex_host import BOOTSTRAP_STATE # noqa: E402 from plugin_version import PLUGIN_VERSION # noqa: E402 from powercontext_lifecycle.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, @@ -105,7 +101,7 @@ def main(settings: CodexPluginSettings | None = None) -> int: persist_session=True, ) return 0 - previous_receipt = load_receipt_state(session_id) + previous_receipt = BOOTSTRAP_STATE.load_receipt_state(session_id) try: settings = CodexPluginSettings() if settings is None else settings except Exception: @@ -154,7 +150,7 @@ def main(settings: CodexPluginSettings | None = None) -> int: ) return 0 try: - save_receipt(session_id, scope_id, receipt_id) + BOOTSTRAP_STATE.save_receipt(session_id, scope_id, receipt_id) json.dump( { "hookSpecificOutput": { @@ -300,16 +296,16 @@ def _clear_stale_receipt_after_failed_claim( previous_receipt: tuple[str, str] | None, ) -> None: if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): - clear_receipt_if_matches(session_id, *previous_receipt) + BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) def _clear_observed_receipt(session_id: str, previous_receipt: tuple[str, str] | None) -> None: if previous_receipt is not None: - clear_receipt_if_matches(session_id, *previous_receipt) + BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, error: Exception) -> None: - clear_receipt_if_matches(session_id, scope_id, receipt_id) + BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, scope_id, receipt_id) if isinstance(error, BrokenPipeError): try: stdout = sys.stdout.fileno() diff --git a/integrations/codex/plugins/powercontext/hooks/token_savings.py b/integrations/codex/plugins/powercontext/hooks/token_savings.py index 99f60a2511..d62e7ad822 100644 --- a/integrations/codex/plugins/powercontext/hooks/token_savings.py +++ b/integrations/codex/plugins/powercontext/hooks/token_savings.py @@ -32,7 +32,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 +from codex_host import DIAGNOSTICS # noqa: E402 from hooks.recall import ( # noqa: E402 _decode_error_code, _HttpStatusError, @@ -255,7 +255,7 @@ def _append_event( if outcome in emitted: return emitted.add(outcome) - if not _should_emit_diagnostic(outcome): + if not DIAGNOSTICS.should_emit(outcome): return event: dict[str, object] = {"component": _COMPONENT, "event": "status", "outcome": outcome} event.update(fields) diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py new file mode 100644 index 0000000000..861f995e3f --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/bootstrap_state.py @@ -0,0 +1,186 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/bootstrap_state.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Persist only a hashed Session key and the latest bootstrap receipt identity.""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + + +@dataclass(frozen=True) +class BootstrapState: + """Where one host keeps its bootstrap receipts. + + Receipts are kept in ``powercontext-bootstrap/`` under the value of ``directory_variable`` or, when that is unset + or empty, of ``plugin_data_variable``, the host's plugin data directory. ``~`` is expanded; without an absolute + directory, no receipt is kept. + """ + + directory_variable: str + plugin_data_variable: str + + def load_receipt(self, session_id: str | None, scope_id: str) -> str | None: + state = self.load_receipt_state(session_id) + return state[1] if state is not None and state[0] == scope_id else None + + def load_receipt_state(self, session_id: str | None) -> tuple[str, str] | None: + path = self._state_path(session_id) + if path is None: + return None + return _read_receipt_state(path) + + def save_receipt(self, session_id: str | None, scope_id: str, receipt_id: str) -> None: + path = self._state_path(session_id) + if path is None: + raise OSError + root = path.parent + root.mkdir(mode=0o700, parents=True, exist_ok=True) + if root.is_symlink() or not root.is_dir(): + raise OSError + if os.name != "nt": + root.chmod(0o700) + payload = json.dumps( + {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, + separators=(",", ":"), + ).encode() + with _locked(path): + temporary = root / f".{path.name}.{uuid4().hex}.tmp" + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary, flags, 0o600) + try: + try: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + finally: + # Windows cannot replace the temporary file while its descriptor is open. + os.close(descriptor) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + with suppress(FileNotFoundError): + temporary.unlink() + + def clear_receipt(self, session_id: str | None) -> None: + path = self._state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if not path.is_symlink(): + path.unlink(missing_ok=True) + except OSError: + return + + def clear_receipt_if_matches( + self, + session_id: str | None, + scope_id: str, + receipt_id: str, + ) -> None: + path = self._state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if _read_receipt_state(path) == (scope_id, receipt_id): + path.unlink(missing_ok=True) + except OSError: + return + + def _state_path(self, session_id: str | None) -> Path | None: + if session_id is None or not session_id.strip(): + return None + configured = os.environ.get(self.directory_variable) + plugin_data = os.environ.get(self.plugin_data_variable) + if configured is None and plugin_data is None: + return None + root = Path(configured or plugin_data or "").expanduser() + if not root.is_absolute(): + return None + digest = sha256(session_id.strip().encode()).hexdigest() + return root / "powercontext-bootstrap" / f"{digest}.json" + + +def _read_receipt_state(path: Path) -> tuple[str, str] | None: + try: + if path.is_symlink() or not path.is_file() or (os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or set(value) != {"version", "scope_id", "receipt_id"}: + return None + scope_id = value.get("scope_id") + receipt_id = value.get("receipt_id") + if ( + value.get("version") != 1 + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(receipt_id, str) + or not 1 <= len(receipt_id) <= 64 + or not all("\x21" <= character <= "\x7e" for character in receipt_id) + ): + return None + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + return scope_id, receipt_id + + +@contextmanager +def _locked(path: Path) -> Iterator[None]: + lock_path = path.with_name(f".{path.name}.lock") + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/diagnostics.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/diagnostics.py new file mode 100644 index 0000000000..9a0e17ed99 --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/diagnostics.py @@ -0,0 +1,124 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/diagnostics.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Best-effort cross-process throttling for host-visible hook diagnostics.""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) +_COOLDOWN_SECONDS = 60.0 + + +@dataclass(frozen=True) +class DiagnosticThrottle: + """Limits how often one host shows its user the same failure diagnostic. + + ``state_file`` names the host's file in ``powercontext/`` under the user state directory: ``XDG_STATE_HOME``, by + default ``~/.local/state``, or ``LOCALAPPDATA`` on Windows. ``POWERCONTEXT_DIAGNOSTIC_STATE_FILE`` replaces the + whole path. + """ + + state_file: str + + def should_emit(self, outcome: str) -> bool: + """Return whether a diagnostic should be shown to the host user.""" + + if outcome not in _FAILURE_OUTCOMES: + return True + + try: + now = time.time() + state_path = self._state_path() + with _locked(state_path.with_name(f"{state_path.name}.lock")): + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): + state = {} + if not isinstance(state, dict): + state = {} + previous = state.get(outcome) + if ( + isinstance(previous, (int, float)) + and not isinstance(previous, bool) + and 0 <= now - previous < _COOLDOWN_SECONDS + ): + return False + + state[outcome] = now + temporary_path = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") + temporary_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8") + os.replace(temporary_path, state_path) + except (OSError, TypeError, ValueError): + # Diagnostics must never make a hook invocation fail. + return True + return True + + def _state_path(self) -> Path: + configured = os.environ.get("POWERCONTEXT_DIAGNOSTIC_STATE_FILE") + if configured and configured.strip(): + return Path(configured) + if os.name == "nt": + root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") + else: + root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") + return root / "powercontext" / self.state_file + + +@contextmanager +def _locked(lock_path: Path) -> Iterator[None]: + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/src/powercontext_integration_lifecycle/bootstrap_state.py b/src/powercontext_integration_lifecycle/bootstrap_state.py new file mode 100644 index 0000000000..e60463e883 --- /dev/null +++ b/src/powercontext_integration_lifecycle/bootstrap_state.py @@ -0,0 +1,183 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Persist only a hashed Session key and the latest bootstrap receipt identity.""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + + +@dataclass(frozen=True) +class BootstrapState: + """Where one host keeps its bootstrap receipts. + + Receipts are kept in ``powercontext-bootstrap/`` under the value of ``directory_variable`` or, when that is unset + or empty, of ``plugin_data_variable``, the host's plugin data directory. ``~`` is expanded; without an absolute + directory, no receipt is kept. + """ + + directory_variable: str + plugin_data_variable: str + + def load_receipt(self, session_id: str | None, scope_id: str) -> str | None: + state = self.load_receipt_state(session_id) + return state[1] if state is not None and state[0] == scope_id else None + + def load_receipt_state(self, session_id: str | None) -> tuple[str, str] | None: + path = self._state_path(session_id) + if path is None: + return None + return _read_receipt_state(path) + + def save_receipt(self, session_id: str | None, scope_id: str, receipt_id: str) -> None: + path = self._state_path(session_id) + if path is None: + raise OSError + root = path.parent + root.mkdir(mode=0o700, parents=True, exist_ok=True) + if root.is_symlink() or not root.is_dir(): + raise OSError + if os.name != "nt": + root.chmod(0o700) + payload = json.dumps( + {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, + separators=(",", ":"), + ).encode() + with _locked(path): + temporary = root / f".{path.name}.{uuid4().hex}.tmp" + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary, flags, 0o600) + try: + try: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + finally: + # Windows cannot replace the temporary file while its descriptor is open. + os.close(descriptor) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + with suppress(FileNotFoundError): + temporary.unlink() + + def clear_receipt(self, session_id: str | None) -> None: + path = self._state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if not path.is_symlink(): + path.unlink(missing_ok=True) + except OSError: + return + + def clear_receipt_if_matches( + self, + session_id: str | None, + scope_id: str, + receipt_id: str, + ) -> None: + path = self._state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if _read_receipt_state(path) == (scope_id, receipt_id): + path.unlink(missing_ok=True) + except OSError: + return + + def _state_path(self, session_id: str | None) -> Path | None: + if session_id is None or not session_id.strip(): + return None + configured = os.environ.get(self.directory_variable) + plugin_data = os.environ.get(self.plugin_data_variable) + if configured is None and plugin_data is None: + return None + root = Path(configured or plugin_data or "").expanduser() + if not root.is_absolute(): + return None + digest = sha256(session_id.strip().encode()).hexdigest() + return root / "powercontext-bootstrap" / f"{digest}.json" + + +def _read_receipt_state(path: Path) -> tuple[str, str] | None: + try: + if path.is_symlink() or not path.is_file() or (os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or set(value) != {"version", "scope_id", "receipt_id"}: + return None + scope_id = value.get("scope_id") + receipt_id = value.get("receipt_id") + if ( + value.get("version") != 1 + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(receipt_id, str) + or not 1 <= len(receipt_id) <= 64 + or not all("\x21" <= character <= "\x7e" for character in receipt_id) + ): + return None + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + return scope_id, receipt_id + + +@contextmanager +def _locked(path: Path) -> Iterator[None]: + lock_path = path.with_name(f".{path.name}.lock") + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/src/powercontext_integration_lifecycle/diagnostics.py b/src/powercontext_integration_lifecycle/diagnostics.py new file mode 100644 index 0000000000..ff7793de2d --- /dev/null +++ b/src/powercontext_integration_lifecycle/diagnostics.py @@ -0,0 +1,121 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Best-effort cross-process throttling for host-visible hook diagnostics.""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) +_COOLDOWN_SECONDS = 60.0 + + +@dataclass(frozen=True) +class DiagnosticThrottle: + """Limits how often one host shows its user the same failure diagnostic. + + ``state_file`` names the host's file in ``powercontext/`` under the user state directory: ``XDG_STATE_HOME``, by + default ``~/.local/state``, or ``LOCALAPPDATA`` on Windows. ``POWERCONTEXT_DIAGNOSTIC_STATE_FILE`` replaces the + whole path. + """ + + state_file: str + + def should_emit(self, outcome: str) -> bool: + """Return whether a diagnostic should be shown to the host user.""" + + if outcome not in _FAILURE_OUTCOMES: + return True + + try: + now = time.time() + state_path = self._state_path() + with _locked(state_path.with_name(f"{state_path.name}.lock")): + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): + state = {} + if not isinstance(state, dict): + state = {} + previous = state.get(outcome) + if ( + isinstance(previous, (int, float)) + and not isinstance(previous, bool) + and 0 <= now - previous < _COOLDOWN_SECONDS + ): + return False + + state[outcome] = now + temporary_path = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") + temporary_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8") + os.replace(temporary_path, state_path) + except (OSError, TypeError, ValueError): + # Diagnostics must never make a hook invocation fail. + return True + return True + + def _state_path(self) -> Path: + configured = os.environ.get("POWERCONTEXT_DIAGNOSTIC_STATE_FILE") + if configured and configured.strip(): + return Path(configured) + if os.name == "nt": + root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") + else: + root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") + return root / "powercontext" / self.state_file + + +@contextmanager +def _locked(lock_path: Path) -> Iterator[None]: + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/tests/claude_code_plugin/conftest.py b/tests/claude_code_plugin/conftest.py index 0dea0411ac..2184c30a30 100644 --- a/tests/claude_code_plugin/conftest.py +++ b/tests/claude_code_plugin/conftest.py @@ -26,12 +26,13 @@ CLAUDE_CODE_ROOT = REPOSITORY_ROOT / "integrations" / "claude-code" PLUGIN_ROOT = CLAUDE_CODE_ROOT / "plugins" / "powercontext" _PLUGIN_MODULE_NAMES = ( - "bootstrap_state", + "claude_code_host", "claude_code_settings", - "hooks", "powercontext_lifecycle", "powercontext_lifecycle.bootstrap_context", + "powercontext_lifecycle.bootstrap_state", "powercontext_lifecycle.client_config", + "powercontext_lifecycle.diagnostics", "powercontext_lifecycle.prepared_context", "powercontext_lifecycle.prompt_privacy", "scripts", @@ -84,6 +85,8 @@ def hook_module(plugin_imports: None) -> ModuleType: def isolated_diagnostic_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("POWERCONTEXT_DIAGNOSTIC_STATE_FILE", str(tmp_path / "claude-code-diagnostics.json")) monkeypatch.setenv("POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR", str(tmp_path / "plugin-data")) + # The host's plugin data directory is set as well, so the tests notice if it takes precedence over the override. + monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "host-plugin-data")) @pytest.fixture diff --git a/tests/claude_code_plugin/test_bootstrap.py b/tests/claude_code_plugin/test_bootstrap.py index a0d6f5fd9a..91940b2bcf 100644 --- a/tests/claude_code_plugin/test_bootstrap.py +++ b/tests/claude_code_plugin/test_bootstrap.py @@ -192,7 +192,7 @@ def test_repeated_injected_event_preserves_receipt_for_first_query( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_start_module.save_receipt("session-1", "scope:test", "bcr_test") + session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) monkeypatch.setattr( @@ -221,7 +221,7 @@ def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( session_start_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - bootstrap_state = sys.modules[session_start_module.load_receipt_state.__module__] + bootstrap_state = sys.modules[session_start_module.BOOTSTRAP_STATE.load_receipt_state.__module__] original_read = bootstrap_state._read_receipt_state snapshot_read = Event() winner_saved = Event() @@ -237,7 +237,7 @@ def synchronized_read(path): def save_winner() -> None: if snapshot_read.wait(2): - session_start_module.save_receipt("session-1", "scope:test", "bcr_test") + session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") winner_saved.set() def post(*_args, **_kwargs): @@ -268,7 +268,7 @@ def post(*_args, **_kwargs): assert winner is not None winner.join(timeout=2) assert not winner.is_alive() - assert session_start_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_start_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_invalid_settings_clear_a_stale_receipt( @@ -276,7 +276,7 @@ def test_invalid_settings_clear_a_stale_receipt( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") + session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") def invalid_settings(): raise ValueError @@ -376,13 +376,13 @@ def test_concurrent_delivery_loser_preserves_the_winner_receipt( session_start_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") + session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") def post(path, payload, **_kwargs): if path == "/v1/context/bootstrap": return _ready() if payload["outcome"] == "injected": - session_start_module.save_receipt("session-1", "scope:test", "bcr_test") + session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") raise OSError return {"receipt_id": "bcr_test", "state": "injected"} @@ -397,7 +397,7 @@ def post(path, payload, **_kwargs): monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 - assert session_start_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_start_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_session_start_output_failure_clears_the_deduplication_receipt( @@ -473,7 +473,7 @@ def post(*_args, **_kwargs): monkeypatch.setattr(session_start_module, "_post_json", post) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") + session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") monkeypatch.setattr( sys, "stdin", diff --git a/tests/claude_code_plugin/test_hook.py b/tests/claude_code_plugin/test_hook.py index fbcef52a9a..ed00e8a5b6 100644 --- a/tests/claude_code_plugin/test_hook.py +++ b/tests/claude_code_plugin/test_hook.py @@ -723,7 +723,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or handler: type[BaseHTTPRequestHandler], outcome: str, ) -> None: - session_start_module.save_receipt("session-a", "scope:test", "bcr_previous") + session_start_module.BOOTSTRAP_STATE.save_receipt("session-a", "scope:test", "bcr_previous") with _serve(handler) as server_url: _, errors = _run_main( session_start_module, @@ -733,7 +733,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or ) assert json.loads(errors)["outcome"] == outcome - assert session_start_module.load_receipt_state("session-a") is None + assert session_start_module.BOOTSTRAP_STATE.load_receipt_state("session-a") is None @pytest.mark.parametrize( diff --git a/tests/codex_plugin/conftest.py b/tests/codex_plugin/conftest.py index 4f10acb80b..cff577f479 100644 --- a/tests/codex_plugin/conftest.py +++ b/tests/codex_plugin/conftest.py @@ -48,6 +48,8 @@ def isolated_diagnostic_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) - monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) monkeypatch.setenv("POWERCONTEXT_DIAGNOSTIC_STATE_FILE", str(tmp_path / "codex-diagnostics.json")) monkeypatch.setenv("POWERCONTEXT_BOOTSTRAP_STATE_DIR", str(tmp_path / "plugin-data")) + # The host's plugin data directory is set as well, so the tests notice if it takes precedence over the override. + monkeypatch.setenv("PLUGIN_DATA", str(tmp_path / "host-plugin-data")) monkeypatch.delenv("POWERCONTEXT_CODEX_AUTHORIZATION", raising=False) monkeypatch.delenv("POWERCONTEXT_CODEX_SCOPE_ID", raising=False) monkeypatch.delenv("POWERCONTEXT_CLIENT_API_TOKEN", raising=False) diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py index 3c9ec73eed..03a342f2e7 100644 --- a/tests/codex_plugin/test_bootstrap.py +++ b/tests/codex_plugin/test_bootstrap.py @@ -221,7 +221,7 @@ def test_repeated_injected_event_preserves_receipt_for_first_query( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_binding_module.save_receipt("session-1", "scope:test", "bcr_test") + session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) monkeypatch.setattr( @@ -250,7 +250,7 @@ def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( session_binding_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - bootstrap_state = sys.modules[session_binding_module.load_receipt_state.__module__] + bootstrap_state = sys.modules[session_binding_module.BOOTSTRAP_STATE.load_receipt_state.__module__] original_read = bootstrap_state._read_receipt_state snapshot_read = Event() winner_saved = Event() @@ -266,7 +266,7 @@ def synchronized_read(path): def save_winner() -> None: if snapshot_read.wait(2): - session_binding_module.save_receipt("session-1", "scope:test", "bcr_test") + session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") winner_saved.set() def post(*_args, **_kwargs): @@ -297,7 +297,7 @@ def post(*_args, **_kwargs): assert winner is not None winner.join(timeout=2) assert not winner.is_alive() - assert session_binding_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_binding_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_invalid_settings_clear_a_stale_receipt( @@ -305,7 +305,7 @@ def test_invalid_settings_clear_a_stale_receipt( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") + session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") def invalid_settings(): raise ValueError @@ -401,13 +401,13 @@ def test_concurrent_delivery_loser_preserves_the_winner_receipt( session_binding_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") + session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") def post(path, payload, **_kwargs): if path == "/v1/context/bootstrap": return _ready() if payload["outcome"] == "injected": - session_binding_module.save_receipt("session-1", "scope:test", "bcr_test") + session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") raise OSError return {"receipt_id": "bcr_test", "state": "injected"} @@ -422,7 +422,7 @@ def post(path, payload, **_kwargs): monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 - assert session_binding_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_binding_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_session_start_output_failure_clears_the_deduplication_receipt( @@ -498,7 +498,7 @@ def post(*_args, **_kwargs): monkeypatch.setattr(session_binding_module, "_post_json", post) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") + session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") monkeypatch.setattr( sys, "stdin", diff --git a/tests/codex_plugin/test_recall.py b/tests/codex_plugin/test_recall.py index 5c40a2aa72..cdcb61ba1f 100644 --- a/tests/codex_plugin/test_recall.py +++ b/tests/codex_plugin/test_recall.py @@ -710,7 +710,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or handler: type[BaseHTTPRequestHandler], outcome: str, ) -> None: - session_binding_module.save_receipt("thread-a", "scope:test", "bcr_previous") + session_binding_module.BOOTSTRAP_STATE.save_receipt("thread-a", "scope:test", "bcr_previous") with _serve(handler) as server_url: settings = session_binding_module.CodexPluginSettings() object.__setattr__(settings, "server_url", server_url) @@ -722,7 +722,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or ) assert json.loads(errors)["outcome"] == outcome - assert session_binding_module.load_receipt_state("thread-a") is None + assert session_binding_module.BOOTSTRAP_STATE.load_receipt_state("thread-a") is None @pytest.mark.parametrize( diff --git a/tests/test_hook_diagnostics.py b/tests/test_hook_diagnostics.py index 1d5cd2c0ea..b1d2258fb6 100644 --- a/tests/test_hook_diagnostics.py +++ b/tests/test_hook_diagnostics.py @@ -14,24 +14,14 @@ from __future__ import annotations -import importlib.util import subprocess import sys import time from pathlib import Path -from types import ModuleType import pytest -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] - - -def _load_diagnostics(name: str, path: Path) -> ModuleType: - spec = importlib.util.spec_from_file_location(name, path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module +from powercontext_integration_lifecycle.diagnostics import DiagnosticThrottle def _lock_holder_code() -> str: @@ -70,24 +60,9 @@ def _lock_holder_code() -> str: """ -@pytest.mark.parametrize( - ("name", "relative_path"), - [ - ("codex", "integrations/codex/plugins/powercontext/hooks/diagnostics.py"), - ("claude_code", "integrations/claude-code/plugins/powercontext/hooks/diagnostics.py"), - ], -) -def test_diagnostic_lock_contention_is_bounded( - name: str, - relative_path: str, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - diagnostics = _load_diagnostics( - f"powercontext_{name}_diagnostics_contention", - REPOSITORY_ROOT / relative_path, - ) - state_path = tmp_path / f"{name}-diagnostics.json" +def test_diagnostic_lock_contention_is_bounded(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + diagnostics = DiagnosticThrottle(state_file="diagnostics.json") + state_path = tmp_path / "diagnostics.json" lock_path = state_path.with_name(f"{state_path.name}.lock") monkeypatch.setenv("POWERCONTEXT_DIAGNOSTIC_STATE_FILE", str(state_path)) From c1c9fb2de6c415ada868b8ed4cc6c5cc3b054df0 Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Tue, 27 Oct 2026 23:27:16 -0400 Subject: [PATCH 3/4] refactor(integrations): move the SessionStart flow and bounded HTTP into the lifecycle package Claude Code's hooks/session_start.py and Codex's hooks/session_binding.py ran the same SessionStart flow: resolve the Scope, request the bootstrap package, record its delivery, keep the receipt for the first prompt, and report failures on stderr. Their Scope clients carried the same bounded HTTP helpers, open_bounded, bind_response_deadline, and complete_body, and both plugins defined the same typed Scope binding errors. The response deadline raised socket.timeout in Claude Code, which Python 3.9 needs, and TimeoutError in Codex, the same class on the Python 3.11 and later that Codex runs; the shared helper raises socket.timeout. Both hooks read the bootstrap response, and the error code of a failed request, under the deadline that the request started, so slow headers leave the body only what remains; the flow keeps that deadline. The flow now lives once in src/powercontext_integration_lifecycle/, in session_start.py, with the HTTP helpers in transport.py and the errors in scope_binding_errors.py, which replaces Claude Code's module of that name and Codex's classes in scope_binding.py. A HostProfile in claude_code_host.py and codex_host.py names what differs between the hosts: the integration name, the User-Agent, the diagnostic component, the diagnostic throttle, and the receipt state, which the plugins declared separately before. The hooks keep what is native to their host: they validate the payload, accept their own lifecycles, pass their settings to the flow as plain values, write the package in their output format, and, as before, catch what the flow does not handle. Along with the Scope resolver, they pass the flow their Scope client's reader for the error code of a failed bootstrap request, since the clients stay in the plugins. Codex still binds the session when the lifecycle is missing or unknown. Everything else that used the helpers or the errors, from the prompt hooks to the Claude Code status line, imports them from the package. These are the first package modules that import their siblings. They do so relatively, because the copies use another name than the source, and only modules that WorkBuddy and Hermes do not ship may do it; the source package test enforces both. ty no longer checks the Codex package copy: it resolves powercontext_lifecycle to the identical Claude Code copy, so it cannot resolve the relative imports of the Codex copy. It still checks the source and the Claude Code copy. Requests, receipts, diagnostics, and output are unchanged, and the Claude Code and Codex characterization fixtures pass as recorded. The hooks now read the event identifier before the previous receipt and convert their settings, handoff included, before the Scope is resolved; after a broken pipe they point stdout at the null device before the receipt is cleared. None of these readings can fail, and the null device affects only stdout, so the new order is not observable. The bootstrap tests intercept the Server requests in the shared flow instead of the hooks, and the tests that keep or check a receipt reach it through the hook's HostProfile, with their assertions unchanged. A new test for each host checks that the hook passes its configured credential, time limits, size limit, and handoff to the flow; the characterization fixtures do not cover all of them. --- .../plugins/powercontext/claude_code_host.py | 14 +- .../powercontext/hooks/session_start.py | 364 ++--------------- .../powercontext/hooks/user_prompt_submit.py | 13 +- .../powercontext_lifecycle/__init__.py | 5 +- .../powercontext_lifecycle/host_profile.py | 41 ++ .../scope_binding_errors.py | 46 +++ .../powercontext_lifecycle/session_start.py | 381 ++++++++++++++++++ .../powercontext_lifecycle/transport.py | 135 +++++++ .../powercontext/scripts/statusline.py | 5 +- .../powercontext/scripts/workspace_scope.py | 128 +----- .../codex/plugins/powercontext/codex_host.py | 15 +- .../plugins/powercontext/hooks/bind_tools.py | 6 +- .../plugins/powercontext/hooks/recall.py | 16 +- .../powercontext/hooks/session_binding.py | 362 ++--------------- .../powercontext/hooks/token_savings.py | 10 +- .../powercontext_lifecycle/__init__.py | 5 +- .../powercontext_lifecycle/host_profile.py | 41 ++ .../scope_binding_errors.py | 46 +++ .../powercontext_lifecycle/session_start.py | 381 ++++++++++++++++++ .../powercontext_lifecycle/transport.py | 135 +++++++ .../powercontext/scripts/scope_binding.py | 145 +------ pyproject.toml | 3 + .../__init__.py | 5 +- .../host_profile.py | 38 ++ .../scope_binding_errors.py | 12 +- .../session_start.py | 378 +++++++++++++++++ .../transport.py | 132 ++++++ tests/claude_code_plugin/conftest.py | 9 + tests/claude_code_plugin/test_bootstrap.py | 127 ++++-- tests/claude_code_plugin/test_hook.py | 4 +- tests/codex_plugin/conftest.py | 6 + tests/codex_plugin/test_bootstrap.py | 124 ++++-- tests/codex_plugin/test_recall.py | 4 +- tests/test_integration_lifecycle.py | 11 +- 34 files changed, 2133 insertions(+), 1014 deletions(-) create mode 100644 integrations/claude-code/plugins/powercontext/powercontext_lifecycle/host_profile.py create mode 100644 integrations/claude-code/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py create mode 100644 integrations/claude-code/plugins/powercontext/powercontext_lifecycle/session_start.py create mode 100644 integrations/claude-code/plugins/powercontext/powercontext_lifecycle/transport.py create mode 100644 integrations/codex/plugins/powercontext/powercontext_lifecycle/host_profile.py create mode 100644 integrations/codex/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py create mode 100644 integrations/codex/plugins/powercontext/powercontext_lifecycle/session_start.py create mode 100644 integrations/codex/plugins/powercontext/powercontext_lifecycle/transport.py create mode 100644 src/powercontext_integration_lifecycle/host_profile.py rename {integrations/claude-code/plugins/powercontext => src/powercontext_integration_lifecycle}/scope_binding_errors.py (83%) create mode 100644 src/powercontext_integration_lifecycle/session_start.py create mode 100644 src/powercontext_integration_lifecycle/transport.py diff --git a/integrations/claude-code/plugins/powercontext/claude_code_host.py b/integrations/claude-code/plugins/powercontext/claude_code_host.py index b77d9b29ce..94e09b8b14 100644 --- a/integrations/claude-code/plugins/powercontext/claude_code_host.py +++ b/integrations/claude-code/plugins/powercontext/claude_code_host.py @@ -16,9 +16,15 @@ from powercontext_lifecycle.bootstrap_state import BootstrapState from powercontext_lifecycle.diagnostics import DiagnosticThrottle +from powercontext_lifecycle.host_profile import HostProfile -BOOTSTRAP_STATE = BootstrapState( - directory_variable="POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR", - plugin_data_variable="CLAUDE_PLUGIN_DATA", +CLAUDE_CODE = HostProfile( + integration="claude-code", + user_agent="powercontext-claude-code-plugin/0.1.3", + component="powercontext.claude_code", + diagnostics=DiagnosticThrottle(state_file="claude-code-diagnostics.json"), + bootstrap_state=BootstrapState( + directory_variable="POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR", + plugin_data_variable="CLAUDE_PLUGIN_DATA", + ), ) -DIAGNOSTICS = DiagnosticThrottle(state_file="claude-code-diagnostics.json") diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py index 92af57e56d..78bb596467 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/session_start.py +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -20,68 +20,25 @@ import json import os import sys -from collections.abc import Mapping -from contextlib import suppress -from http.client import HTTPException from pathlib import Path -from time import monotonic -from typing import Any, Protocol, cast -from urllib.error import HTTPError -from urllib.request import Request +from typing import Any, cast _PLUGIN_ROOT = Path(__file__).resolve().parents[1] _SCRIPTS_ROOT = _PLUGIN_ROOT / "scripts" sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from claude_code_host import BOOTSTRAP_STATE # noqa: E402 +from claude_code_host import CLAUDE_CODE # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 -from powercontext_lifecycle.bootstrap_context import ( # noqa: E402 - InvalidBootstrapResponse, - validate_bootstrap_context, - validate_delivery_receipt, -) -from scope_binding_errors import ( # noqa: E402 - ScopeBindingError, - ScopeBindingRejectedError, - ScopeBindingStatusError, - ScopeBindingUnavailableError, -) -from workspace_scope import ( # noqa: E402 - bind_response_deadline, - complete_body, - open_bounded, - read_error_code, - resolve_scope_id, +from powercontext_lifecycle.session_start import ( # noqa: E402 + BootstrapConnection, + SessionStartEvent, + hook_event_id, + run_session_start, ) +from workspace_scope import read_error_code, resolve_scope_id # noqa: E402 _LIFECYCLES = frozenset({"startup", "resume", "clear", "compact", "fork"}) -_MAX_RESPONSE_BYTES = 1_048_576 -_READ_CHUNK_BYTES = 65_536 -_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" -_REQUEST_HEADERS = { - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": "powercontext-claude-code-plugin/0.1.3", -} - - -class _ReadableResponse(Protocol): - status: int - - def read(self, amount: int = -1) -> bytes: ... - - def __enter__(self) -> _ReadableResponse: ... - - def __exit__(self, *args: object) -> object: ... - - -class _HttpStatusError(RuntimeError): - def __init__(self, status: int, path: str, code: str | None = None) -> None: - self.status = status - self.path = path - self.code = code - super().__init__(f"PowerContext returned HTTP {status}") def main(settings: ClaudeCodePluginSettings | None = None) -> int: @@ -99,211 +56,49 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: or source not in _LIFECYCLES ): return 0 - previous_receipt = BOOTSTRAP_STATE.load_receipt_state(session_id) - try: - settings = ClaudeCodePluginSettings.from_environment() if settings is None else settings - except Exception: - _clear_observed_receipt(session_id, previous_receipt) - return 0 - deadline = monotonic() + settings.http_budget_seconds - prepared = _resolve_and_prepare(cwd, session_id, source, payload, settings=settings, deadline=deadline) - if prepared is None: - _clear_observed_receipt(session_id, previous_receipt) - return 0 - scope_id, response = prepared - if response["status"] != "ready": - receipt = _synchronize_non_ready_receipt(session_id, scope_id, response, previous_receipt) - if response["status"] == "empty": - _emit_bootstrap_event( - "empty", - http_status=200, - context_status="empty", - content_bytes=0, - truncated=cast(bool, response["truncated"]), - receipt_state=receipt["state"], - ) - return 0 - receipt = cast(dict[str, str], response["receipt"]) - receipt_id = receipt["receipt_id"] - try: - delivered = validate_delivery_receipt( - _post_json( - "/v1/context/bootstrap/receipts", - {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "injected"}, - settings=settings, - deadline=deadline, - ), - receipt_id=receipt_id, - ) - _require_injected(delivered) - except Exception: - _clear_stale_receipt_after_failed_claim(session_id, scope_id, receipt_id, previous_receipt) - _emit_bootstrap_event("delivery_failed") - with suppress(Exception): - _post_json( - "/v1/context/bootstrap/receipts", - {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "failed"}, - settings=settings, - deadline=deadline, - ) - return 0 - try: - BOOTSTRAP_STATE.save_receipt(session_id, scope_id, receipt_id) - json.dump( - { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": response["content"], - } - }, - sys.stdout, - separators=(",", ":"), - ) - sys.stdout.write("\n") - sys.stdout.flush() - except Exception as error: - _handle_output_failure(session_id, scope_id, receipt_id, error) - return 0 + event = SessionStartEvent(session_id=session_id, cwd=cwd, lifecycle=source, event_id=hook_event_id(payload)) + run_session_start(CLAUDE_CODE, event, connect=lambda: _connect(event, settings), emit=_emit_context) except Exception: return 0 return 0 -def _resolve_and_prepare( - cwd: str, - session_id: str, - source: str, - payload: Mapping[str, object], - *, - settings: ClaudeCodePluginSettings, - deadline: float, -) -> tuple[str, dict[str, object]] | None: - try: - scope_id = resolve_scope_id( - cwd, - session_id=session_id, - settings=settings, +def _connect(event: SessionStartEvent, settings: ClaudeCodePluginSettings | None) -> BootstrapConnection: + loaded = ClaudeCodePluginSettings.from_environment() if settings is None else settings + return BootstrapConnection( + server_url=loaded.server_url, + authorization=loaded.authorization, + request_timeout_seconds=loaded.request_timeout_seconds, + http_budget_seconds=loaded.http_budget_seconds, + enabled=loaded.bootstrap_context, + max_bytes=loaded.bootstrap_max_bytes, + handoff=None if loaded.bootstrap_handoff is None else loaded.bootstrap_handoff.as_request(), + resolve_scope_id=lambda deadline: resolve_scope_id( + event.cwd, + session_id=event.session_id, + settings=loaded, deadline=deadline, - ) - request: dict[str, object] = { - "scope_id": scope_id, - "enabled": settings.bootstrap_context, - "profile": _BOOTSTRAP_PROFILE, - "lifecycle": source, - "integration": "claude-code", - "max_bytes": settings.bootstrap_max_bytes, - } - if settings.bootstrap_handoff is not None: - request["handoff"] = settings.bootstrap_handoff.as_request() - event_id = _identifier(payload, "event_id", "hook_id", "invocation_id", "request_id") - if event_id is not None: - request["event_id"] = event_id - response = validate_bootstrap_context( - _post_json("/v1/context/bootstrap", request, settings=settings, deadline=deadline), - max_bytes=settings.bootstrap_max_bytes, - ) - except ScopeBindingRejectedError: - _emit_bootstrap_event("authentication_failed") - except (ScopeBindingUnavailableError, OSError, TimeoutError, HTTPException): - # http.client reports a truncated or malformed response as HTTPException, not OSError. - _emit_bootstrap_event("server_unavailable", recovery="powercontext doctor") - except (ScopeBindingStatusError, _HttpStatusError) as error: - _emit_http_failure(error.status, error.code) - except (ScopeBindingError, InvalidBootstrapResponse): - _emit_bootstrap_event("invalid_response") - else: - return scope_id, response - return None - - -def _identifier(payload: Mapping[str, object], *names: str) -> str | None: - for name in names: - value = payload.get(name) - if isinstance(value, str) and value.strip() and len(value.strip()) <= 512: - return value.strip() - return None - - -def _post_json( - path: str, - payload: Mapping[str, object], - *, - settings: ClaudeCodePluginSettings, - deadline: float, -) -> dict[str, object]: - headers = dict(_REQUEST_HEADERS) - if settings.authorization is not None: - headers["Authorization"] = settings.authorization - request = Request( # noqa: S310 - settings validates the configured transport. - f"{settings.server_url}{path}", - data=json.dumps(payload, separators=(",", ":")).encode(), - headers=headers, - method="POST", + ), + read_error_code=read_error_code, ) - timeout = min(settings.request_timeout_seconds, _remaining_time(deadline)) - # One deadline covers the headers and the body, so a slow header phase leaves the body only what is left. - request_deadline = min(deadline, monotonic() + timeout) - try: - with open_bounded(request, timeout=timeout) as response: - if response.status != 200: - raise _HttpStatusError(response.status, path) - raw = _read_response(response, deadline=request_deadline) - except HTTPError as error: - code = read_error_code(error, deadline=request_deadline) - raise _HttpStatusError(error.code, path, code) from error - try: - value = json.loads(raw) - except (ValueError, RecursionError) as error: - # Besides JSONDecodeError, json raises ValueError for an over-long integer and RecursionError for deep nesting. - raise InvalidBootstrapResponse from error - if not isinstance(value, dict): - raise InvalidBootstrapResponse - return cast(dict[str, object], value) - - -def _read_response(response: _ReadableResponse, *, deadline: float) -> bytes: - bind_response_deadline(response, deadline) - content = bytearray() - while True: - remaining = _MAX_RESPONSE_BYTES + 1 - len(content) - chunk = response.read(min(_READ_CHUNK_BYTES, remaining)) - if not chunk: - return complete_body(response, bytes(content)) - content.extend(chunk) - if len(content) > _MAX_RESPONSE_BYTES: - raise InvalidBootstrapResponse - - -def _remaining_time(deadline: float) -> float: - remaining = deadline - monotonic() - if remaining <= 0: - raise TimeoutError - return remaining - - -def _require_injected(receipt: Mapping[str, str]) -> None: - if receipt.get("state") != "injected": - raise InvalidBootstrapResponse - - -def _clear_stale_receipt_after_failed_claim( - session_id: str, - scope_id: str, - receipt_id: str, - previous_receipt: tuple[str, str] | None, -) -> None: - if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): - BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) - -def _clear_observed_receipt(session_id: str, previous_receipt: tuple[str, str] | None) -> None: - if previous_receipt is not None: - BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) - -def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, error: Exception) -> None: - BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, scope_id, receipt_id) - if isinstance(error, BrokenPipeError): +def _emit_context(content: str) -> None: + try: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": content, + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + sys.stdout.flush() + except BrokenPipeError: + # Python flushes stdout again at exit; pointing it at the null device keeps that flush quiet. try: stdout = sys.stdout.fileno() descriptor = os.open(os.devnull, os.O_WRONLY) @@ -313,80 +108,7 @@ def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, erro os.close(descriptor) except (AttributeError, OSError, ValueError): pass - _emit_bootstrap_event("delivery_failed") - - -def _synchronize_non_ready_receipt( - session_id: str, - scope_id: str, - response: Mapping[str, object], - previous_receipt: tuple[str, str] | None, -) -> dict[str, str]: - """Retain a receipt only when this exact lifecycle event was already injected.""" - - receipt = cast(dict[str, str], response["receipt"]) - if ( - response.get("status") == "skipped" - and response.get("reason") == "already_delivered" - and receipt["state"] == "injected" - and previous_receipt == (scope_id, receipt["receipt_id"]) - ): - return receipt - _clear_observed_receipt(session_id, previous_receipt) - return receipt - - -def _emit_http_failure(status: int, code: str | None = None) -> None: - if status == 401: - outcome = "authentication_failed" - elif status == 403: - outcome = "authorization_denied" - elif status == 404 and code is None: - outcome = "version_mismatch" - elif status == 503: - outcome = "server_unavailable" - else: - outcome = "invalid_response" - _emit_bootstrap_event( - outcome, - http_status=status, - error_code=code, - recovery="powercontext doctor" if outcome == "server_unavailable" else None, - ) - - -def _emit_bootstrap_event( - outcome: str, - *, - http_status: int | None = None, - error_code: str | None = None, - context_status: str | None = None, - content_bytes: int | None = None, - truncated: bool | None = None, - receipt_state: str | None = None, - recovery: str | None = None, -) -> None: - event: dict[str, object] = { - "component": "powercontext.claude_code.bootstrap", - "event": "context_bootstrap", - "outcome": outcome, - "profile": _BOOTSTRAP_PROFILE, - } - if http_status is not None: - event["http_status"] = http_status - if error_code is not None: - event["error_code"] = error_code - if context_status is not None: - event["context_status"] = context_status - if content_bytes is not None: - event["content_bytes"] = content_bytes - if truncated is not None: - event["truncated"] = truncated - if receipt_state is not None: - event["receipt_state"] = receipt_state - if recovery is not None: - event["recovery"] = recovery - sys.stderr.write(json.dumps(event, separators=(",", ":")) + "\n") + raise if __name__ == "__main__": diff --git a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py index da8052371d..135d055c8b 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -34,16 +34,17 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from claude_code_host import BOOTSTRAP_STATE, DIAGNOSTICS # noqa: E402 +from claude_code_host import CLAUDE_CODE # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 from powercontext_lifecycle.prompt_privacy import scrub_secrets # noqa: E402 -from scope_binding_errors import ( # noqa: E402 +from powercontext_lifecycle.scope_binding_errors import ( # noqa: E402 ScopeBindingRejectedError, ScopeBindingStatusError, ScopeBindingUnavailableError, ) -from workspace_scope import bind_response_deadline, complete_body, open_bounded, resolve_scope_id # noqa: E402 +from powercontext_lifecycle.transport import bind_response_deadline, complete_body, open_bounded # noqa: E402 +from workspace_scope import resolve_scope_id # noqa: E402 _MAX_CONTEXT_BYTES = _prepared_context.MAX_CONTEXT_BYTES _InvalidResponseError = _prepared_context.InvalidPreparedContextResponse @@ -166,8 +167,8 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: # noqa: C901 ) _write_hook_output(diagnostic_events=diagnostic_events) return 0 - bootstrap_receipt_id = BOOTSTRAP_STATE.load_receipt(session_id, scope_id) - BOOTSTRAP_STATE.clear_receipt(session_id) + bootstrap_receipt_id = CLAUDE_CODE.bootstrap_state.load_receipt(session_id, scope_id) + CLAUDE_CODE.bootstrap_state.clear_receipt(session_id) safe_prompt = scrub_secrets(prompt, settings.authorization) query = _bounded_recall_query(safe_prompt) context = None @@ -501,7 +502,7 @@ def _emit_context_event( if key in emitted_diagnostics: return emitted_diagnostics.add(key) - if not DIAGNOSTICS.should_emit(outcome): + if not CLAUDE_CODE.diagnostics.should_emit(outcome): return event: dict[str, object] = { "component": "powercontext.claude_code.recall", diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py index 365fd06322..d787f43287 100644 --- a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py @@ -24,7 +24,8 @@ that is missing or has drifted. Claude Code runs its hooks with the ``python3`` on ``PATH``, often the macOS system Python 3.9, and Hermes installs no -dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. A -single-module copy cannot reach its siblings, so a module may not import them either. The package sits outside +dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. Only a +module that WorkBuddy and Hermes do not ship may import its siblings, since a single-module copy cannot reach them; it +imports them relatively, because the package copies have another name than the source. The package sits outside ``powercontext`` so that importing it loads nothing from the SDK. """ diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/host_profile.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/host_profile.py new file mode 100644 index 0000000000..1da4c3b7a2 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/host_profile.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/host_profile.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""The values in which one host's lifecycle differs from another's.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .bootstrap_state import BootstrapState +from .diagnostics import DiagnosticThrottle + + +@dataclass(frozen=True) +class HostProfile: + """What the lifecycle flows need to know about one host. + + ``integration`` names the host in Server requests, and ``user_agent`` identifies its plugin. ``component`` begins + the component of each diagnostic the host reports, such as ``powercontext.claude_code``. ``diagnostics`` and + ``bootstrap_state`` locate the host's diagnostic throttle and its bootstrap receipts. + """ + + integration: str + user_agent: str + component: str + diagnostics: DiagnosticThrottle + bootstrap_state: BootstrapState diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py new file mode 100644 index 0000000000..a0c4d590ea --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/scope_binding_errors.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Typed Scope binding failures that the Scope clients raise and the lifecycle flows classify.""" + +from __future__ import annotations + + +class ScopeBindingError(RuntimeError): + """Raised when the integration cannot establish one current Scope.""" + + +class ScopeBindingUnavailableError(ScopeBindingError): + """Transport failure, timeout, exhausted budget, or HTTP 503, whose status it keeps.""" + + def __init__(self, status: int | None = None) -> None: + self.status = status + super().__init__("PowerContext is unavailable" if status is None else f"PowerContext returned HTTP {status}") + + +class ScopeBindingRejectedError(ScopeBindingError): + """The Server rejected the credential.""" + + +class ScopeBindingStatusError(ScopeBindingError): + """A non-successful HTTP status outside the fixed classifications, with the error code the Server sent.""" + + def __init__(self, status: int, path: str, code: str | None = None) -> None: + self.status = status + self.path = path + self.code = code + super().__init__(f"PowerContext returned HTTP {status}") diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/session_start.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/session_start.py new file mode 100644 index 0000000000..307ecf6e80 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/session_start.py @@ -0,0 +1,381 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/session_start.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Inject one bounded PowerContext bootstrap package when a host session starts.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Callable, Mapping +from contextlib import suppress +from dataclasses import dataclass, field +from http.client import HTTPException +from time import monotonic +from typing import Protocol, cast +from urllib.error import HTTPError +from urllib.request import Request + +from .bootstrap_context import InvalidBootstrapResponse, validate_bootstrap_context, validate_delivery_receipt +from .host_profile import HostProfile +from .scope_binding_errors import ( + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, +) +from .transport import bind_response_deadline, complete_body, open_bounded + +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" + + +class _ReadableResponse(Protocol): + status: int + + def read(self, amount: int = -1) -> bytes: ... + + def __enter__(self) -> _ReadableResponse: ... + + def __exit__(self, *args: object) -> object: ... + + +class _HttpStatusError(RuntimeError): + def __init__(self, status: int, path: str, code: str | None = None) -> None: + self.status = status + self.path = path + self.code = code + super().__init__(f"PowerContext returned HTTP {status}") + + +class _ErrorCodeReader(Protocol): + def __call__(self, error: HTTPError, *, deadline: float) -> str | None: ... + + +@dataclass(frozen=True) +class SessionStartEvent: + """One SessionStart event, translated from the host's payload.""" + + session_id: str + cwd: str + lifecycle: str + event_id: str | None + + +@dataclass(frozen=True) +class BootstrapConnection: + """The host settings that the bootstrap requests use, as plain values. + + ``resolve_scope_id`` resolves the event's Scope through the host's Scope client, within the deadline it is given. + ``read_error_code`` reads the error code of a failed bootstrap request through the same client, which fails the + request as unavailable when that body stalls or breaks off, and as invalid when it exceeds the size bound. + """ + + server_url: str + authorization: str | None = field(repr=False) + request_timeout_seconds: float + http_budget_seconds: float + enabled: bool + max_bytes: int + handoff: Mapping[str, object] | None + resolve_scope_id: Callable[[float], str] + read_error_code: _ErrorCodeReader + + +def hook_event_id(payload: Mapping[str, object]) -> str | None: + """Return the identifier that a hook payload gives its event, if it gives a usable one.""" + + for name in ("event_id", "hook_id", "invocation_id", "request_id"): + value = payload.get(name) + if isinstance(value, str) and value.strip() and len(value.strip()) <= 512: + return value.strip() + return None + + +def run_session_start( + host: HostProfile, + event: SessionStartEvent, + *, + connect: Callable[[], BootstrapConnection], + emit: Callable[[str], None], +) -> None: + """Hand the event's bootstrap package to ``emit`` once the Server has recorded its delivery. + + ``connect`` loads the host's settings after the session's previous receipt has been read; if it fails, that + receipt is cleared and nothing is reported. ``emit`` writes the package in the host's native form and raises if it + cannot. + + The flow reports the failures it recognizes as diagnostics on stderr and does not record them in the host's + diagnostic throttle, which applies to the prompt hooks' ``systemMessage``. It recognizes Scope binding errors; + transport errors and timeouts, including a response that stalls, breaks off, or ends before its Content-Length, + which ``http.client`` may raise as ``HTTPException``; HTTP error statuses, with the error code the Server sent; + responses that exceed the size bound or cannot be decoded; and, once the package has arrived, any failure to record + its delivery or to emit it. Anything else propagates, so the host's entry point must still catch it. + """ + + previous_receipt = host.bootstrap_state.load_receipt_state(event.session_id) + try: + connection = connect() + except Exception: + _clear_observed_receipt(host, event.session_id, previous_receipt) + return + deadline = monotonic() + connection.http_budget_seconds + prepared = _resolve_and_prepare(host, event, connection, deadline=deadline) + if prepared is None: + _clear_observed_receipt(host, event.session_id, previous_receipt) + return + scope_id, response = prepared + if response["status"] != "ready": + receipt = _synchronize_non_ready_receipt(host, event.session_id, scope_id, response, previous_receipt) + if response["status"] == "empty": + _emit_bootstrap_event( + host, + "empty", + http_status=200, + context_status="empty", + content_bytes=0, + truncated=cast(bool, response["truncated"]), + receipt_state=receipt["state"], + ) + return + receipt = cast(dict[str, str], response["receipt"]) + receipt_id = receipt["receipt_id"] + try: + delivered = validate_delivery_receipt( + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "injected"}, + host=host, + connection=connection, + deadline=deadline, + ), + receipt_id=receipt_id, + ) + _require_injected(delivered) + except Exception: + _clear_stale_receipt_after_failed_claim(host, event.session_id, scope_id, receipt_id, previous_receipt) + _emit_bootstrap_event(host, "delivery_failed") + with suppress(Exception): + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "failed"}, + host=host, + connection=connection, + deadline=deadline, + ) + return + try: + host.bootstrap_state.save_receipt(event.session_id, scope_id, receipt_id) + emit(cast(str, response["content"])) + except Exception: + host.bootstrap_state.clear_receipt_if_matches(event.session_id, scope_id, receipt_id) + _emit_bootstrap_event(host, "delivery_failed") + + +def _resolve_and_prepare( + host: HostProfile, + event: SessionStartEvent, + connection: BootstrapConnection, + *, + deadline: float, +) -> tuple[str, dict[str, object]] | None: + try: + scope_id = connection.resolve_scope_id(deadline) + request: dict[str, object] = { + "scope_id": scope_id, + "enabled": connection.enabled, + "profile": _BOOTSTRAP_PROFILE, + "lifecycle": event.lifecycle, + "integration": host.integration, + "max_bytes": connection.max_bytes, + } + if connection.handoff is not None: + request["handoff"] = connection.handoff + if event.event_id is not None: + request["event_id"] = event.event_id + response = validate_bootstrap_context( + _post_json("/v1/context/bootstrap", request, host=host, connection=connection, deadline=deadline), + max_bytes=connection.max_bytes, + ) + except ScopeBindingRejectedError: + _emit_bootstrap_event(host, "authentication_failed") + except (ScopeBindingUnavailableError, OSError, TimeoutError, HTTPException): + # http.client reports a truncated or malformed response as HTTPException, not OSError. + _emit_bootstrap_event(host, "server_unavailable", recovery="powercontext doctor") + except (ScopeBindingStatusError, _HttpStatusError) as error: + _emit_http_failure(host, error.status, error.code) + except (ScopeBindingError, InvalidBootstrapResponse): + _emit_bootstrap_event(host, "invalid_response") + else: + return scope_id, response + return None + + +def _post_json( + path: str, + payload: Mapping[str, object], + *, + host: HostProfile, + connection: BootstrapConnection, + deadline: float, +) -> dict[str, object]: + headers = {"Accept": "application/json", "Content-Type": "application/json", "User-Agent": host.user_agent} + if connection.authorization is not None: + headers["Authorization"] = connection.authorization + request = Request( # noqa: S310 - the host's settings validate the configured transport. + f"{connection.server_url}{path}", + data=json.dumps(payload, separators=(",", ":")).encode(), + headers=headers, + method="POST", + ) + timeout = min(connection.request_timeout_seconds, _remaining_time(deadline)) + # One deadline covers the headers and the body, so a slow header phase leaves the body only what is left. + request_deadline = min(deadline, monotonic() + timeout) + try: + with open_bounded(request, timeout=timeout) as response: + if response.status != 200: + raise _HttpStatusError(response.status, path) + raw = _read_response(response, deadline=request_deadline) + except HTTPError as error: + code = connection.read_error_code(error, deadline=request_deadline) + raise _HttpStatusError(error.code, path, code) from error + try: + value = json.loads(raw) + except (ValueError, RecursionError) as error: + # Besides JSONDecodeError, json raises ValueError for an over-long integer and RecursionError for deep nesting. + raise InvalidBootstrapResponse from error + if not isinstance(value, dict): + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _read_response(response: _ReadableResponse, *, deadline: float) -> bytes: + bind_response_deadline(response, deadline) + content = bytearray() + while True: + remaining = _MAX_RESPONSE_BYTES + 1 - len(content) + chunk = response.read(min(_READ_CHUNK_BYTES, remaining)) + if not chunk: + return complete_body(response, bytes(content)) + content.extend(chunk) + if len(content) > _MAX_RESPONSE_BYTES: + raise InvalidBootstrapResponse + + +def _remaining_time(deadline: float) -> float: + remaining = deadline - monotonic() + if remaining <= 0: + raise TimeoutError + return remaining + + +def _require_injected(receipt: Mapping[str, str]) -> None: + if receipt.get("state") != "injected": + raise InvalidBootstrapResponse + + +def _clear_stale_receipt_after_failed_claim( + host: HostProfile, + session_id: str, + scope_id: str, + receipt_id: str, + previous_receipt: tuple[str, str] | None, +) -> None: + if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): + host.bootstrap_state.clear_receipt_if_matches(session_id, *previous_receipt) + + +def _clear_observed_receipt(host: HostProfile, session_id: str, previous_receipt: tuple[str, str] | None) -> None: + if previous_receipt is not None: + host.bootstrap_state.clear_receipt_if_matches(session_id, *previous_receipt) + + +def _synchronize_non_ready_receipt( + host: HostProfile, + session_id: str, + scope_id: str, + response: Mapping[str, object], + previous_receipt: tuple[str, str] | None, +) -> dict[str, str]: + """Retain a receipt only when this exact lifecycle event was already injected.""" + + receipt = cast(dict[str, str], response["receipt"]) + if ( + response.get("status") == "skipped" + and response.get("reason") == "already_delivered" + and receipt["state"] == "injected" + and previous_receipt == (scope_id, receipt["receipt_id"]) + ): + return receipt + _clear_observed_receipt(host, session_id, previous_receipt) + return receipt + + +def _emit_http_failure(host: HostProfile, status: int, code: str | None = None) -> None: + if status == 401: + outcome = "authentication_failed" + elif status == 403: + outcome = "authorization_denied" + elif status == 404 and code is None: + outcome = "version_mismatch" + elif status == 503: + outcome = "server_unavailable" + else: + outcome = "invalid_response" + _emit_bootstrap_event( + host, + outcome, + http_status=status, + error_code=code, + recovery="powercontext doctor" if outcome == "server_unavailable" else None, + ) + + +def _emit_bootstrap_event( + host: HostProfile, + outcome: str, + *, + http_status: int | None = None, + error_code: str | None = None, + context_status: str | None = None, + content_bytes: int | None = None, + truncated: bool | None = None, + receipt_state: str | None = None, + recovery: str | None = None, +) -> None: + event: dict[str, object] = { + "component": f"{host.component}.bootstrap", + "event": "context_bootstrap", + "outcome": outcome, + "profile": _BOOTSTRAP_PROFILE, + } + if http_status is not None: + event["http_status"] = http_status + if error_code is not None: + event["error_code"] = error_code + if context_status is not None: + event["context_status"] = context_status + if content_bytes is not None: + event["content_bytes"] = content_bytes + if truncated is not None: + event["truncated"] = truncated + if receipt_state is not None: + event["receipt_state"] = receipt_state + if recovery is not None: + event["recovery"] = recovery + sys.stderr.write(json.dumps(event, separators=(",", ":")) + "\n") diff --git a/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/transport.py b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/transport.py new file mode 100644 index 0000000000..57411618cc --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/transport.py @@ -0,0 +1,135 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/transport.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Bounded HTTP for plugin code that the host runs under a time budget.""" + +from __future__ import annotations + +import socket +import threading +from http.client import IncompleteRead +from time import monotonic +from typing import Any +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +class _RejectRedirects(HTTPRedirectHandler): + # typing.override needs Python 3.12, and these modules may import only the standard library. + def redirect_request( # ty: ignore[missing-override-decorator] + self, + req: Request, + fp: object, + code: int, + msg: str, + headers: object, + newurl: str, + ) -> Request | None: + return None + + +_URL_OPENER = build_opener(_RejectRedirects) + + +def open_bounded(request: Request, *, timeout: float) -> Any: + """Open one request under a hard wall-clock bound, response headers included. + + urllib applies its timeout to each individual socket read, so a server that + trickles headers can outlive the caller's deadline. Running the open in a + daemon worker and abandoning it on expiry keeps the caller inside its budget. + """ + + outcome: list[Any] = [] + + def _open() -> None: + try: + outcome.append(_URL_OPENER.open(request, timeout=timeout)) + except BaseException as error: + outcome.append(error) + + worker = threading.Thread(target=_open, name="powercontext-http", daemon=True) + worker.start() + worker.join(timeout) + if worker.is_alive(): + raise TimeoutError + result = outcome[0] if outcome else TimeoutError() + if isinstance(result, BaseException): + raise result + return result + + +class _DeadlineSocket: + """Enforce one absolute deadline on every response socket read. + + ``http.client`` can consume many socket reads inside a single ``read`` call + while it parses chunk framing, so tightening the socket timeout once per + bounded read cannot stop a server that trickles chunk extensions. Recomputing + the timeout before every receive keeps the caller's absolute deadline, + framing included. + """ + + def __init__(self, sock: Any, deadline: float) -> None: + self._sock = sock + self._deadline = deadline + + def _remaining_time(self) -> float: + remaining = self._deadline - monotonic() + if remaining <= 0: + # Raise what the socket itself raises: on Python 3.9 socket.timeout is not TimeoutError, and SocketIO + # fails with IndexError on any other OSError that carries no errno. + raise socket.timeout + return remaining + + def recv(self, *args: Any) -> Any: + self._sock.settimeout(self._remaining_time()) + return self._sock.recv(*args) + + def recv_into(self, *args: Any) -> Any: + self._sock.settimeout(self._remaining_time()) + return self._sock.recv_into(*args) + + def __getattr__(self, name: str) -> Any: + return getattr(self._sock, name) + + +def bind_response_deadline(response: object, deadline: float) -> None: + """Keep every socket read of one open response inside the absolute deadline.""" + + if isinstance(response, HTTPError): + response = response.fp + raw: Any = getattr(getattr(response, "fp", None), "raw", None) + if raw is None: + return + sock = getattr(raw, "_sock", None) + if sock is None or isinstance(sock, _DeadlineSocket) or not hasattr(sock, "recv_into"): + return + raw._sock = _DeadlineSocket(sock, deadline) + + +def complete_body(response: object, body: bytes) -> bytes: + """Return a body read to its end, failing one that ended before its announced Content-Length. + + ``http.client`` reports a truncated chunked body as ``IncompleteRead`` but returns a Content-Length body that the + connection cut short as if it were complete, leaving the missing byte count in ``length``. + """ + + if isinstance(response, HTTPError): + response = response.fp + missing = getattr(response, "length", None) + if missing: + raise IncompleteRead(body, missing) + return body diff --git a/integrations/claude-code/plugins/powercontext/scripts/statusline.py b/integrations/claude-code/plugins/powercontext/scripts/statusline.py index 3b6541a61d..9e514481dd 100644 --- a/integrations/claude-code/plugins/powercontext/scripts/statusline.py +++ b/integrations/claude-code/plugins/powercontext/scripts/statusline.py @@ -35,12 +35,13 @@ sys.path.insert(0, str(_SCRIPTS_ROOT)) from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 -from scope_binding_errors import ( # noqa: E402 +from powercontext_lifecycle.scope_binding_errors import ( # noqa: E402 ScopeBindingRejectedError, ScopeBindingStatusError, ScopeBindingUnavailableError, ) -from workspace_scope import bind_response_deadline, complete_body, open_bounded, resolve_scope_id # noqa: E402 +from powercontext_lifecycle.transport import bind_response_deadline, complete_body, open_bounded # noqa: E402 +from workspace_scope import resolve_scope_id # noqa: E402 _MAX_RESPONSE_BYTES = 1024 * 1024 _USER_AGENT = "powercontext-claude-code-statusline/0.1.0" diff --git a/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py index b119cd5194..6053184553 100644 --- a/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py +++ b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py @@ -20,39 +20,29 @@ import argparse import json import os -import socket import subprocess import sys -import threading from collections.abc import Mapping, Sequence from hashlib import sha256 -from http.client import HTTPException, IncompleteRead +from http.client import HTTPException from pathlib import Path from shutil import which from time import monotonic -from typing import TYPE_CHECKING, Any, Protocol, TypeVar +from typing import Any, Protocol from urllib.error import HTTPError -from urllib.request import HTTPRedirectHandler, Request, build_opener - -if TYPE_CHECKING: - from typing_extensions import override -else: - _MethodT = TypeVar("_MethodT") - - def override(method: _MethodT, /) -> _MethodT: - return method - +from urllib.request import Request _PLUGIN_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_PLUGIN_ROOT)) from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 -from scope_binding_errors import ( # noqa: E402 +from powercontext_lifecycle.scope_binding_errors import ( # noqa: E402 ScopeBindingError, ScopeBindingRejectedError, ScopeBindingStatusError, ScopeBindingUnavailableError, ) +from powercontext_lifecycle.transport import bind_response_deadline, complete_body, open_bounded # noqa: E402 _MAX_RESPONSE_BYTES = 1_048_576 _REQUEST_HEADERS = { @@ -75,51 +65,6 @@ class _Response(Protocol): def read(self, amount: int = -1, /) -> bytes: ... -class _RejectRedirects(HTTPRedirectHandler): - @override - def redirect_request( - self, - req: Request, - fp: object, - code: int, - msg: str, - headers: object, - newurl: str, - ) -> Request | None: - return None - - -_URL_OPENER = build_opener(_RejectRedirects) - - -def open_bounded(request: Request, *, timeout: float) -> Any: - """Open one request under a hard wall-clock bound, response headers included. - - urllib applies its timeout to each individual socket read, so a server that - trickles headers can outlive the caller's deadline. Running the open in a - daemon worker and abandoning it on expiry keeps the status line inside its - budget. - """ - - outcome: list[Any] = [] - - def _open() -> None: - try: - outcome.append(_URL_OPENER.open(request, timeout=timeout)) - except BaseException as error: - outcome.append(error) - - worker = threading.Thread(target=_open, name="powercontext-http", daemon=True) - worker.start() - worker.join(timeout) - if worker.is_alive(): - raise TimeoutError - result = outcome[0] if outcome else TimeoutError() - if isinstance(result, BaseException): - raise result - return result - - def resolve_scope_id( cwd: str, *, @@ -280,69 +225,6 @@ def _remaining_time(deadline: float) -> float: return remaining -class _DeadlineSocket: - """Enforce one absolute deadline on every response socket read. - - ``http.client`` can consume many socket reads inside a single ``read`` call - while it parses chunk framing, so tightening the socket timeout once per - bounded read cannot stop a server that trickles chunk extensions. Recomputing - the timeout before every receive keeps the caller's absolute deadline, - framing included. - """ - - def __init__(self, sock: Any, deadline: float) -> None: - self._sock = sock - self._deadline = deadline - - def _remaining_time(self) -> float: - remaining = self._deadline - monotonic() - if remaining <= 0: - # Raise what the socket itself raises: on Python 3.9 socket.timeout is not TimeoutError, and SocketIO - # fails with IndexError on any other OSError that carries no errno. - raise socket.timeout - return remaining - - def recv(self, *args: Any) -> Any: - self._sock.settimeout(self._remaining_time()) - return self._sock.recv(*args) - - def recv_into(self, *args: Any) -> Any: - self._sock.settimeout(self._remaining_time()) - return self._sock.recv_into(*args) - - def __getattr__(self, name: str) -> Any: - return getattr(self._sock, name) - - -def bind_response_deadline(response: object, deadline: float) -> None: - """Keep every socket read of one open response inside the absolute deadline.""" - - if isinstance(response, HTTPError): - response = response.fp - raw: Any = getattr(getattr(response, "fp", None), "raw", None) - if raw is None: - return - sock = getattr(raw, "_sock", None) - if sock is None or isinstance(sock, _DeadlineSocket) or not hasattr(sock, "recv_into"): - return - raw._sock = _DeadlineSocket(sock, deadline) - - -def complete_body(response: object, body: bytes) -> bytes: - """Return a body read to its end, failing one that ended before its announced Content-Length. - - ``http.client`` reports a truncated chunked body as ``IncompleteRead`` but returns a Content-Length body that the - connection cut short as if it were complete, leaving the missing byte count in ``length``. - """ - - if isinstance(response, HTTPError): - response = response.fp - missing = getattr(response, "length", None) - if missing: - raise IncompleteRead(body, missing) - return body - - def _set_response_timeout(response: object, timeout: float) -> None: """Tighten urllib's socket timeout before each bounded read.""" diff --git a/integrations/codex/plugins/powercontext/codex_host.py b/integrations/codex/plugins/powercontext/codex_host.py index dd15be1e9f..3ea608bcb2 100644 --- a/integrations/codex/plugins/powercontext/codex_host.py +++ b/integrations/codex/plugins/powercontext/codex_host.py @@ -14,11 +14,18 @@ """What the shared lifecycle modules need to know about Codex.""" +from plugin_version import PLUGIN_VERSION from powercontext_lifecycle.bootstrap_state import BootstrapState from powercontext_lifecycle.diagnostics import DiagnosticThrottle +from powercontext_lifecycle.host_profile import HostProfile -BOOTSTRAP_STATE = BootstrapState( - directory_variable="POWERCONTEXT_BOOTSTRAP_STATE_DIR", - plugin_data_variable="PLUGIN_DATA", +CODEX = HostProfile( + integration="codex", + user_agent=f"powercontext-codex-plugin/{PLUGIN_VERSION}", + component="powercontext.codex", + diagnostics=DiagnosticThrottle(state_file="codex-diagnostics.json"), + bootstrap_state=BootstrapState( + directory_variable="POWERCONTEXT_BOOTSTRAP_STATE_DIR", + plugin_data_variable="PLUGIN_DATA", + ), ) -DIAGNOSTICS = DiagnosticThrottle(state_file="codex-diagnostics.json") diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index cc3674c719..362fd2ae7d 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -28,14 +28,12 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from scope_binding import ( # noqa: E402 +from powercontext_lifecycle.scope_binding_errors import ( # noqa: E402 ScopeBindingError, ScopeBindingRejectedError, ScopeBindingStatusError, - binding_keys, - resolve_scope_id, - session_binding_key, ) +from scope_binding import binding_keys, resolve_scope_id, session_binding_key # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _PREFIX = "mcp__powercontext__" diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index fbc2abcc16..296a789420 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -36,19 +36,17 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from codex_host import BOOTSTRAP_STATE, DIAGNOSTICS # noqa: E402 +from codex_host import CODEX # noqa: E402 from plugin_version import PLUGIN_VERSION # noqa: E402 from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 from powercontext_lifecycle.prompt_privacy import scrub_secrets # noqa: E402 -from scope_binding import ( # noqa: E402 +from powercontext_lifecycle.scope_binding_errors import ( # noqa: E402 ScopeBindingRejectedError, ScopeBindingStatusError, ScopeBindingUnavailableError, - bind_response_deadline, - complete_body, - open_bounded, - resolve_scope_id, ) +from powercontext_lifecycle.transport import bind_response_deadline, complete_body, open_bounded # noqa: E402 +from scope_binding import resolve_scope_id # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _MAX_CONTEXT_BYTES = _prepared_context.MAX_CONTEXT_BYTES @@ -173,8 +171,8 @@ def main(settings: CodexPluginSettings | None = None) -> int: ) _write_hook_output(diagnostic_events=diagnostic_events) return 0 - bootstrap_receipt_id = BOOTSTRAP_STATE.load_receipt(session_id, scope_id) - BOOTSTRAP_STATE.clear_receipt(session_id) + bootstrap_receipt_id = CODEX.bootstrap_state.load_receipt(session_id, scope_id) + CODEX.bootstrap_state.clear_receipt(session_id) authorization = settings.authorization.get_secret_value() if settings.authorization is not None else None safe_prompt = scrub_secrets(prompt, authorization) query = _bounded_recall_query(safe_prompt) @@ -582,7 +580,7 @@ def _emit_context_event( if key in emitted_diagnostics: return emitted_diagnostics.add(key) - if not DIAGNOSTICS.should_emit(outcome): + if not CODEX.diagnostics.should_emit(outcome): return event: dict[str, object] = { "component": "powercontext.codex.recall", diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index bbd00661c3..6c2bd4448b 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -20,67 +20,26 @@ import json import os import sys -from collections.abc import Mapping -from contextlib import suppress -from http.client import HTTPException from pathlib import Path from time import monotonic -from typing import Any, Protocol, cast -from urllib.error import HTTPError -from urllib.request import Request +from typing import Any, cast _PLUGIN_ROOT = Path(__file__).resolve().parents[1] _SCRIPTS_ROOT = _PLUGIN_ROOT / "scripts" sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from codex_host import BOOTSTRAP_STATE # noqa: E402 -from plugin_version import PLUGIN_VERSION # noqa: E402 -from powercontext_lifecycle.bootstrap_context import ( # noqa: E402 - InvalidBootstrapResponse, - validate_bootstrap_context, - validate_delivery_receipt, -) -from scope_binding import ( # noqa: E402 - ScopeBindingError, - ScopeBindingRejectedError, - ScopeBindingStatusError, - ScopeBindingUnavailableError, - bind_response_deadline, - complete_body, - open_bounded, - read_error_code, - resolve_scope_id, +from codex_host import CODEX # noqa: E402 +from powercontext_lifecycle.session_start import ( # noqa: E402 + BootstrapConnection, + SessionStartEvent, + hook_event_id, + run_session_start, ) +from scope_binding import read_error_code, resolve_scope_id # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _LIFECYCLES = frozenset({"startup", "resume", "clear", "compact", "restore", "fork"}) -_MAX_RESPONSE_BYTES = 1_048_576 -_READ_CHUNK_BYTES = 65_536 -_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" -_REQUEST_HEADERS = { - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": f"powercontext-codex-plugin/{PLUGIN_VERSION}", -} - - -class _ReadableResponse(Protocol): - status: int - - def read(self, amount: int = -1) -> bytes: ... - - def __enter__(self) -> _ReadableResponse: ... - - def __exit__(self, *args: object) -> object: ... - - -class _HttpStatusError(RuntimeError): - def __init__(self, status: int, path: str, code: str | None = None) -> None: - self.status = status - self.path = path - self.code = code - super().__init__(f"PowerContext returned HTTP {status}") def main(settings: CodexPluginSettings | None = None) -> int: @@ -101,212 +60,50 @@ def main(settings: CodexPluginSettings | None = None) -> int: persist_session=True, ) return 0 - previous_receipt = BOOTSTRAP_STATE.load_receipt_state(session_id) - try: - settings = CodexPluginSettings() if settings is None else settings - except Exception: - _clear_observed_receipt(session_id, previous_receipt) - return 0 - deadline = monotonic() + settings.http_budget_seconds - prepared = _resolve_and_prepare(cwd, session_id, source, payload, settings=settings, deadline=deadline) - if prepared is None: - _clear_observed_receipt(session_id, previous_receipt) - return 0 - scope_id, response = prepared - if response["status"] != "ready": - receipt = _synchronize_non_ready_receipt(session_id, scope_id, response, previous_receipt) - if response["status"] == "empty": - _emit_bootstrap_event( - "empty", - http_status=200, - context_status="empty", - content_bytes=0, - truncated=cast(bool, response["truncated"]), - receipt_state=receipt["state"], - ) - return 0 - receipt = cast(dict[str, str], response["receipt"]) - receipt_id = receipt["receipt_id"] - try: - delivered = validate_delivery_receipt( - _post_json( - "/v1/context/bootstrap/receipts", - {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "injected"}, - settings=settings, - deadline=deadline, - ), - receipt_id=receipt_id, - ) - _require_injected(delivered) - except Exception: - _clear_stale_receipt_after_failed_claim(session_id, scope_id, receipt_id, previous_receipt) - _emit_bootstrap_event("delivery_failed") - with suppress(Exception): - _post_json( - "/v1/context/bootstrap/receipts", - {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "failed"}, - settings=settings, - deadline=deadline, - ) - return 0 - try: - BOOTSTRAP_STATE.save_receipt(session_id, scope_id, receipt_id) - json.dump( - { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": response["content"], - } - }, - sys.stdout, - separators=(",", ":"), - ) - sys.stdout.write("\n") - sys.stdout.flush() - except Exception as error: - _handle_output_failure(session_id, scope_id, receipt_id, error) - return 0 + event = SessionStartEvent(session_id=session_id, cwd=cwd, lifecycle=source, event_id=hook_event_id(payload)) + run_session_start(CODEX, event, connect=lambda: _connect(event, settings), emit=_emit_context) except Exception: return 0 return 0 -def _resolve_and_prepare( - cwd: str, - session_id: str, - source: str, - payload: Mapping[str, object], - *, - settings: CodexPluginSettings, - deadline: float, -) -> tuple[str, dict[str, object]] | None: - try: - scope_id = resolve_scope_id( - cwd, - session_id=session_id, - settings=settings, +def _connect(event: SessionStartEvent, settings: CodexPluginSettings | None) -> BootstrapConnection: + loaded = CodexPluginSettings() if settings is None else settings + return BootstrapConnection( + server_url=loaded.server_url, + authorization=None if loaded.authorization is None else loaded.authorization.get_secret_value(), + request_timeout_seconds=loaded.request_timeout_seconds, + http_budget_seconds=loaded.http_budget_seconds, + enabled=loaded.bootstrap_context, + max_bytes=loaded.bootstrap_max_bytes, + handoff=None if loaded.bootstrap_handoff is None else loaded.bootstrap_handoff.model_dump(), + resolve_scope_id=lambda deadline: resolve_scope_id( + event.cwd, + session_id=event.session_id, + settings=loaded, deadline=deadline, persist_session=True, - ) - request: dict[str, object] = { - "scope_id": scope_id, - "enabled": settings.bootstrap_context, - "profile": _BOOTSTRAP_PROFILE, - "lifecycle": source, - "integration": "codex", - "max_bytes": settings.bootstrap_max_bytes, - } - if settings.bootstrap_handoff is not None: - request["handoff"] = settings.bootstrap_handoff.model_dump() - event_id = _identifier(payload, "event_id", "hook_id", "invocation_id", "request_id") - if event_id is not None: - request["event_id"] = event_id - response = validate_bootstrap_context( - _post_json("/v1/context/bootstrap", request, settings=settings, deadline=deadline), - max_bytes=settings.bootstrap_max_bytes, - ) - except ScopeBindingRejectedError: - _emit_bootstrap_event("authentication_failed") - except (ScopeBindingUnavailableError, OSError, TimeoutError, HTTPException): - # http.client reports a truncated or malformed response as HTTPException, not OSError. - _emit_bootstrap_event("server_unavailable", recovery="powercontext doctor") - except (ScopeBindingStatusError, _HttpStatusError) as error: - _emit_http_failure(error.status, error.code) - except (ScopeBindingError, InvalidBootstrapResponse): - _emit_bootstrap_event("invalid_response") - else: - return scope_id, response - return None - - -def _identifier(payload: Mapping[str, object], *names: str) -> str | None: - for name in names: - value = payload.get(name) - if isinstance(value, str) and value.strip() and len(value.strip()) <= 512: - return value.strip() - return None - - -def _post_json( - path: str, - payload: Mapping[str, object], - *, - settings: CodexPluginSettings, - deadline: float, -) -> dict[str, object]: - headers = dict(_REQUEST_HEADERS) - if settings.authorization is not None: - headers["Authorization"] = settings.authorization.get_secret_value() - request = Request( # noqa: S310 - settings validates the configured transport. - f"{settings.server_url}{path}", - data=json.dumps(payload, separators=(",", ":")).encode(), - headers=headers, - method="POST", + ), + read_error_code=read_error_code, ) - timeout = min(settings.request_timeout_seconds, _remaining_time(deadline)) - # One deadline covers the headers and the body, so a slow header phase leaves the body only what is left. - request_deadline = min(deadline, monotonic() + timeout) - try: - with open_bounded(request, timeout=timeout) as response: - if response.status != 200: - raise _HttpStatusError(response.status, path) - raw = _read_response(response, deadline=request_deadline) - except HTTPError as error: - code = read_error_code(error, deadline=request_deadline) - raise _HttpStatusError(error.code, path, code) from error - try: - value = json.loads(raw) - except (ValueError, RecursionError) as error: - # Besides JSONDecodeError, json raises ValueError for an over-long integer and RecursionError for deep nesting. - raise InvalidBootstrapResponse from error - if not isinstance(value, dict): - raise InvalidBootstrapResponse - return cast(dict[str, object], value) - - -def _read_response(response: _ReadableResponse, *, deadline: float) -> bytes: - bind_response_deadline(response, deadline) - content = bytearray() - while True: - remaining = _MAX_RESPONSE_BYTES + 1 - len(content) - chunk = response.read(min(_READ_CHUNK_BYTES, remaining)) - if not chunk: - return complete_body(response, bytes(content)) - content.extend(chunk) - if len(content) > _MAX_RESPONSE_BYTES: - raise InvalidBootstrapResponse - - -def _remaining_time(deadline: float) -> float: - remaining = deadline - monotonic() - if remaining <= 0: - raise TimeoutError - return remaining - - -def _require_injected(receipt: Mapping[str, str]) -> None: - if receipt.get("state") != "injected": - raise InvalidBootstrapResponse - - -def _clear_stale_receipt_after_failed_claim( - session_id: str, - scope_id: str, - receipt_id: str, - previous_receipt: tuple[str, str] | None, -) -> None: - if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): - BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) -def _clear_observed_receipt(session_id: str, previous_receipt: tuple[str, str] | None) -> None: - if previous_receipt is not None: - BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, *previous_receipt) - - -def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, error: Exception) -> None: - BOOTSTRAP_STATE.clear_receipt_if_matches(session_id, scope_id, receipt_id) - if isinstance(error, BrokenPipeError): +def _emit_context(content: str) -> None: + try: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": content, + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + sys.stdout.flush() + except BrokenPipeError: + # Python flushes stdout again at exit; pointing it at the null device keeps that flush quiet. try: stdout = sys.stdout.fileno() descriptor = os.open(os.devnull, os.O_WRONLY) @@ -316,80 +113,7 @@ def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, erro os.close(descriptor) except (AttributeError, OSError, ValueError): pass - _emit_bootstrap_event("delivery_failed") - - -def _synchronize_non_ready_receipt( - session_id: str, - scope_id: str, - response: Mapping[str, object], - previous_receipt: tuple[str, str] | None, -) -> dict[str, str]: - """Retain a receipt only when this exact lifecycle event was already injected.""" - - receipt = cast(dict[str, str], response["receipt"]) - if ( - response.get("status") == "skipped" - and response.get("reason") == "already_delivered" - and receipt["state"] == "injected" - and previous_receipt == (scope_id, receipt["receipt_id"]) - ): - return receipt - _clear_observed_receipt(session_id, previous_receipt) - return receipt - - -def _emit_http_failure(status: int, code: str | None = None) -> None: - if status == 401: - outcome = "authentication_failed" - elif status == 403: - outcome = "authorization_denied" - elif status == 404 and code is None: - outcome = "version_mismatch" - elif status == 503: - outcome = "server_unavailable" - else: - outcome = "invalid_response" - _emit_bootstrap_event( - outcome, - http_status=status, - error_code=code, - recovery="powercontext doctor" if outcome == "server_unavailable" else None, - ) - - -def _emit_bootstrap_event( - outcome: str, - *, - http_status: int | None = None, - error_code: str | None = None, - context_status: str | None = None, - content_bytes: int | None = None, - truncated: bool | None = None, - receipt_state: str | None = None, - recovery: str | None = None, -) -> None: - event: dict[str, object] = { - "component": "powercontext.codex.bootstrap", - "event": "context_bootstrap", - "outcome": outcome, - "profile": _BOOTSTRAP_PROFILE, - } - if http_status is not None: - event["http_status"] = http_status - if error_code is not None: - event["error_code"] = error_code - if context_status is not None: - event["context_status"] = context_status - if content_bytes is not None: - event["content_bytes"] = content_bytes - if truncated is not None: - event["truncated"] = truncated - if receipt_state is not None: - event["receipt_state"] = receipt_state - if recovery is not None: - event["recovery"] = recovery - sys.stderr.write(json.dumps(event, separators=(",", ":")) + "\n") + raise if __name__ == "__main__": diff --git a/integrations/codex/plugins/powercontext/hooks/token_savings.py b/integrations/codex/plugins/powercontext/hooks/token_savings.py index d62e7ad822..ef66c43fa2 100644 --- a/integrations/codex/plugins/powercontext/hooks/token_savings.py +++ b/integrations/codex/plugins/powercontext/hooks/token_savings.py @@ -32,7 +32,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from codex_host import DIAGNOSTICS # noqa: E402 +from codex_host import CODEX # noqa: E402 from hooks.recall import ( # noqa: E402 _decode_error_code, _HttpStatusError, @@ -41,13 +41,13 @@ _ServerUnavailableError, ) from plugin_version import PLUGIN_VERSION # noqa: E402 -from scope_binding import ( # noqa: E402 +from powercontext_lifecycle.scope_binding_errors import ( # noqa: E402 ScopeBindingRejectedError, ScopeBindingStatusError, ScopeBindingUnavailableError, - open_bounded, - resolve_scope_id, ) +from powercontext_lifecycle.transport import open_bounded # noqa: E402 +from scope_binding import resolve_scope_id # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _COMPONENT = "powercontext.codex.token_savings" @@ -255,7 +255,7 @@ def _append_event( if outcome in emitted: return emitted.add(outcome) - if not DIAGNOSTICS.should_emit(outcome): + if not CODEX.diagnostics.should_emit(outcome): return event: dict[str, object] = {"component": _COMPONENT, "event": "status", "outcome": outcome} event.update(fields) diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py index 365fd06322..d787f43287 100644 --- a/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py @@ -24,7 +24,8 @@ that is missing or has drifted. Claude Code runs its hooks with the ``python3`` on ``PATH``, often the macOS system Python 3.9, and Hermes installs no -dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. A -single-module copy cannot reach its siblings, so a module may not import them either. The package sits outside +dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. Only a +module that WorkBuddy and Hermes do not ship may import its siblings, since a single-module copy cannot reach them; it +imports them relatively, because the package copies have another name than the source. The package sits outside ``powercontext`` so that importing it loads nothing from the SDK. """ diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/host_profile.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/host_profile.py new file mode 100644 index 0000000000..1da4c3b7a2 --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/host_profile.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/host_profile.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""The values in which one host's lifecycle differs from another's.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .bootstrap_state import BootstrapState +from .diagnostics import DiagnosticThrottle + + +@dataclass(frozen=True) +class HostProfile: + """What the lifecycle flows need to know about one host. + + ``integration`` names the host in Server requests, and ``user_agent`` identifies its plugin. ``component`` begins + the component of each diagnostic the host reports, such as ``powercontext.claude_code``. ``diagnostics`` and + ``bootstrap_state`` locate the host's diagnostic throttle and its bootstrap receipts. + """ + + integration: str + user_agent: str + component: str + diagnostics: DiagnosticThrottle + bootstrap_state: BootstrapState diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py new file mode 100644 index 0000000000..a0c4d590ea --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/scope_binding_errors.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/scope_binding_errors.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Typed Scope binding failures that the Scope clients raise and the lifecycle flows classify.""" + +from __future__ import annotations + + +class ScopeBindingError(RuntimeError): + """Raised when the integration cannot establish one current Scope.""" + + +class ScopeBindingUnavailableError(ScopeBindingError): + """Transport failure, timeout, exhausted budget, or HTTP 503, whose status it keeps.""" + + def __init__(self, status: int | None = None) -> None: + self.status = status + super().__init__("PowerContext is unavailable" if status is None else f"PowerContext returned HTTP {status}") + + +class ScopeBindingRejectedError(ScopeBindingError): + """The Server rejected the credential.""" + + +class ScopeBindingStatusError(ScopeBindingError): + """A non-successful HTTP status outside the fixed classifications, with the error code the Server sent.""" + + def __init__(self, status: int, path: str, code: str | None = None) -> None: + self.status = status + self.path = path + self.code = code + super().__init__(f"PowerContext returned HTTP {status}") diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/session_start.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/session_start.py new file mode 100644 index 0000000000..307ecf6e80 --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/session_start.py @@ -0,0 +1,381 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/session_start.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Inject one bounded PowerContext bootstrap package when a host session starts.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Callable, Mapping +from contextlib import suppress +from dataclasses import dataclass, field +from http.client import HTTPException +from time import monotonic +from typing import Protocol, cast +from urllib.error import HTTPError +from urllib.request import Request + +from .bootstrap_context import InvalidBootstrapResponse, validate_bootstrap_context, validate_delivery_receipt +from .host_profile import HostProfile +from .scope_binding_errors import ( + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, +) +from .transport import bind_response_deadline, complete_body, open_bounded + +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" + + +class _ReadableResponse(Protocol): + status: int + + def read(self, amount: int = -1) -> bytes: ... + + def __enter__(self) -> _ReadableResponse: ... + + def __exit__(self, *args: object) -> object: ... + + +class _HttpStatusError(RuntimeError): + def __init__(self, status: int, path: str, code: str | None = None) -> None: + self.status = status + self.path = path + self.code = code + super().__init__(f"PowerContext returned HTTP {status}") + + +class _ErrorCodeReader(Protocol): + def __call__(self, error: HTTPError, *, deadline: float) -> str | None: ... + + +@dataclass(frozen=True) +class SessionStartEvent: + """One SessionStart event, translated from the host's payload.""" + + session_id: str + cwd: str + lifecycle: str + event_id: str | None + + +@dataclass(frozen=True) +class BootstrapConnection: + """The host settings that the bootstrap requests use, as plain values. + + ``resolve_scope_id`` resolves the event's Scope through the host's Scope client, within the deadline it is given. + ``read_error_code`` reads the error code of a failed bootstrap request through the same client, which fails the + request as unavailable when that body stalls or breaks off, and as invalid when it exceeds the size bound. + """ + + server_url: str + authorization: str | None = field(repr=False) + request_timeout_seconds: float + http_budget_seconds: float + enabled: bool + max_bytes: int + handoff: Mapping[str, object] | None + resolve_scope_id: Callable[[float], str] + read_error_code: _ErrorCodeReader + + +def hook_event_id(payload: Mapping[str, object]) -> str | None: + """Return the identifier that a hook payload gives its event, if it gives a usable one.""" + + for name in ("event_id", "hook_id", "invocation_id", "request_id"): + value = payload.get(name) + if isinstance(value, str) and value.strip() and len(value.strip()) <= 512: + return value.strip() + return None + + +def run_session_start( + host: HostProfile, + event: SessionStartEvent, + *, + connect: Callable[[], BootstrapConnection], + emit: Callable[[str], None], +) -> None: + """Hand the event's bootstrap package to ``emit`` once the Server has recorded its delivery. + + ``connect`` loads the host's settings after the session's previous receipt has been read; if it fails, that + receipt is cleared and nothing is reported. ``emit`` writes the package in the host's native form and raises if it + cannot. + + The flow reports the failures it recognizes as diagnostics on stderr and does not record them in the host's + diagnostic throttle, which applies to the prompt hooks' ``systemMessage``. It recognizes Scope binding errors; + transport errors and timeouts, including a response that stalls, breaks off, or ends before its Content-Length, + which ``http.client`` may raise as ``HTTPException``; HTTP error statuses, with the error code the Server sent; + responses that exceed the size bound or cannot be decoded; and, once the package has arrived, any failure to record + its delivery or to emit it. Anything else propagates, so the host's entry point must still catch it. + """ + + previous_receipt = host.bootstrap_state.load_receipt_state(event.session_id) + try: + connection = connect() + except Exception: + _clear_observed_receipt(host, event.session_id, previous_receipt) + return + deadline = monotonic() + connection.http_budget_seconds + prepared = _resolve_and_prepare(host, event, connection, deadline=deadline) + if prepared is None: + _clear_observed_receipt(host, event.session_id, previous_receipt) + return + scope_id, response = prepared + if response["status"] != "ready": + receipt = _synchronize_non_ready_receipt(host, event.session_id, scope_id, response, previous_receipt) + if response["status"] == "empty": + _emit_bootstrap_event( + host, + "empty", + http_status=200, + context_status="empty", + content_bytes=0, + truncated=cast(bool, response["truncated"]), + receipt_state=receipt["state"], + ) + return + receipt = cast(dict[str, str], response["receipt"]) + receipt_id = receipt["receipt_id"] + try: + delivered = validate_delivery_receipt( + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "injected"}, + host=host, + connection=connection, + deadline=deadline, + ), + receipt_id=receipt_id, + ) + _require_injected(delivered) + except Exception: + _clear_stale_receipt_after_failed_claim(host, event.session_id, scope_id, receipt_id, previous_receipt) + _emit_bootstrap_event(host, "delivery_failed") + with suppress(Exception): + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "failed"}, + host=host, + connection=connection, + deadline=deadline, + ) + return + try: + host.bootstrap_state.save_receipt(event.session_id, scope_id, receipt_id) + emit(cast(str, response["content"])) + except Exception: + host.bootstrap_state.clear_receipt_if_matches(event.session_id, scope_id, receipt_id) + _emit_bootstrap_event(host, "delivery_failed") + + +def _resolve_and_prepare( + host: HostProfile, + event: SessionStartEvent, + connection: BootstrapConnection, + *, + deadline: float, +) -> tuple[str, dict[str, object]] | None: + try: + scope_id = connection.resolve_scope_id(deadline) + request: dict[str, object] = { + "scope_id": scope_id, + "enabled": connection.enabled, + "profile": _BOOTSTRAP_PROFILE, + "lifecycle": event.lifecycle, + "integration": host.integration, + "max_bytes": connection.max_bytes, + } + if connection.handoff is not None: + request["handoff"] = connection.handoff + if event.event_id is not None: + request["event_id"] = event.event_id + response = validate_bootstrap_context( + _post_json("/v1/context/bootstrap", request, host=host, connection=connection, deadline=deadline), + max_bytes=connection.max_bytes, + ) + except ScopeBindingRejectedError: + _emit_bootstrap_event(host, "authentication_failed") + except (ScopeBindingUnavailableError, OSError, TimeoutError, HTTPException): + # http.client reports a truncated or malformed response as HTTPException, not OSError. + _emit_bootstrap_event(host, "server_unavailable", recovery="powercontext doctor") + except (ScopeBindingStatusError, _HttpStatusError) as error: + _emit_http_failure(host, error.status, error.code) + except (ScopeBindingError, InvalidBootstrapResponse): + _emit_bootstrap_event(host, "invalid_response") + else: + return scope_id, response + return None + + +def _post_json( + path: str, + payload: Mapping[str, object], + *, + host: HostProfile, + connection: BootstrapConnection, + deadline: float, +) -> dict[str, object]: + headers = {"Accept": "application/json", "Content-Type": "application/json", "User-Agent": host.user_agent} + if connection.authorization is not None: + headers["Authorization"] = connection.authorization + request = Request( # noqa: S310 - the host's settings validate the configured transport. + f"{connection.server_url}{path}", + data=json.dumps(payload, separators=(",", ":")).encode(), + headers=headers, + method="POST", + ) + timeout = min(connection.request_timeout_seconds, _remaining_time(deadline)) + # One deadline covers the headers and the body, so a slow header phase leaves the body only what is left. + request_deadline = min(deadline, monotonic() + timeout) + try: + with open_bounded(request, timeout=timeout) as response: + if response.status != 200: + raise _HttpStatusError(response.status, path) + raw = _read_response(response, deadline=request_deadline) + except HTTPError as error: + code = connection.read_error_code(error, deadline=request_deadline) + raise _HttpStatusError(error.code, path, code) from error + try: + value = json.loads(raw) + except (ValueError, RecursionError) as error: + # Besides JSONDecodeError, json raises ValueError for an over-long integer and RecursionError for deep nesting. + raise InvalidBootstrapResponse from error + if not isinstance(value, dict): + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _read_response(response: _ReadableResponse, *, deadline: float) -> bytes: + bind_response_deadline(response, deadline) + content = bytearray() + while True: + remaining = _MAX_RESPONSE_BYTES + 1 - len(content) + chunk = response.read(min(_READ_CHUNK_BYTES, remaining)) + if not chunk: + return complete_body(response, bytes(content)) + content.extend(chunk) + if len(content) > _MAX_RESPONSE_BYTES: + raise InvalidBootstrapResponse + + +def _remaining_time(deadline: float) -> float: + remaining = deadline - monotonic() + if remaining <= 0: + raise TimeoutError + return remaining + + +def _require_injected(receipt: Mapping[str, str]) -> None: + if receipt.get("state") != "injected": + raise InvalidBootstrapResponse + + +def _clear_stale_receipt_after_failed_claim( + host: HostProfile, + session_id: str, + scope_id: str, + receipt_id: str, + previous_receipt: tuple[str, str] | None, +) -> None: + if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): + host.bootstrap_state.clear_receipt_if_matches(session_id, *previous_receipt) + + +def _clear_observed_receipt(host: HostProfile, session_id: str, previous_receipt: tuple[str, str] | None) -> None: + if previous_receipt is not None: + host.bootstrap_state.clear_receipt_if_matches(session_id, *previous_receipt) + + +def _synchronize_non_ready_receipt( + host: HostProfile, + session_id: str, + scope_id: str, + response: Mapping[str, object], + previous_receipt: tuple[str, str] | None, +) -> dict[str, str]: + """Retain a receipt only when this exact lifecycle event was already injected.""" + + receipt = cast(dict[str, str], response["receipt"]) + if ( + response.get("status") == "skipped" + and response.get("reason") == "already_delivered" + and receipt["state"] == "injected" + and previous_receipt == (scope_id, receipt["receipt_id"]) + ): + return receipt + _clear_observed_receipt(host, session_id, previous_receipt) + return receipt + + +def _emit_http_failure(host: HostProfile, status: int, code: str | None = None) -> None: + if status == 401: + outcome = "authentication_failed" + elif status == 403: + outcome = "authorization_denied" + elif status == 404 and code is None: + outcome = "version_mismatch" + elif status == 503: + outcome = "server_unavailable" + else: + outcome = "invalid_response" + _emit_bootstrap_event( + host, + outcome, + http_status=status, + error_code=code, + recovery="powercontext doctor" if outcome == "server_unavailable" else None, + ) + + +def _emit_bootstrap_event( + host: HostProfile, + outcome: str, + *, + http_status: int | None = None, + error_code: str | None = None, + context_status: str | None = None, + content_bytes: int | None = None, + truncated: bool | None = None, + receipt_state: str | None = None, + recovery: str | None = None, +) -> None: + event: dict[str, object] = { + "component": f"{host.component}.bootstrap", + "event": "context_bootstrap", + "outcome": outcome, + "profile": _BOOTSTRAP_PROFILE, + } + if http_status is not None: + event["http_status"] = http_status + if error_code is not None: + event["error_code"] = error_code + if context_status is not None: + event["context_status"] = context_status + if content_bytes is not None: + event["content_bytes"] = content_bytes + if truncated is not None: + event["truncated"] = truncated + if receipt_state is not None: + event["receipt_state"] = receipt_state + if recovery is not None: + event["recovery"] = recovery + sys.stderr.write(json.dumps(event, separators=(",", ":")) + "\n") diff --git a/integrations/codex/plugins/powercontext/powercontext_lifecycle/transport.py b/integrations/codex/plugins/powercontext/powercontext_lifecycle/transport.py new file mode 100644 index 0000000000..57411618cc --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/transport.py @@ -0,0 +1,135 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated from src/powercontext_integration_lifecycle/transport.py; do not edit. +# Edit the source and run `make integration-lifecycle` instead. + +"""Bounded HTTP for plugin code that the host runs under a time budget.""" + +from __future__ import annotations + +import socket +import threading +from http.client import IncompleteRead +from time import monotonic +from typing import Any +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +class _RejectRedirects(HTTPRedirectHandler): + # typing.override needs Python 3.12, and these modules may import only the standard library. + def redirect_request( # ty: ignore[missing-override-decorator] + self, + req: Request, + fp: object, + code: int, + msg: str, + headers: object, + newurl: str, + ) -> Request | None: + return None + + +_URL_OPENER = build_opener(_RejectRedirects) + + +def open_bounded(request: Request, *, timeout: float) -> Any: + """Open one request under a hard wall-clock bound, response headers included. + + urllib applies its timeout to each individual socket read, so a server that + trickles headers can outlive the caller's deadline. Running the open in a + daemon worker and abandoning it on expiry keeps the caller inside its budget. + """ + + outcome: list[Any] = [] + + def _open() -> None: + try: + outcome.append(_URL_OPENER.open(request, timeout=timeout)) + except BaseException as error: + outcome.append(error) + + worker = threading.Thread(target=_open, name="powercontext-http", daemon=True) + worker.start() + worker.join(timeout) + if worker.is_alive(): + raise TimeoutError + result = outcome[0] if outcome else TimeoutError() + if isinstance(result, BaseException): + raise result + return result + + +class _DeadlineSocket: + """Enforce one absolute deadline on every response socket read. + + ``http.client`` can consume many socket reads inside a single ``read`` call + while it parses chunk framing, so tightening the socket timeout once per + bounded read cannot stop a server that trickles chunk extensions. Recomputing + the timeout before every receive keeps the caller's absolute deadline, + framing included. + """ + + def __init__(self, sock: Any, deadline: float) -> None: + self._sock = sock + self._deadline = deadline + + def _remaining_time(self) -> float: + remaining = self._deadline - monotonic() + if remaining <= 0: + # Raise what the socket itself raises: on Python 3.9 socket.timeout is not TimeoutError, and SocketIO + # fails with IndexError on any other OSError that carries no errno. + raise socket.timeout + return remaining + + def recv(self, *args: Any) -> Any: + self._sock.settimeout(self._remaining_time()) + return self._sock.recv(*args) + + def recv_into(self, *args: Any) -> Any: + self._sock.settimeout(self._remaining_time()) + return self._sock.recv_into(*args) + + def __getattr__(self, name: str) -> Any: + return getattr(self._sock, name) + + +def bind_response_deadline(response: object, deadline: float) -> None: + """Keep every socket read of one open response inside the absolute deadline.""" + + if isinstance(response, HTTPError): + response = response.fp + raw: Any = getattr(getattr(response, "fp", None), "raw", None) + if raw is None: + return + sock = getattr(raw, "_sock", None) + if sock is None or isinstance(sock, _DeadlineSocket) or not hasattr(sock, "recv_into"): + return + raw._sock = _DeadlineSocket(sock, deadline) + + +def complete_body(response: object, body: bytes) -> bytes: + """Return a body read to its end, failing one that ended before its announced Content-Length. + + ``http.client`` reports a truncated chunked body as ``IncompleteRead`` but returns a Content-Length body that the + connection cut short as if it were complete, leaving the missing byte count in ``length``. + """ + + if isinstance(response, HTTPError): + response = response.fp + missing = getattr(response, "length", None) + if missing: + raise IncompleteRead(body, missing) + return body diff --git a/integrations/codex/plugins/powercontext/scripts/scope_binding.py b/integrations/codex/plugins/powercontext/scripts/scope_binding.py index 08aad26dc0..6b7450fe2f 100644 --- a/integrations/codex/plugins/powercontext/scripts/scope_binding.py +++ b/integrations/codex/plugins/powercontext/scripts/scope_binding.py @@ -22,23 +22,27 @@ import os import subprocess import sys -import threading from collections.abc import Mapping, Sequence from hashlib import sha256 -from http.client import HTTPException, IncompleteRead +from http.client import HTTPException from pathlib import Path from shutil import which from time import monotonic from typing import Any, Protocol from urllib.error import HTTPError -from urllib.request import HTTPRedirectHandler, Request, build_opener - -from typing_extensions import override +from urllib.request import Request _PLUGIN_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_PLUGIN_ROOT)) from plugin_version import PLUGIN_VERSION # noqa: E402 +from powercontext_lifecycle.scope_binding_errors import ( # noqa: E402 + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, +) +from powercontext_lifecycle.transport import bind_response_deadline, complete_body, open_bounded # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _MAX_RESPONSE_BYTES = 1_048_576 @@ -49,80 +53,10 @@ } -class ScopeBindingError(RuntimeError): - """Raised when the integration cannot establish one current Scope.""" - - -class ScopeBindingUnavailableError(ScopeBindingError): - """Transport failure, timeout, exhausted budget, or HTTP 503, whose status it keeps.""" - - def __init__(self, status: int | None = None) -> None: - self.status = status - super().__init__("PowerContext is unavailable" if status is None else f"PowerContext returned HTTP {status}") - - -class ScopeBindingRejectedError(ScopeBindingError): - """The Server rejected the credential.""" - - -class ScopeBindingStatusError(ScopeBindingError): - """A non-successful HTTP status outside the fixed classifications, with the error code the Server sent.""" - - def __init__(self, status: int, path: str, code: str | None = None) -> None: - self.status = status - self.path = path - self.code = code - super().__init__(f"PowerContext returned HTTP {status}") - - class _Response(Protocol): def read(self, amount: int = -1, /) -> bytes: ... -class _RejectRedirects(HTTPRedirectHandler): - @override - def redirect_request( - self, - req: Request, - fp: object, - code: int, - msg: str, - headers: object, - newurl: str, - ) -> Request | None: - return None - - -_URL_OPENER = build_opener(_RejectRedirects) - - -def open_bounded(request: Request, *, timeout: float) -> Any: - """Open one request under a hard wall-clock bound, response headers included. - - urllib applies its timeout to each individual socket read, so a server that - trickles headers can outlive the caller's deadline. Running the open in a - daemon worker and abandoning it on expiry keeps hooks inside their budget. - """ - - outcome: list[Any] = [] - - def _open() -> None: - try: - outcome.append(_URL_OPENER.open(request, timeout=timeout)) - except BaseException as error: - outcome.append(error) - - worker = threading.Thread(target=_open, name="powercontext-http", daemon=True) - worker.start() - worker.join(timeout) - if worker.is_alive(): - raise TimeoutError - result = outcome[0] if outcome else TimeoutError() - if isinstance(result, BaseException): - raise result - return result - - def resolve_scope_id( cwd: str, *, @@ -293,67 +227,6 @@ def _remaining_time(deadline: float) -> float: return remaining -class _DeadlineSocket: - """Enforce one absolute deadline on every response socket read. - - ``http.client`` can consume many socket reads inside a single ``read`` call - while it parses chunk framing, so tightening the socket timeout once per - bounded read cannot stop a server that trickles chunk extensions. Recomputing - the timeout before every receive keeps the caller's absolute deadline, - framing included. - """ - - def __init__(self, sock: Any, deadline: float) -> None: - self._sock = sock - self._deadline = deadline - - def _remaining_time(self) -> float: - remaining = self._deadline - monotonic() - if remaining <= 0: - raise TimeoutError - return remaining - - def recv(self, *args: Any) -> Any: - self._sock.settimeout(self._remaining_time()) - return self._sock.recv(*args) - - def recv_into(self, *args: Any) -> Any: - self._sock.settimeout(self._remaining_time()) - return self._sock.recv_into(*args) - - def __getattr__(self, name: str) -> Any: - return getattr(self._sock, name) - - -def bind_response_deadline(response: object, deadline: float) -> None: - """Keep every socket read of one open response inside the absolute deadline.""" - - if isinstance(response, HTTPError): - response = response.fp - raw: Any = getattr(getattr(response, "fp", None), "raw", None) - if raw is None: - return - sock = getattr(raw, "_sock", None) - if sock is None or isinstance(sock, _DeadlineSocket) or not hasattr(sock, "recv_into"): - return - raw._sock = _DeadlineSocket(sock, deadline) - - -def complete_body(response: object, body: bytes) -> bytes: - """Return a body read to its end, failing one that ended before its announced Content-Length. - - ``http.client`` reports a truncated chunked body as ``IncompleteRead`` but returns a Content-Length body that the - connection cut short as if it were complete, leaving the missing byte count in ``length``. - """ - - if isinstance(response, HTTPError): - response = response.fp - missing = getattr(response, "length", None) - if missing: - raise IncompleteRead(body, missing) - return body - - def _set_response_timeout(response: object, timeout: float) -> None: """Tighten urllib's socket timeout before each bounded read.""" diff --git a/pyproject.toml b/pyproject.toml index 5b7396951f..50f67fa4b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -253,6 +253,9 @@ exclude = [ "evaluation/memory/capture_ownership", "evaluation/skills", "evaluation/zcode_guidance", + # Both plugin directories are search paths, and ty resolves powercontext_lifecycle to the Claude Code copy, so it + # cannot resolve the relative imports of this identical generated copy. + "integrations/codex/plugins/powercontext/powercontext_lifecycle", ] [tool.ty.rules] diff --git a/src/powercontext_integration_lifecycle/__init__.py b/src/powercontext_integration_lifecycle/__init__.py index fa2ead29ea..5ce43e6467 100644 --- a/src/powercontext_integration_lifecycle/__init__.py +++ b/src/powercontext_integration_lifecycle/__init__.py @@ -21,7 +21,8 @@ that is missing or has drifted. Claude Code runs its hooks with the ``python3`` on ``PATH``, often the macOS system Python 3.9, and Hermes installs no -dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. A -single-module copy cannot reach its siblings, so a module may not import them either. The package sits outside +dependency for its plugin. A module may therefore import only the standard library and must run on Python 3.9. Only a +module that WorkBuddy and Hermes do not ship may import its siblings, since a single-module copy cannot reach them; it +imports them relatively, because the package copies have another name than the source. The package sits outside ``powercontext`` so that importing it loads nothing from the SDK. """ diff --git a/src/powercontext_integration_lifecycle/host_profile.py b/src/powercontext_integration_lifecycle/host_profile.py new file mode 100644 index 0000000000..89d1771cc0 --- /dev/null +++ b/src/powercontext_integration_lifecycle/host_profile.py @@ -0,0 +1,38 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The values in which one host's lifecycle differs from another's.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .bootstrap_state import BootstrapState +from .diagnostics import DiagnosticThrottle + + +@dataclass(frozen=True) +class HostProfile: + """What the lifecycle flows need to know about one host. + + ``integration`` names the host in Server requests, and ``user_agent`` identifies its plugin. ``component`` begins + the component of each diagnostic the host reports, such as ``powercontext.claude_code``. ``diagnostics`` and + ``bootstrap_state`` locate the host's diagnostic throttle and its bootstrap receipts. + """ + + integration: str + user_agent: str + component: str + diagnostics: DiagnosticThrottle + bootstrap_state: BootstrapState diff --git a/integrations/claude-code/plugins/powercontext/scope_binding_errors.py b/src/powercontext_integration_lifecycle/scope_binding_errors.py similarity index 83% rename from integrations/claude-code/plugins/powercontext/scope_binding_errors.py rename to src/powercontext_integration_lifecycle/scope_binding_errors.py index 39a0156c23..cc8b1e72d6 100644 --- a/integrations/claude-code/plugins/powercontext/scope_binding_errors.py +++ b/src/powercontext_integration_lifecycle/scope_binding_errors.py @@ -12,13 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Typed Scope binding failures shared by the Claude Code plugin entry points.""" +"""Typed Scope binding failures that the Scope clients raise and the lifecycle flows classify.""" from __future__ import annotations class ScopeBindingError(RuntimeError): - """Raised when Claude Code cannot establish one current Scope.""" + """Raised when the integration cannot establish one current Scope.""" class ScopeBindingUnavailableError(ScopeBindingError): @@ -41,11 +41,3 @@ def __init__(self, status: int, path: str, code: str | None = None) -> None: self.path = path self.code = code super().__init__(f"PowerContext returned HTTP {status}") - - -__all__ = [ - "ScopeBindingError", - "ScopeBindingRejectedError", - "ScopeBindingStatusError", - "ScopeBindingUnavailableError", -] diff --git a/src/powercontext_integration_lifecycle/session_start.py b/src/powercontext_integration_lifecycle/session_start.py new file mode 100644 index 0000000000..d8cd15ef05 --- /dev/null +++ b/src/powercontext_integration_lifecycle/session_start.py @@ -0,0 +1,378 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inject one bounded PowerContext bootstrap package when a host session starts.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Callable, Mapping +from contextlib import suppress +from dataclasses import dataclass, field +from http.client import HTTPException +from time import monotonic +from typing import Protocol, cast +from urllib.error import HTTPError +from urllib.request import Request + +from .bootstrap_context import InvalidBootstrapResponse, validate_bootstrap_context, validate_delivery_receipt +from .host_profile import HostProfile +from .scope_binding_errors import ( + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, +) +from .transport import bind_response_deadline, complete_body, open_bounded + +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" + + +class _ReadableResponse(Protocol): + status: int + + def read(self, amount: int = -1) -> bytes: ... + + def __enter__(self) -> _ReadableResponse: ... + + def __exit__(self, *args: object) -> object: ... + + +class _HttpStatusError(RuntimeError): + def __init__(self, status: int, path: str, code: str | None = None) -> None: + self.status = status + self.path = path + self.code = code + super().__init__(f"PowerContext returned HTTP {status}") + + +class _ErrorCodeReader(Protocol): + def __call__(self, error: HTTPError, *, deadline: float) -> str | None: ... + + +@dataclass(frozen=True) +class SessionStartEvent: + """One SessionStart event, translated from the host's payload.""" + + session_id: str + cwd: str + lifecycle: str + event_id: str | None + + +@dataclass(frozen=True) +class BootstrapConnection: + """The host settings that the bootstrap requests use, as plain values. + + ``resolve_scope_id`` resolves the event's Scope through the host's Scope client, within the deadline it is given. + ``read_error_code`` reads the error code of a failed bootstrap request through the same client, which fails the + request as unavailable when that body stalls or breaks off, and as invalid when it exceeds the size bound. + """ + + server_url: str + authorization: str | None = field(repr=False) + request_timeout_seconds: float + http_budget_seconds: float + enabled: bool + max_bytes: int + handoff: Mapping[str, object] | None + resolve_scope_id: Callable[[float], str] + read_error_code: _ErrorCodeReader + + +def hook_event_id(payload: Mapping[str, object]) -> str | None: + """Return the identifier that a hook payload gives its event, if it gives a usable one.""" + + for name in ("event_id", "hook_id", "invocation_id", "request_id"): + value = payload.get(name) + if isinstance(value, str) and value.strip() and len(value.strip()) <= 512: + return value.strip() + return None + + +def run_session_start( + host: HostProfile, + event: SessionStartEvent, + *, + connect: Callable[[], BootstrapConnection], + emit: Callable[[str], None], +) -> None: + """Hand the event's bootstrap package to ``emit`` once the Server has recorded its delivery. + + ``connect`` loads the host's settings after the session's previous receipt has been read; if it fails, that + receipt is cleared and nothing is reported. ``emit`` writes the package in the host's native form and raises if it + cannot. + + The flow reports the failures it recognizes as diagnostics on stderr and does not record them in the host's + diagnostic throttle, which applies to the prompt hooks' ``systemMessage``. It recognizes Scope binding errors; + transport errors and timeouts, including a response that stalls, breaks off, or ends before its Content-Length, + which ``http.client`` may raise as ``HTTPException``; HTTP error statuses, with the error code the Server sent; + responses that exceed the size bound or cannot be decoded; and, once the package has arrived, any failure to record + its delivery or to emit it. Anything else propagates, so the host's entry point must still catch it. + """ + + previous_receipt = host.bootstrap_state.load_receipt_state(event.session_id) + try: + connection = connect() + except Exception: + _clear_observed_receipt(host, event.session_id, previous_receipt) + return + deadline = monotonic() + connection.http_budget_seconds + prepared = _resolve_and_prepare(host, event, connection, deadline=deadline) + if prepared is None: + _clear_observed_receipt(host, event.session_id, previous_receipt) + return + scope_id, response = prepared + if response["status"] != "ready": + receipt = _synchronize_non_ready_receipt(host, event.session_id, scope_id, response, previous_receipt) + if response["status"] == "empty": + _emit_bootstrap_event( + host, + "empty", + http_status=200, + context_status="empty", + content_bytes=0, + truncated=cast(bool, response["truncated"]), + receipt_state=receipt["state"], + ) + return + receipt = cast(dict[str, str], response["receipt"]) + receipt_id = receipt["receipt_id"] + try: + delivered = validate_delivery_receipt( + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "injected"}, + host=host, + connection=connection, + deadline=deadline, + ), + receipt_id=receipt_id, + ) + _require_injected(delivered) + except Exception: + _clear_stale_receipt_after_failed_claim(host, event.session_id, scope_id, receipt_id, previous_receipt) + _emit_bootstrap_event(host, "delivery_failed") + with suppress(Exception): + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "failed"}, + host=host, + connection=connection, + deadline=deadline, + ) + return + try: + host.bootstrap_state.save_receipt(event.session_id, scope_id, receipt_id) + emit(cast(str, response["content"])) + except Exception: + host.bootstrap_state.clear_receipt_if_matches(event.session_id, scope_id, receipt_id) + _emit_bootstrap_event(host, "delivery_failed") + + +def _resolve_and_prepare( + host: HostProfile, + event: SessionStartEvent, + connection: BootstrapConnection, + *, + deadline: float, +) -> tuple[str, dict[str, object]] | None: + try: + scope_id = connection.resolve_scope_id(deadline) + request: dict[str, object] = { + "scope_id": scope_id, + "enabled": connection.enabled, + "profile": _BOOTSTRAP_PROFILE, + "lifecycle": event.lifecycle, + "integration": host.integration, + "max_bytes": connection.max_bytes, + } + if connection.handoff is not None: + request["handoff"] = connection.handoff + if event.event_id is not None: + request["event_id"] = event.event_id + response = validate_bootstrap_context( + _post_json("/v1/context/bootstrap", request, host=host, connection=connection, deadline=deadline), + max_bytes=connection.max_bytes, + ) + except ScopeBindingRejectedError: + _emit_bootstrap_event(host, "authentication_failed") + except (ScopeBindingUnavailableError, OSError, TimeoutError, HTTPException): + # http.client reports a truncated or malformed response as HTTPException, not OSError. + _emit_bootstrap_event(host, "server_unavailable", recovery="powercontext doctor") + except (ScopeBindingStatusError, _HttpStatusError) as error: + _emit_http_failure(host, error.status, error.code) + except (ScopeBindingError, InvalidBootstrapResponse): + _emit_bootstrap_event(host, "invalid_response") + else: + return scope_id, response + return None + + +def _post_json( + path: str, + payload: Mapping[str, object], + *, + host: HostProfile, + connection: BootstrapConnection, + deadline: float, +) -> dict[str, object]: + headers = {"Accept": "application/json", "Content-Type": "application/json", "User-Agent": host.user_agent} + if connection.authorization is not None: + headers["Authorization"] = connection.authorization + request = Request( # noqa: S310 - the host's settings validate the configured transport. + f"{connection.server_url}{path}", + data=json.dumps(payload, separators=(",", ":")).encode(), + headers=headers, + method="POST", + ) + timeout = min(connection.request_timeout_seconds, _remaining_time(deadline)) + # One deadline covers the headers and the body, so a slow header phase leaves the body only what is left. + request_deadline = min(deadline, monotonic() + timeout) + try: + with open_bounded(request, timeout=timeout) as response: + if response.status != 200: + raise _HttpStatusError(response.status, path) + raw = _read_response(response, deadline=request_deadline) + except HTTPError as error: + code = connection.read_error_code(error, deadline=request_deadline) + raise _HttpStatusError(error.code, path, code) from error + try: + value = json.loads(raw) + except (ValueError, RecursionError) as error: + # Besides JSONDecodeError, json raises ValueError for an over-long integer and RecursionError for deep nesting. + raise InvalidBootstrapResponse from error + if not isinstance(value, dict): + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _read_response(response: _ReadableResponse, *, deadline: float) -> bytes: + bind_response_deadline(response, deadline) + content = bytearray() + while True: + remaining = _MAX_RESPONSE_BYTES + 1 - len(content) + chunk = response.read(min(_READ_CHUNK_BYTES, remaining)) + if not chunk: + return complete_body(response, bytes(content)) + content.extend(chunk) + if len(content) > _MAX_RESPONSE_BYTES: + raise InvalidBootstrapResponse + + +def _remaining_time(deadline: float) -> float: + remaining = deadline - monotonic() + if remaining <= 0: + raise TimeoutError + return remaining + + +def _require_injected(receipt: Mapping[str, str]) -> None: + if receipt.get("state") != "injected": + raise InvalidBootstrapResponse + + +def _clear_stale_receipt_after_failed_claim( + host: HostProfile, + session_id: str, + scope_id: str, + receipt_id: str, + previous_receipt: tuple[str, str] | None, +) -> None: + if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): + host.bootstrap_state.clear_receipt_if_matches(session_id, *previous_receipt) + + +def _clear_observed_receipt(host: HostProfile, session_id: str, previous_receipt: tuple[str, str] | None) -> None: + if previous_receipt is not None: + host.bootstrap_state.clear_receipt_if_matches(session_id, *previous_receipt) + + +def _synchronize_non_ready_receipt( + host: HostProfile, + session_id: str, + scope_id: str, + response: Mapping[str, object], + previous_receipt: tuple[str, str] | None, +) -> dict[str, str]: + """Retain a receipt only when this exact lifecycle event was already injected.""" + + receipt = cast(dict[str, str], response["receipt"]) + if ( + response.get("status") == "skipped" + and response.get("reason") == "already_delivered" + and receipt["state"] == "injected" + and previous_receipt == (scope_id, receipt["receipt_id"]) + ): + return receipt + _clear_observed_receipt(host, session_id, previous_receipt) + return receipt + + +def _emit_http_failure(host: HostProfile, status: int, code: str | None = None) -> None: + if status == 401: + outcome = "authentication_failed" + elif status == 403: + outcome = "authorization_denied" + elif status == 404 and code is None: + outcome = "version_mismatch" + elif status == 503: + outcome = "server_unavailable" + else: + outcome = "invalid_response" + _emit_bootstrap_event( + host, + outcome, + http_status=status, + error_code=code, + recovery="powercontext doctor" if outcome == "server_unavailable" else None, + ) + + +def _emit_bootstrap_event( + host: HostProfile, + outcome: str, + *, + http_status: int | None = None, + error_code: str | None = None, + context_status: str | None = None, + content_bytes: int | None = None, + truncated: bool | None = None, + receipt_state: str | None = None, + recovery: str | None = None, +) -> None: + event: dict[str, object] = { + "component": f"{host.component}.bootstrap", + "event": "context_bootstrap", + "outcome": outcome, + "profile": _BOOTSTRAP_PROFILE, + } + if http_status is not None: + event["http_status"] = http_status + if error_code is not None: + event["error_code"] = error_code + if context_status is not None: + event["context_status"] = context_status + if content_bytes is not None: + event["content_bytes"] = content_bytes + if truncated is not None: + event["truncated"] = truncated + if receipt_state is not None: + event["receipt_state"] = receipt_state + if recovery is not None: + event["recovery"] = recovery + sys.stderr.write(json.dumps(event, separators=(",", ":")) + "\n") diff --git a/src/powercontext_integration_lifecycle/transport.py b/src/powercontext_integration_lifecycle/transport.py new file mode 100644 index 0000000000..805bf15206 --- /dev/null +++ b/src/powercontext_integration_lifecycle/transport.py @@ -0,0 +1,132 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bounded HTTP for plugin code that the host runs under a time budget.""" + +from __future__ import annotations + +import socket +import threading +from http.client import IncompleteRead +from time import monotonic +from typing import Any +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +class _RejectRedirects(HTTPRedirectHandler): + # typing.override needs Python 3.12, and these modules may import only the standard library. + def redirect_request( # ty: ignore[missing-override-decorator] + self, + req: Request, + fp: object, + code: int, + msg: str, + headers: object, + newurl: str, + ) -> Request | None: + return None + + +_URL_OPENER = build_opener(_RejectRedirects) + + +def open_bounded(request: Request, *, timeout: float) -> Any: + """Open one request under a hard wall-clock bound, response headers included. + + urllib applies its timeout to each individual socket read, so a server that + trickles headers can outlive the caller's deadline. Running the open in a + daemon worker and abandoning it on expiry keeps the caller inside its budget. + """ + + outcome: list[Any] = [] + + def _open() -> None: + try: + outcome.append(_URL_OPENER.open(request, timeout=timeout)) + except BaseException as error: + outcome.append(error) + + worker = threading.Thread(target=_open, name="powercontext-http", daemon=True) + worker.start() + worker.join(timeout) + if worker.is_alive(): + raise TimeoutError + result = outcome[0] if outcome else TimeoutError() + if isinstance(result, BaseException): + raise result + return result + + +class _DeadlineSocket: + """Enforce one absolute deadline on every response socket read. + + ``http.client`` can consume many socket reads inside a single ``read`` call + while it parses chunk framing, so tightening the socket timeout once per + bounded read cannot stop a server that trickles chunk extensions. Recomputing + the timeout before every receive keeps the caller's absolute deadline, + framing included. + """ + + def __init__(self, sock: Any, deadline: float) -> None: + self._sock = sock + self._deadline = deadline + + def _remaining_time(self) -> float: + remaining = self._deadline - monotonic() + if remaining <= 0: + # Raise what the socket itself raises: on Python 3.9 socket.timeout is not TimeoutError, and SocketIO + # fails with IndexError on any other OSError that carries no errno. + raise socket.timeout + return remaining + + def recv(self, *args: Any) -> Any: + self._sock.settimeout(self._remaining_time()) + return self._sock.recv(*args) + + def recv_into(self, *args: Any) -> Any: + self._sock.settimeout(self._remaining_time()) + return self._sock.recv_into(*args) + + def __getattr__(self, name: str) -> Any: + return getattr(self._sock, name) + + +def bind_response_deadline(response: object, deadline: float) -> None: + """Keep every socket read of one open response inside the absolute deadline.""" + + if isinstance(response, HTTPError): + response = response.fp + raw: Any = getattr(getattr(response, "fp", None), "raw", None) + if raw is None: + return + sock = getattr(raw, "_sock", None) + if sock is None or isinstance(sock, _DeadlineSocket) or not hasattr(sock, "recv_into"): + return + raw._sock = _DeadlineSocket(sock, deadline) + + +def complete_body(response: object, body: bytes) -> bytes: + """Return a body read to its end, failing one that ended before its announced Content-Length. + + ``http.client`` reports a truncated chunked body as ``IncompleteRead`` but returns a Content-Length body that the + connection cut short as if it were complete, leaving the missing byte count in ``length``. + """ + + if isinstance(response, HTTPError): + response = response.fp + missing = getattr(response, "length", None) + if missing: + raise IncompleteRead(body, missing) + return body diff --git a/tests/claude_code_plugin/conftest.py b/tests/claude_code_plugin/conftest.py index 2184c30a30..80094466e6 100644 --- a/tests/claude_code_plugin/conftest.py +++ b/tests/claude_code_plugin/conftest.py @@ -33,8 +33,12 @@ "powercontext_lifecycle.bootstrap_state", "powercontext_lifecycle.client_config", "powercontext_lifecycle.diagnostics", + "powercontext_lifecycle.host_profile", "powercontext_lifecycle.prepared_context", "powercontext_lifecycle.prompt_privacy", + "powercontext_lifecycle.scope_binding_errors", + "powercontext_lifecycle.session_start", + "powercontext_lifecycle.transport", "scripts", "scripts.workspace_scope", "workspace_scope", @@ -97,6 +101,11 @@ def session_start_module(plugin_imports: None) -> ModuleType: ) +@pytest.fixture +def session_start_flow(session_start_module: ModuleType) -> ModuleType: + return sys.modules[session_start_module.run_session_start.__module__] + + @pytest.fixture def scope_module(plugin_imports: None) -> ModuleType: return _load_module( diff --git a/tests/claude_code_plugin/test_bootstrap.py b/tests/claude_code_plugin/test_bootstrap.py index 91940b2bcf..77dbb2fa68 100644 --- a/tests/claude_code_plugin/test_bootstrap.py +++ b/tests/claude_code_plugin/test_bootstrap.py @@ -23,9 +23,11 @@ from hashlib import sha256 from pathlib import Path from threading import Event, Thread +from time import monotonic from types import ModuleType from typing import cast from urllib.error import HTTPError +from urllib.request import Request import pytest @@ -82,6 +84,7 @@ def _already_delivered() -> dict[str, object]: def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( session_start_module: ModuleType, + session_start_flow: ModuleType, hook_module: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -94,7 +97,7 @@ def post(path, payload, **_kwargs): return {"receipt_id": "bcr_test", "state": "injected"} return _ready() - monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -189,12 +192,13 @@ def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): def test_repeated_injected_event_preserves_receipt_for_first_query( session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") + session_start_module.CLAUDE_CODE.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_test") monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr(session_start_flow, "_post_json", lambda *_args, **_kwargs: _already_delivered()) monkeypatch.setattr( sys, "stdin", @@ -219,9 +223,10 @@ def test_repeated_injected_event_preserves_receipt_for_first_query( def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - bootstrap_state = sys.modules[session_start_module.BOOTSTRAP_STATE.load_receipt_state.__module__] + bootstrap_state = sys.modules[session_start_module.CLAUDE_CODE.bootstrap_state.load_receipt_state.__module__] original_read = bootstrap_state._read_receipt_state snapshot_read = Event() winner_saved = Event() @@ -237,7 +242,7 @@ def synchronized_read(path): def save_winner() -> None: if snapshot_read.wait(2): - session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") + session_start_module.CLAUDE_CODE.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_test") winner_saved.set() def post(*_args, **_kwargs): @@ -249,7 +254,7 @@ def post(*_args, **_kwargs): monkeypatch.setattr(bootstrap_state, "_read_receipt_state", synchronized_read) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr( sys, "stdin", @@ -268,7 +273,10 @@ def post(*_args, **_kwargs): assert winner is not None winner.join(timeout=2) assert not winner.is_alive() - assert session_start_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_start_module.CLAUDE_CODE.bootstrap_state.load_receipt_state("session-1") == ( + "scope:test", + "bcr_test", + ) def test_invalid_settings_clear_a_stale_receipt( @@ -276,7 +284,7 @@ def test_invalid_settings_clear_a_stale_receipt( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") + session_start_module.CLAUDE_CODE.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_previous") def invalid_settings(): raise ValueError @@ -300,7 +308,7 @@ def invalid_settings(): @pytest.mark.parametrize("invalid_field", ["receipt", "artifact"]) def test_bootstrap_response_rejects_identifiers_containing_spaces( invalid_field: str, - session_start_module: ModuleType, + session_start_flow: ModuleType, ) -> None: response = _ready() if invalid_field == "receipt": @@ -310,20 +318,21 @@ def test_bootstrap_response_rejects_identifiers_containing_spaces( artifact = cast(dict[str, object], items[0]["artifact"]) artifact["artifact_id"] = "memory id" - with pytest.raises(session_start_module.InvalidBootstrapResponse): - session_start_module.validate_bootstrap_context(response, max_bytes=4096) + with pytest.raises(session_start_flow.InvalidBootstrapResponse): + session_start_flow.validate_bootstrap_context(response, max_bytes=4096) @pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "fork"]) def test_session_start_preserves_distinct_lifecycle_sources( lifecycle: str, session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: requests: list[dict[str, object]] = [] monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( - session_start_module, + session_start_flow, "_post_json", lambda _path, payload, **_kwargs: requests.append(payload) or _skipped(), ) @@ -343,6 +352,7 @@ def test_session_start_preserves_distinct_lifecycle_sources( def test_session_start_fails_open_and_does_not_inject_when_delivery_cannot_be_recorded( session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: calls = 0 @@ -354,7 +364,7 @@ def post(path, _payload, **_kwargs): return _ready() raise OSError - monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -374,19 +384,20 @@ def post(path, _payload, **_kwargs): def test_concurrent_delivery_loser_preserves_the_winner_receipt( session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") + session_start_module.CLAUDE_CODE.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_previous") def post(path, payload, **_kwargs): if path == "/v1/context/bootstrap": return _ready() if payload["outcome"] == "injected": - session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") + session_start_module.CLAUDE_CODE.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_test") raise OSError return {"receipt_id": "bcr_test", "state": "injected"} - monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -397,18 +408,22 @@ def post(path, payload, **_kwargs): monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 - assert session_start_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_start_module.CLAUDE_CODE.bootstrap_state.load_receipt_state("session-1") == ( + "scope:test", + "bcr_test", + ) def test_session_start_output_failure_clears_the_deduplication_receipt( session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: def post(path, _payload, **_kwargs): return {"receipt_id": "bcr_test", "state": "injected"} if path.endswith("/receipts") else _ready() - monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -427,7 +442,7 @@ def post(path, _payload, **_kwargs): output.close() assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) - monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr(session_start_flow, "_post_json", lambda *_args, **_kwargs: _already_delivered()) monkeypatch.setattr( sys, "stdin", @@ -440,9 +455,10 @@ def post(path, _payload, **_kwargs): def test_session_start_rejects_malformed_bootstrap_without_blocking( session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) + monkeypatch.setattr(session_start_flow, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -463,6 +479,7 @@ def test_session_start_rejects_malformed_bootstrap_without_blocking( def test_session_start_empty_or_timeout_fails_open( result: dict[str, object] | TimeoutError, session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -471,9 +488,9 @@ def post(*_args, **_kwargs): raise result return result - monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - session_start_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") + session_start_module.CLAUDE_CODE.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_previous") monkeypatch.setattr( sys, "stdin", @@ -507,6 +524,7 @@ def test_session_start_classifies_http_failures_without_blocking( http_status: int, outcome: str, session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: class Response: @@ -518,7 +536,7 @@ def __enter__(self): def __exit__(self, *_args): return None - monkeypatch.setattr(session_start_module, "open_bounded", lambda *_args, **_kwargs: Response()) + monkeypatch.setattr(session_start_flow, "open_bounded", lambda *_args, **_kwargs: Response()) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -538,8 +556,54 @@ def __exit__(self, *_args): assert "content" not in diagnostic +def test_session_start_requests_follow_the_configured_settings( + session_start_module: ModuleType, + session_start_flow: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings_module = sys.modules[session_start_module.ClaudeCodePluginSettings.__module__] + budgets: list[float] = [] + requests: list[tuple[Request, float]] = [] + + def resolve(_cwd, *, deadline, **_kwargs): + budgets.append(deadline - monotonic()) + return "scope:test" + + def open_bounded(request, *, timeout): + requests.append((request, timeout)) + raise TimeoutError + + monkeypatch.setattr(session_start_module, "resolve_scope_id", resolve) + monkeypatch.setattr(session_start_flow, "open_bounded", open_bounded) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + settings = session_start_module.ClaudeCodePluginSettings( + authorization="Bearer configured-token", + bootstrap_context=True, + bootstrap_max_bytes=2048, + bootstrap_handoff=settings_module.BootstrapHandoff(artifact_id="handoff-1", revision=3), + request_timeout_seconds=0.5, + http_budget_seconds=5.0, + ) + + assert session_start_module.main(settings) == 0 + ((request, timeout),) = requests + assert 4.0 < budgets[0] <= 5.0 + assert 0.0 < timeout <= 0.5 + assert request.get_header("Authorization") == "Bearer configured-token" + body = json.loads(cast(bytes, request.data)) + assert body["enabled"] is True + assert body["max_bytes"] == 2048 + assert body["handoff"] == {"family": "handoff", "artifact_id": "handoff-1", "revision": 3} + + def test_bootstrap_error_body_shares_the_request_deadline_with_its_headers( session_start_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: elapsed = 0.0 @@ -562,14 +626,21 @@ def open_error(*_args: object, **_kwargs: object) -> None: raise error # Advance only transport time so scheduler pauses cannot masquerade as deadline overruns. - monkeypatch.setattr(session_start_module, "monotonic", lambda: elapsed) + monkeypatch.setattr(session_start_flow, "monotonic", lambda: elapsed) monkeypatch.setattr(sys.modules[session_start_module.read_error_code.__module__], "monotonic", lambda: elapsed) - monkeypatch.setattr(session_start_module, "open_bounded", open_error) - with pytest.raises(session_start_module.ScopeBindingUnavailableError): - session_start_module._post_json( + monkeypatch.setattr(session_start_flow, "open_bounded", open_error) + event = session_start_flow.SessionStartEvent( + session_id="session-1", cwd="/workspace", lifecycle="startup", event_id=None + ) + connection = session_start_module._connect( + event, session_start_module.ClaudeCodePluginSettings(request_timeout_seconds=1.0) + ) + with pytest.raises(session_start_flow.ScopeBindingUnavailableError): + session_start_flow._post_json( "/v1/context/bootstrap", {}, - settings=session_start_module.ClaudeCodePluginSettings(request_timeout_seconds=1.0), + host=session_start_module.CLAUDE_CODE, + connection=connection, deadline=10.0, ) diff --git a/tests/claude_code_plugin/test_hook.py b/tests/claude_code_plugin/test_hook.py index ed00e8a5b6..ed35581ddf 100644 --- a/tests/claude_code_plugin/test_hook.py +++ b/tests/claude_code_plugin/test_hook.py @@ -723,7 +723,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or handler: type[BaseHTTPRequestHandler], outcome: str, ) -> None: - session_start_module.BOOTSTRAP_STATE.save_receipt("session-a", "scope:test", "bcr_previous") + session_start_module.CLAUDE_CODE.bootstrap_state.save_receipt("session-a", "scope:test", "bcr_previous") with _serve(handler) as server_url: _, errors = _run_main( session_start_module, @@ -733,7 +733,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or ) assert json.loads(errors)["outcome"] == outcome - assert session_start_module.BOOTSTRAP_STATE.load_receipt_state("session-a") is None + assert session_start_module.CLAUDE_CODE.bootstrap_state.load_receipt_state("session-a") is None @pytest.mark.parametrize( diff --git a/tests/codex_plugin/conftest.py b/tests/codex_plugin/conftest.py index cff577f479..8aa1c707df 100644 --- a/tests/codex_plugin/conftest.py +++ b/tests/codex_plugin/conftest.py @@ -15,6 +15,7 @@ from __future__ import annotations import importlib.util +import sys from pathlib import Path from types import ModuleType @@ -74,3 +75,8 @@ def bind_tools_module() -> ModuleType: @pytest.fixture def session_binding_module() -> ModuleType: return _load_module("powercontext_codex_session_binding", PLUGIN_ROOT / "hooks" / "session_binding.py") + + +@pytest.fixture +def session_start_flow(session_binding_module: ModuleType) -> ModuleType: + return sys.modules[session_binding_module.run_session_start.__module__] diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py index 03a342f2e7..a9368e56b3 100644 --- a/tests/codex_plugin/test_bootstrap.py +++ b/tests/codex_plugin/test_bootstrap.py @@ -23,9 +23,11 @@ from hashlib import sha256 from pathlib import Path from threading import Event, Thread +from time import monotonic from types import ModuleType from typing import cast from urllib.error import HTTPError +from urllib.request import Request import pytest @@ -82,6 +84,7 @@ def _already_delivered() -> dict[str, object]: def test_session_start_without_lifecycle_still_persists_session_binding( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: resolutions: list[tuple[str, str | None, bool]] = [] @@ -93,7 +96,7 @@ def resolve(cwd, *, session_id, persist_session=False, **_kwargs): monkeypatch.setattr(session_binding_module, "resolve_scope_id", resolve) monkeypatch.setattr( - session_binding_module, + session_start_flow, "_post_json", lambda path, *_args, **_kwargs: requests.append(path) or _skipped(), ) @@ -113,6 +116,7 @@ def resolve(cwd, *, session_id, persist_session=False, **_kwargs): def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( session_binding_module: ModuleType, + session_start_flow: ModuleType, recall_module: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -125,7 +129,7 @@ def post(path, payload, **_kwargs): return {"receipt_id": "bcr_test", "state": "injected"} return _ready() - monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -218,12 +222,13 @@ def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): def test_repeated_injected_event_preserves_receipt_for_first_query( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") + session_binding_module.CODEX.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_test") monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr(session_start_flow, "_post_json", lambda *_args, **_kwargs: _already_delivered()) monkeypatch.setattr( sys, "stdin", @@ -248,9 +253,10 @@ def test_repeated_injected_event_preserves_receipt_for_first_query( def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - bootstrap_state = sys.modules[session_binding_module.BOOTSTRAP_STATE.load_receipt_state.__module__] + bootstrap_state = sys.modules[session_binding_module.CODEX.bootstrap_state.load_receipt_state.__module__] original_read = bootstrap_state._read_receipt_state snapshot_read = Event() winner_saved = Event() @@ -266,7 +272,7 @@ def synchronized_read(path): def save_winner() -> None: if snapshot_read.wait(2): - session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") + session_binding_module.CODEX.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_test") winner_saved.set() def post(*_args, **_kwargs): @@ -278,7 +284,7 @@ def post(*_args, **_kwargs): monkeypatch.setattr(bootstrap_state, "_read_receipt_state", synchronized_read) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr( sys, "stdin", @@ -297,7 +303,7 @@ def post(*_args, **_kwargs): assert winner is not None winner.join(timeout=2) assert not winner.is_alive() - assert session_binding_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_binding_module.CODEX.bootstrap_state.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_invalid_settings_clear_a_stale_receipt( @@ -305,7 +311,7 @@ def test_invalid_settings_clear_a_stale_receipt( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") + session_binding_module.CODEX.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_previous") def invalid_settings(): raise ValueError @@ -325,7 +331,7 @@ def invalid_settings(): @pytest.mark.parametrize("invalid_field", ["receipt", "artifact"]) def test_bootstrap_response_rejects_identifiers_containing_spaces( invalid_field: str, - session_binding_module: ModuleType, + session_start_flow: ModuleType, ) -> None: response = _ready() if invalid_field == "receipt": @@ -335,20 +341,21 @@ def test_bootstrap_response_rejects_identifiers_containing_spaces( artifact = cast(dict[str, object], items[0]["artifact"]) artifact["artifact_id"] = "memory id" - with pytest.raises(session_binding_module.InvalidBootstrapResponse): - session_binding_module.validate_bootstrap_context(response, max_bytes=4096) + with pytest.raises(session_start_flow.InvalidBootstrapResponse): + session_start_flow.validate_bootstrap_context(response, max_bytes=4096) @pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "restore", "fork"]) def test_session_start_preserves_distinct_lifecycle_sources( lifecycle: str, session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: requests: list[dict[str, object]] = [] monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( - session_binding_module, + session_start_flow, "_post_json", lambda path, payload, **_kwargs: requests.append(payload) or _skipped(), ) @@ -368,6 +375,7 @@ def test_session_start_preserves_distinct_lifecycle_sources( def test_session_start_fails_open_and_does_not_inject_when_delivery_cannot_be_recorded( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: calls = 0 @@ -379,7 +387,7 @@ def post(path, _payload, **_kwargs): return _ready() raise OSError - monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -399,19 +407,20 @@ def post(path, _payload, **_kwargs): def test_concurrent_delivery_loser_preserves_the_winner_receipt( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") + session_binding_module.CODEX.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_previous") def post(path, payload, **_kwargs): if path == "/v1/context/bootstrap": return _ready() if payload["outcome"] == "injected": - session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_test") + session_binding_module.CODEX.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_test") raise OSError return {"receipt_id": "bcr_test", "state": "injected"} - monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -422,18 +431,19 @@ def post(path, payload, **_kwargs): monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 - assert session_binding_module.BOOTSTRAP_STATE.load_receipt_state("session-1") == ("scope:test", "bcr_test") + assert session_binding_module.CODEX.bootstrap_state.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_session_start_output_failure_clears_the_deduplication_receipt( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: def post(path, _payload, **_kwargs): return {"receipt_id": "bcr_test", "state": "injected"} if path.endswith("/receipts") else _ready() - monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -452,7 +462,7 @@ def post(path, _payload, **_kwargs): output.close() assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) - monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr(session_start_flow, "_post_json", lambda *_args, **_kwargs: _already_delivered()) monkeypatch.setattr( sys, "stdin", @@ -465,9 +475,10 @@ def post(path, _payload, **_kwargs): def test_session_start_rejects_malformed_bootstrap_without_blocking( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) + monkeypatch.setattr(session_start_flow, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -488,6 +499,7 @@ def test_session_start_rejects_malformed_bootstrap_without_blocking( def test_session_start_empty_or_timeout_fails_open( result: dict[str, object] | TimeoutError, session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -496,9 +508,9 @@ def post(*_args, **_kwargs): raise result return result - monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_start_flow, "_post_json", post) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") - session_binding_module.BOOTSTRAP_STATE.save_receipt("session-1", "scope:test", "bcr_previous") + session_binding_module.CODEX.bootstrap_state.save_receipt("session-1", "scope:test", "bcr_previous") monkeypatch.setattr( sys, "stdin", @@ -532,6 +544,7 @@ def test_session_start_classifies_http_failures_without_blocking( http_status: int, outcome: str, session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: class Response: @@ -543,7 +556,7 @@ def __enter__(self): def __exit__(self, *_args): return None - monkeypatch.setattr(session_binding_module, "open_bounded", lambda *_args, **_kwargs: Response()) + monkeypatch.setattr(session_start_flow, "open_bounded", lambda *_args, **_kwargs: Response()) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") monkeypatch.setattr( sys, @@ -563,8 +576,54 @@ def __exit__(self, *_args): assert "content" not in diagnostic +def test_session_start_requests_follow_the_configured_settings( + session_binding_module: ModuleType, + session_start_flow: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings_module = sys.modules[session_binding_module.CodexPluginSettings.__module__] + budgets: list[float] = [] + requests: list[tuple[Request, float]] = [] + + def resolve(_cwd, *, deadline, **_kwargs): + budgets.append(deadline - monotonic()) + return "scope:test" + + def open_bounded(request, *, timeout): + requests.append((request, timeout)) + raise TimeoutError + + monkeypatch.setattr(session_binding_module, "resolve_scope_id", resolve) + monkeypatch.setattr(session_start_flow, "open_bounded", open_bounded) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + settings = session_binding_module.CodexPluginSettings( + authorization="Bearer configured-token", + bootstrap_context=True, + bootstrap_max_bytes=2048, + bootstrap_handoff=settings_module.BootstrapHandoff(artifact_id="handoff-1", revision=3), + request_timeout_seconds=0.5, + http_budget_seconds=5.0, + ) + + assert session_binding_module.main(settings) == 0 + ((request, timeout),) = requests + assert 4.0 < budgets[0] <= 5.0 + assert 0.0 < timeout <= 0.5 + assert request.get_header("Authorization") == "Bearer configured-token" + body = json.loads(cast(bytes, request.data)) + assert body["enabled"] is True + assert body["max_bytes"] == 2048 + assert body["handoff"] == {"family": "handoff", "artifact_id": "handoff-1", "revision": 3} + + def test_bootstrap_error_body_shares_the_request_deadline_with_its_headers( session_binding_module: ModuleType, + session_start_flow: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: elapsed = 0.0 @@ -587,14 +646,21 @@ def open_error(*_args: object, **_kwargs: object) -> None: raise error # Advance only transport time so scheduler pauses cannot masquerade as deadline overruns. - monkeypatch.setattr(session_binding_module, "monotonic", lambda: elapsed) + monkeypatch.setattr(session_start_flow, "monotonic", lambda: elapsed) monkeypatch.setattr(sys.modules[session_binding_module.read_error_code.__module__], "monotonic", lambda: elapsed) - monkeypatch.setattr(session_binding_module, "open_bounded", open_error) - with pytest.raises(session_binding_module.ScopeBindingUnavailableError): - session_binding_module._post_json( + monkeypatch.setattr(session_start_flow, "open_bounded", open_error) + event = session_start_flow.SessionStartEvent( + session_id="session-1", cwd="/workspace", lifecycle="startup", event_id=None + ) + connection = session_binding_module._connect( + event, session_binding_module.CodexPluginSettings(request_timeout_seconds=1.0) + ) + with pytest.raises(session_start_flow.ScopeBindingUnavailableError): + session_start_flow._post_json( "/v1/context/bootstrap", {}, - settings=session_binding_module.CodexPluginSettings(request_timeout_seconds=1.0), + host=session_binding_module.CODEX, + connection=connection, deadline=10.0, ) diff --git a/tests/codex_plugin/test_recall.py b/tests/codex_plugin/test_recall.py index cdcb61ba1f..6255a44077 100644 --- a/tests/codex_plugin/test_recall.py +++ b/tests/codex_plugin/test_recall.py @@ -710,7 +710,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or handler: type[BaseHTTPRequestHandler], outcome: str, ) -> None: - session_binding_module.BOOTSTRAP_STATE.save_receipt("thread-a", "scope:test", "bcr_previous") + session_binding_module.CODEX.bootstrap_state.save_receipt("thread-a", "scope:test", "bcr_previous") with _serve(handler) as server_url: settings = session_binding_module.CodexPluginSettings() object.__setattr__(settings, "server_url", server_url) @@ -722,7 +722,7 @@ def test_session_start_clears_the_kept_receipt_when_a_response_cannot_be_read_or ) assert json.loads(errors)["outcome"] == outcome - assert session_binding_module.BOOTSTRAP_STATE.load_receipt_state("thread-a") is None + assert session_binding_module.CODEX.bootstrap_state.load_receipt_state("thread-a") is None @pytest.mark.parametrize( diff --git a/tests/test_integration_lifecycle.py b/tests/test_integration_lifecycle.py index 2d7eea0c96..e623f004ea 100644 --- a/tests/test_integration_lifecycle.py +++ b/tests/test_integration_lifecycle.py @@ -14,9 +14,9 @@ """The integration lifecycle helpers run inside plugins that are installed without the PowerContext package. -Claude Code runs its hooks with the ``python3`` on ``PATH``, and Hermes installs no dependency for its plugin. -WorkBuddy and Hermes also ship single modules, which cannot reach their siblings. A helper may therefore import only -the standard library, and no sibling. +Claude Code runs its hooks with the ``python3`` on ``PATH``, and Hermes installs no dependency for its plugin, so a +helper may import only the standard library. WorkBuddy and Hermes also ship single modules, which cannot reach their +siblings, so a module they ship may not import a sibling either. """ from __future__ import annotations @@ -27,6 +27,7 @@ from pathlib import Path import pytest +from generate_integration_lifecycle import COPIES ROOT = Path(__file__).parents[1] SOURCE = ROOT / "src/powercontext_integration_lifecycle" @@ -34,10 +35,12 @@ @pytest.mark.parametrize("module", sorted(SOURCE.glob("*.py")), ids=lambda path: path.name) def test_lifecycle_helper_imports_only_the_standard_library(module: Path) -> None: + # Claude Code and Codex ship the whole package, so a module that only they ship may import its siblings. + allowed = sys.stdlib_module_names if module.name in COPIES else sys.stdlib_module_names | {""} outside = [ f"line {node.lineno}: {ast.unparse(node)}" for node in ast.walk(ast.parse(module.read_text(encoding="utf-8"))) - if isinstance(node, (ast.Import, ast.ImportFrom)) and not _top_level_names(node) <= sys.stdlib_module_names + if isinstance(node, (ast.Import, ast.ImportFrom)) and not _top_level_names(node) <= allowed ] assert not outside, f"{module.name} imports outside the standard library: {'; '.join(outside)}" From b8254af4b199dd68cdd614708ee9419738ea5952 Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Wed, 28 Oct 2026 02:17:45 -0400 Subject: [PATCH 4/4] test(integrations): run the SessionStart hooks with recorded host payloads The characterization fixtures give the Claude Code and Codex SessionStart hooks payloads written by hand. The hosts send more: Claude Code adds context and cache fields on resume and fork, and the model and prompt ID after compaction; Codex adds the permission mode. The payloads in tests/fixtures/recorded_host_payloads were recorded from Claude Code 2.1.277 (startup, resume, compact, fork) and Codex 0.165.0-alpha.1.2 (startup, resume, fork), one for each lifecycle; clear was not recorded, nor compact in Codex. The payloads keep every key and value except the session ID, working directory, transcript path, and prompt ID, which follow the characterization fixtures. Each recorded payload replaces the payload of the characterized case in which SessionStart injects the package and the first prompt reports its receipt. It must lead to the same requests, output, and state, apart from the lifecycle that the bootstrap request names. A second test runs each host's hook with a stdout pipe that has no reader, as when the host stops reading. The hook must exit with status 0, report the failed delivery once, and keep no receipt. No other test covers the hooks' redirection of stdout to the null device after a broken pipe: without it, Python reports the broken pipe again at exit and the process exits with status 120. Both tests also pass before the flow moved into the lifecycle package. --- .../recorded_host_payloads/claude-code.json | 43 ++++++ .../recorded_host_payloads/codex.json | 32 +++++ tests/test_session_start_shells.py | 122 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 tests/fixtures/recorded_host_payloads/claude-code.json create mode 100644 tests/fixtures/recorded_host_payloads/codex.json create mode 100644 tests/test_session_start_shells.py diff --git a/tests/fixtures/recorded_host_payloads/claude-code.json b/tests/fixtures/recorded_host_payloads/claude-code.json new file mode 100644 index 0000000000..a3aac7c955 --- /dev/null +++ b/tests/fixtures/recorded_host_payloads/claude-code.json @@ -0,0 +1,43 @@ +{ + "description": "SessionStart payloads that Claude Code 2.1.277 passed to the plugin's hook in headless runs, one per lifecycle. The session ID, working directory, transcript path, and prompt IDs follow the characterization fixture; every other key and value is as the host sent it.", + "payloads": { + "startup": { + "session_id": "session-a", + "transcript_path": "$HOME/.claude/projects/workspace/session-a.jsonl", + "cwd": "$CWD", + "hook_event_name": "SessionStart", + "source": "startup" + }, + "resume": { + "session_id": "session-a", + "transcript_path": "$HOME/.claude/projects/workspace/session-a.jsonl", + "cwd": "$CWD", + "hook_event_name": "SessionStart", + "source": "resume", + "seconds_since_last_response": 24, + "context_tokens": 23063, + "prompt_cache_likely_expired": false, + "estimated_cache_write_usd": 0.0461 + }, + "compact": { + "session_id": "session-a", + "transcript_path": "$HOME/.claude/projects/workspace/session-a.jsonl", + "cwd": "$CWD", + "prompt_id": "prompt-1", + "hook_event_name": "SessionStart", + "source": "compact", + "model": "claude-haiku-4-5-20251001" + }, + "fork": { + "session_id": "session-a", + "transcript_path": "$HOME/.claude/projects/workspace/session-a.jsonl", + "cwd": "$CWD", + "hook_event_name": "SessionStart", + "source": "fork", + "seconds_since_last_response": 30, + "context_tokens": 2913, + "prompt_cache_likely_expired": true, + "estimated_cache_write_usd": 0.0058 + } + } +} diff --git a/tests/fixtures/recorded_host_payloads/codex.json b/tests/fixtures/recorded_host_payloads/codex.json new file mode 100644 index 0000000000..dff71465ee --- /dev/null +++ b/tests/fixtures/recorded_host_payloads/codex.json @@ -0,0 +1,32 @@ +{ + "description": "SessionStart payloads that Codex 0.165.0-alpha.1.2 passed to the plugin's hook in TUI runs, one per lifecycle. The session ID, working directory, transcript path, and prompt IDs follow the characterization fixture; every other key and value is as the host sent it.", + "payloads": { + "startup": { + "session_id": "thread-a", + "transcript_path": "$CONFIG/sessions/2026/10/26/rollout-thread-a.jsonl", + "cwd": "$CWD", + "hook_event_name": "SessionStart", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "source": "startup" + }, + "resume": { + "session_id": "thread-a", + "transcript_path": "$CONFIG/sessions/2026/10/26/rollout-thread-a.jsonl", + "cwd": "$CWD", + "hook_event_name": "SessionStart", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "source": "resume" + }, + "fork": { + "session_id": "thread-a", + "transcript_path": "$CONFIG/sessions/2026/10/26/rollout-thread-a.jsonl", + "cwd": "$CWD", + "hook_event_name": "SessionStart", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "source": "fork" + } + } +} diff --git a/tests/test_session_start_shells.py b/tests/test_session_start_shells.py new file mode 100644 index 0000000000..ea164abc70 --- /dev/null +++ b/tests/test_session_start_shells.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Claude Code and Codex SessionStart hooks handle what their hosts actually send, and a host that stops reading. + +``tests/fixtures/recorded_host_payloads`` holds SessionStart payloads recorded from each real host, one per +lifecycle. Each replaces the payload of the characterized bootstrap case and must lead to the same requests, +output, and state, apart from the lifecycle that the bootstrap request names. Recorded payloads carry fields that +the characterized one lacks, such as the model after a Claude Code compaction. The hooks run as in the +characterization harness, against the Server responses of its fixtures. +""" + +from __future__ import annotations + +import copy +import json +import os +import sys +from pathlib import Path +from typing import Any + +import pytest + +from tests.test_hook_characterization import FIXTURE_DATA, ROOT, _hook_commands, _run_case + +pytestmark = pytest.mark.skipif(os.name == "nt", reason="the recorded state includes POSIX file modes") + +RECORDED = { + host: json.loads((ROOT / f"tests/fixtures/recorded_host_payloads/{host}.json").read_text(encoding="utf-8")) + for host in FIXTURE_DATA +} +RECORDED_CASES = [(host, lifecycle) for host, recorded in RECORDED.items() for lifecycle in recorded["payloads"]] +# In this characterized case, SessionStart injects the package and the first prompt reports its receipt. +BOOTSTRAP_CASE = "session-start-injects-bootstrap-and-prompt-reports-its-receipt" +# Runs the hook with a stdout pipe that has no reader, as when the host stops reading before the hook writes. +_UNREAD_STDOUT = """ +import os, sys +reader, writer = os.pipe() +os.close(reader) +os.dup2(writer, 1) +os.close(writer) +os.execv(sys.argv[1], sys.argv[1:]) +""" + + +@pytest.fixture(scope="module") +def hook_commands(tmp_path_factory: pytest.TempPathFactory) -> dict[str, list[str]]: + return _hook_commands(tmp_path_factory.mktemp("hook-environments")) + + +@pytest.mark.parametrize( + ("host", "lifecycle"), RECORDED_CASES, ids=[f"{host}:{lifecycle}" for host, lifecycle in RECORDED_CASES] +) +def test_recorded_session_start_payload_behaves_as_characterized( + tmp_path: Path, + hook_commands: dict[str, list[str]], + host: str, + lifecycle: str, +) -> None: + payload = RECORDED[host]["payloads"][lifecycle] + characterized = _case(host, BOOTSTRAP_CASE) + session_start, *later = characterized["steps"] + expected = copy.deepcopy([step["expected"] for step in characterized["steps"]]) + for request in expected[0]["requests"]: + if request["path"] == "/v1/context/bootstrap": + request["body"]["lifecycle"] = lifecycle + + observed, unscripted = _run_case( + host, + FIXTURE_DATA[host], + {**characterized, "steps": [{**session_start, "payload": payload}, *later]}, + tmp_path, + hook_commands[host], + ) + + assert payload["source"] == lifecycle + assert unscripted == [] + assert observed == expected + + +@pytest.mark.parametrize("host", list(FIXTURE_DATA)) +def test_session_start_reports_a_package_that_the_host_did_not_read( + tmp_path: Path, + hook_commands: dict[str, list[str]], + host: str, +) -> None: + injected = _case(host, BOOTSTRAP_CASE)["steps"][0] + failed_claim = _case(host, "failed-receipt-claim-is-reported-and-recorded")["steps"][0]["expected"] + + (observed,), unscripted = _run_case( + host, + FIXTURE_DATA[host], + {"steps": [injected]}, + tmp_path, + [sys.executable, "-I", "-c", _UNREAD_STDOUT, *hook_commands[host]], + ) + + assert unscripted == [] + # The Server recorded the delivery before the write failed. + assert observed["requests"] == injected["expected"]["requests"] + # The hook reports the failed delivery as a failed claim does, and Python exits without another error. + assert observed["exit_code"] == 0 + assert observed["stdout"] == [] + assert observed["stderr"] == failed_claim["stderr"] + # No receipt is kept, so the first prompt cannot report a package that the model never received. + directories = {path: entry for path, entry in injected["expected"]["state"].items() if path.endswith("/")} + assert observed["state"] == directories | failed_claim["state"] + + +def _case(host: str, case_id: str) -> dict[str, Any]: + return next(case for case in FIXTURE_DATA[host]["cases"] if case["id"] == case_id)