diff --git a/e2e/bub/tests/test_harbor_agent.py b/e2e/bub/tests/test_harbor_agent.py index 8bbd5753a9..58aaf58594 100644 --- a/e2e/bub/tests/test_harbor_agent.py +++ b/e2e/bub/tests/test_harbor_agent.py @@ -142,8 +142,13 @@ def test_harbor_acp_runner_completes_a_prompt_with_the_pinned_client_sdk(tmp_pat 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)], @@ -156,7 +161,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/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..94e09b8b14 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/claude_code_host.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. + +"""What the shared lifecycle modules need to know about Claude Code.""" + +from powercontext_lifecycle.bootstrap_state import BootstrapState +from powercontext_lifecycle.diagnostics import DiagnosticThrottle +from powercontext_lifecycle.host_profile import HostProfile + +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", + ), +) 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/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 2ce270cd72..78bb596467 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/session_start.py +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -20,72 +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 bootstrap_state import ( # noqa: E402 - clear_receipt_if_matches, - load_receipt_state, - save_receipt, -) +from claude_code_host import CLAUDE_CODE # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 -from hooks.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: @@ -103,211 +56,49 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: or source not in _LIFECYCLES ): return 0 - previous_receipt = 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: - 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): - 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) - -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) - 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) @@ -317,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 aaee2f622b..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,17 +34,17 @@ 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 CLAUDE_CODE # 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 scope_binding_errors import ( # noqa: E402 +from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 +from powercontext_lifecycle.prompt_privacy import scrub_secrets # 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 @@ -167,8 +167,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 = 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 @@ -502,7 +502,7 @@ def _emit_context_event( if key in emitted_diagnostics: return emitted_diagnostics.add(key) - if not _should_emit_diagnostic(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 new file mode 100644 index 0000000000..d787f43287 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/powercontext_lifecycle/__init__.py @@ -0,0 +1,31 @@ +# 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. 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/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_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_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/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/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/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/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/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..3ea608bcb2 --- /dev/null +++ b/integrations/codex/plugins/powercontext/codex_host.py @@ -0,0 +1,31 @@ +# 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 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 + +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", + ), +) 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/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 5ddc5bace1..296a789420 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -36,20 +36,17 @@ 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 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 codex_host import CODEX # noqa: E402 from plugin_version import PLUGIN_VERSION # noqa: E402 -from scope_binding import ( # noqa: E402 +from powercontext_lifecycle import prepared_context as _prepared_context # noqa: E402 +from powercontext_lifecycle.prompt_privacy import scrub_secrets # 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 @@ -174,8 +171,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 = 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) @@ -583,7 +580,7 @@ def _emit_context_event( if key in emitted_diagnostics: return emitted_diagnostics.add(key) - if not _should_emit_diagnostic(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 29b2e1f7da..6c2bd4448b 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -20,71 +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 bootstrap_state import ( # noqa: E402 - clear_receipt_if_matches, - load_receipt_state, - save_receipt, -) -from hooks.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, - 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: @@ -105,212 +60,50 @@ def main(settings: CodexPluginSettings | None = None) -> int: persist_session=True, ) return 0 - previous_receipt = 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: - 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): - 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) - - -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) - 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) @@ -320,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 99f60a2511..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 hooks.diagnostics import should_emit as _should_emit_diagnostic # 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 _should_emit_diagnostic(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 new file mode 100644 index 0000000000..d787f43287 --- /dev/null +++ b/integrations/codex/plugins/powercontext/powercontext_lifecycle/__init__.py @@ -0,0 +1,31 @@ +# 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. 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/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_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_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/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/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/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/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/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 209bdfdc13..dac8ec52e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -256,6 +256,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] @@ -295,8 +298,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 d83b0f57c5..1e9ba13ad1 100644 --- a/scripts/generate_integration_lifecycle.py +++ b/scripts/generate_integration_lifecycle.py @@ -37,28 +37,18 @@ _OPENCLAW = "integrations/openclaw/plugins/memory-powercontext" _OPENCODE = "integrations/opencode/plugins/powercontext" _PI = "integrations/pi/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",), } # A copy keeps the path its plugin imports, so bundled output still names the same module. TYPESCRIPT_COPIES = { @@ -69,13 +59,29 @@ f"{_PI}/src/transport.ts", ), } +# The single-file copies, each table with the directory its modules come from. +TABLES = ((SOURCE, COPIES), (TYPESCRIPT_SOURCE, TYPESCRIPT_COPIES)) +# Every copy's generated-file comment starts with one of these. +GENERATED_FROM = ( + f"# Generated from {SOURCE.relative_to(ROOT).as_posix()}/", + f"// Generated from {TYPESCRIPT_SOURCE.relative_to(ROOT).as_posix()}/", +) DRIFT_MESSAGE = ( "Generated integration lifecycle helpers drifted; edit src/powercontext_integration_lifecycle/ or " "integrations/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 or " - "TYPESCRIPT_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 or TYPESCRIPT_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:" ) @@ -107,19 +113,30 @@ def render(source: Path) -> str: def expected_copies() -> dict[Path, str]: """Return every copy the generator writes, keyed by its path.""" - tables = ((SOURCE, COPIES), (TYPESCRIPT_SOURCE, TYPESCRIPT_COPIES)) - return { + # 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(source / module) - for source, copies in tables - for module, targets in copies.items() + for source, table in TABLES + for module, targets in table.items() for target in targets } + modules = sorted(SOURCE.glob("*.py")) + copies.update({ROOT / package / module.name: render(module) for package in PACKAGE_COPIES for module in modules}) + return copies -def unlisted_copies(listed: set[Path]) -> list[Path]: - """Return committable plugin files named like a generated copy that the generator does not write.""" +def has_generated_comment(content: bytes) -> bool: + """Return whether a file has a line that starts like a generated-file comment.""" + + prefixes = tuple(prefix.encode() for prefix in GENERATED_FROM) + return any(line.startswith(prefixes) 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.""" - 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 @@ -129,8 +146,70 @@ def unlisted_copies(listed: set[Path]) -> 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-file 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. Only the file types of the sources are searched, so bundled + # JavaScript output is not. + names = {Path(target).name for _, table in TABLES for targets in table.values() for target in targets} + sources = [*SOURCE.glob("*.py"), *(TYPESCRIPT_SOURCE / module for module in TYPESCRIPT_COPIES)] + contents = {path.read_bytes() for path in sources} - {b""} + pathspecs = sorted({f":(glob){PLUGINS}/**/*{path.suffix}" for path in sources}) + packages = [ROOT / package for package in PACKAGE_COPIES] + unlisted = [] + for path in sorted(committable_files(*pathspecs) - 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 contents: + 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: @@ -138,24 +217,10 @@ def main() -> None: parser.add_argument("--check", action="store_true", help="Fail when a copy is missing, stale, or kept by hand.") args = parser.parse_args() expected = expected_copies() - 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(set(expected))] - 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") + 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..5ce43e6467 100644 --- a/src/powercontext_integration_lifecycle/__init__.py +++ b/src/powercontext_integration_lifecycle/__init__.py @@ -15,11 +15,14 @@ """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. 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/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/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 0b918468d7..80094466e6 100644 --- a/tests/claude_code_plugin/conftest.py +++ b/tests/claude_code_plugin/conftest.py @@ -26,12 +26,19 @@ 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", - "hooks.bootstrap_context", - "hooks.prepared_context", - "hooks.prompt_privacy", + "powercontext_lifecycle", + "powercontext_lifecycle.bootstrap_context", + "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", @@ -82,6 +89,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 @@ -92,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 a0d6f5fd9a..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.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.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.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.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.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.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.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.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.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 fbcef52a9a..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.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.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 4f10acb80b..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 @@ -48,6 +49,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) @@ -72,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 3c9ec73eed..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.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.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.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.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.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.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.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.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.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_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/codex_plugin/test_recall.py b/tests/codex_plugin/test_recall.py index 5c40a2aa72..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.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.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/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_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)) diff --git a/tests/test_integration_lifecycle.py b/tests/test_integration_lifecycle.py index a41af15248..74e9e7a2fc 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, 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. The TypeScript helpers are bundled into, or loaded as source by, plugins that each install their own dependencies, so a TypeScript helper may import only Node.js built-in modules. @@ -31,6 +31,7 @@ from pathlib import Path import pytest +from generate_integration_lifecycle import COPIES ROOT = Path(__file__).parents[1] SOURCE = ROOT / "src/powercontext_integration_lifecycle" @@ -41,10 +42,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)}" @@ -52,7 +55,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]} 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)