From 77700e51842acd67290272e7f1e989aabd6562a4 Mon Sep 17 00:00:00 2001 From: chenxu wang <150030641+wangchenxuya@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:21:50 -0400 Subject: [PATCH 1/7] feat(context): add bounded project bootstrap context --- .claude-plugin/marketplace.json | 2 +- docs/en/docs/integrations/claude-code.md | 30 +- docs/en/docs/integrations/codex.md | 39 +- docs/en/docs/operate/troubleshoot.md | 4 +- .../en/rfcs/1429_project_bootstrap_context.md | 70 ++++ docs/zh/docs/integrations/claude-code.md | 28 +- docs/zh/docs/integrations/codex.md | 39 +- docs/zh/docs/operate/troubleshoot.md | 4 +- .../zh/rfcs/1429_project_bootstrap_context.md | 48 +++ integrations/capabilities.toml | 13 +- .../powercontext/.claude-plugin/plugin.json | 2 +- .../plugins/powercontext/README.md | 8 + .../plugins/powercontext/bootstrap_state.py | 110 +++++ .../powercontext/claude_code_settings.py | 56 ++- .../powercontext/hooks/bootstrap_context.py | 174 ++++++++ .../plugins/powercontext/hooks/diagnostics.py | 9 +- .../plugins/powercontext/hooks/hooks.json | 18 +- .../powercontext/hooks/session_start.py | 326 +++++++++++++++ .../powercontext/hooks/user_prompt_submit.py | 23 +- .../powercontext/scripts/workspace_scope.py | 2 +- .../codex/plugins/powercontext/README.md | 10 + .../plugins/powercontext/bootstrap_state.py | 110 +++++ .../plugins/powercontext/hooks/bind_tools.py | 2 + .../powercontext/hooks/bootstrap_context.py | 174 ++++++++ .../plugins/powercontext/hooks/diagnostics.py | 9 +- .../plugins/powercontext/hooks/hooks.json | 6 +- .../plugins/powercontext/hooks/recall.py | 19 +- .../powercontext/hooks/session_binding.py | 281 ++++++++++++- .../codex/plugins/powercontext/settings.py | 22 +- .../powercontext/src/operations.generated.ts | 2 + .../powercontext/src/operations.generated.ts | 2 + .../powercontext/src/operations.generated.ts | 2 + openapi/powercontext.yaml | 232 +++++++++++ scripts/integration_manifest.py | 1 + .../builtin/persistence/bootstrap_receipts.py | 213 ++++++++++ .../builtin/persistence/tables.py | 33 +- src/powercontext/builtin/runtime/__init__.py | 14 + .../builtin/runtime/application.py | 381 ++++++++++++++++- .../builtin/runtime/bootstrap_context.py | 167 ++++++++ .../builtin/runtime/composition.py | 2 + src/powercontext/builtin/runtime/models.py | 133 ++++++ src/powercontext/client/client.py | 19 + src/powercontext/http/__init__.py | 24 ++ src/powercontext/http/_generated/models.py | 126 ++++++ .../http/_generated/operations.py | 54 +++ src/powercontext/http/_generated/schema.py | 228 ++++++++++ src/powercontext/server/app.py | 72 ++++ src/powercontext/server/mapping.py | 28 ++ .../builtin/runtime/test_bootstrap_context.py | 38 ++ tests/claude_code_plugin/conftest.py | 11 + tests/claude_code_plugin/test_bootstrap.py | 316 ++++++++++++++ tests/claude_code_plugin/test_contract.py | 11 +- tests/codex_plugin/conftest.py | 1 + tests/codex_plugin/test_bootstrap.py | 314 ++++++++++++++ tests/codex_plugin/test_contract.py | 6 +- tests/e2e/test_access_control_regressions.py | 90 ++++ tests/e2e/test_bootstrap_context.py | 394 ++++++++++++++++++ .../e2e/test_bootstrap_host_service_chain.py | 225 ++++++++++ tests/test_api_contract.py | 38 +- 59 files changed, 4772 insertions(+), 43 deletions(-) create mode 100644 docs/en/rfcs/1429_project_bootstrap_context.md create mode 100644 docs/zh/rfcs/1429_project_bootstrap_context.md create mode 100644 integrations/claude-code/plugins/powercontext/bootstrap_state.py create mode 100644 integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py create mode 100644 integrations/claude-code/plugins/powercontext/hooks/session_start.py create mode 100644 integrations/codex/plugins/powercontext/bootstrap_state.py create mode 100644 integrations/codex/plugins/powercontext/hooks/bootstrap_context.py create mode 100644 src/powercontext/builtin/persistence/bootstrap_receipts.py create mode 100644 src/powercontext/builtin/runtime/bootstrap_context.py create mode 100644 tests/builtin/runtime/test_bootstrap_context.py create mode 100644 tests/claude_code_plugin/test_bootstrap.py create mode 100644 tests/codex_plugin/test_bootstrap.py create mode 100644 tests/e2e/test_bootstrap_context.py create mode 100644 tests/e2e/test_bootstrap_host_service_chain.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 678e29f61a..6c6cf26158 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "powercontext", "source": "./integrations/claude-code/plugins/powercontext", "description": "Restore project memory and transfer current work from Claude Code", - "version": "0.1.2", + "version": "0.1.3", "category": "Productivity" } ] diff --git a/docs/en/docs/integrations/claude-code.md b/docs/en/docs/integrations/claude-code.md index 5ab715d7b6..9e2e92eb10 100644 --- a/docs/en/docs/integrations/claude-code.md +++ b/docs/en/docs/integrations/claude-code.md @@ -56,7 +56,7 @@ powercontext doctor claude-code claude ``` -Use `/hooks` to confirm the `UserPromptSubmit` Hook and `/mcp` to confirm the `powercontext` Server. +Use `/hooks` to confirm the `SessionStart` and `UserPromptSubmit` Hooks and `/mcp` to confirm the `powercontext` Server. These are separate capture/recall and explicit-tool connections; both must work. `doctor claude-code` primarily checks installation state. Complete the [Source and Topic check](../get-started/quickstart.md#4-verify-topic-memory-with-ordinary-conversation) to verify memory behavior. @@ -82,6 +82,31 @@ For each user prompt, the Hook: 3. strictly validates `powercontext.prepared-context.v1` and injects it unchanged through `additionalContext`; 4. independently captures the prompt as ordinary Content Source evidence. +Bootstrap context is a separate, opt-in `SessionStart` path. Enable it before launching Claude Code: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT=true +claude +``` + +On `startup`, `resume`, `clear`, `compact`, and `fork`, the fixed `powercontext.scope-bootstrap.v1` profile can inject +at most six active Memory entries explicitly tagged `bootstrap-context`, considering the current Scope before at most eight direct +Context References. It never scans all Memory, Sources, transcripts, pending Candidates, or generated instructions; +secret-like Memory kinds are excluded. The content is untrusted history and cannot override current instructions. + +The default 4096-byte budget can be set from 512 through 8192 bytes with +`POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES`. No Handoff is selected automatically. Select one exact committed Handoff +from the current Scope only when it is intended for the new Session: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +Use the Memory-entry tag API to add `bootstrap-context` only to reviewed decisions, constraints, objectives, verified +state, or repository guidance. The plugin stores a content-free receipt under `${CLAUDE_PLUGIN_DATA}`; successful +delivery lets the first ordinary query suppress only the exact Memory versions already injected. Revised versions remain +eligible. Preparation, validation, and receipt failures fail open and inject nothing. + The Source pipeline may later extract Memory when a generation model is configured. Prompt capture does not call `remember_memory`, and the Hook never labels an ordinary prompt as `task-outcome`. @@ -222,6 +247,9 @@ run with `--keep-data`. | `POWERCONTEXT_CLAUDE_ALLOW_INSECURE_HTTP` | `false` | Explicitly permit non-loopback plaintext HTTP for PowerContext requests | | `POWERCONTEXT_CLAUDE_SCOPE_ID` | unset | Override durable bindings and the Server default Scope | | `POWERCONTEXT_CLAUDE_AUTHORIZATION` | unset | Complete `Bearer ` header for Hook and MCP requests | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT` | `false` | Inject the curated bootstrap profile on supported `SessionStart` events | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` | `4096` | Bootstrap package limit; valid values are 512 through 8192 bytes | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF` | unset | JSON identity of one exact committed Handoff, for example `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS` | `true` | Capture user prompts as ordinary Source evidence | | `POWERCONTEXT_CLAUDE_FLUSH_ON_CAPTURE` | `false` | Wait for Source processing after capture | | `POWERCONTEXT_CLAUDE_REQUEST_TIMEOUT_SECONDS` | `1` | Per-request Hook timeout | diff --git a/docs/en/docs/integrations/codex.md b/docs/en/docs/integrations/codex.md index 33d1d54ff1..5682784a96 100644 --- a/docs/en/docs/integrations/codex.md +++ b/docs/en/docs/integrations/codex.md @@ -25,7 +25,7 @@ The command adds the repository as a Codex marketplace, installs the PowerContex directory. It is safe to run again. Pass the same `--ref` used to install the PowerContext tool. Open a new Codex session after setup. Use `/hooks` to inspect and, when prompted, trust the PowerContext -`UserPromptSubmit` hook. +`SessionStart` and `UserPromptSubmit` hooks. ## Understand automatic recall, Memory, and Handoff @@ -71,6 +71,40 @@ another task, session, or model. It must be explicitly prepared, inspected, and a few Memory entries. Read [Memory and Handoff](../workflows/memory-and-handoff.md) for the boundary and [Hand off work in Codex](../workflows/handoff-with-codex.md) for the procedure. +## Opt in to Session bootstrap context + +Bootstrap context is off by default. To load a small, deterministic package before Codex's first prompt on +`startup`, `resume`, `clear`, or `compact`, start Codex with: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true +codex +``` + +The fixed `powercontext.scope-bootstrap.v1` profile includes at most six active Memory entries explicitly tagged +`bootstrap-context`, considering the current Scope before at most eight direct Context References. It never scans all Memory, +Sources, transcripts, pending Candidates, or generated instructions. Secret-like Memory kinds are excluded. Historical +text is marked as untrusted and cannot override current user, developer, system, or repository instructions. + +The default package limit is 4096 UTF-8 bytes. It can be set from 512 through 8192 bytes: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES=6144 +``` + +No Handoff is selected automatically. To add one exact committed Handoff from the current Scope, provide its immutable +identity before starting Codex: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +Use the Memory-entry tag API to add `bootstrap-context` only to reviewed decisions, constraints, objectives, verified +state, or repository guidance suitable for every new Session. A content-free delivery receipt makes retries idempotent; +after successful injection, the first ordinary query sends that receipt so the Runtime suppresses only the exact Memory +versions already delivered. A revised version remains eligible for recall. Preparation, validation, and receipt failures +all fail open and inject nothing. + ## Choose standard context text Set `POWERCONTEXT_CODEX_CONTEXT_ASSEMBLY` to a JSON assembly object before starting Codex. This opts into @@ -201,6 +235,9 @@ Complete the [Source, topic evolution, and cross-session recall check](../get-st | `POWERCONTEXT_CODEX_ALLOW_INSECURE_HTTP` | `false` | Explicitly permit non-loopback plaintext HTTP for hooks | | `POWERCONTEXT_CODEX_SCOPE_ID` | unset | Explicitly select an existing Scope instead of resolving bindings and the Server default | | `POWERCONTEXT_CODEX_AUTHORIZATION` | unset | Complete `Bearer ` header; setup persists it in the Windows user environment for Desktop | +| `POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT` | `false` | Inject the curated bootstrap profile on supported `SessionStart` events | +| `POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES` | `4096` | Bootstrap package limit; valid values are 512 through 8192 bytes | +| `POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF` | unset | JSON identity of one exact committed Handoff, for example `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CODEX_CAPTURE_PROMPTS` | `true` | Capture user prompts as Source evidence | | `POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE` | `false` | Wait for Source processing after capture | | `POWERCONTEXT_CODEX_REQUEST_TIMEOUT_SECONDS` | `1` | Per-request hook timeout | diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 07d931b60d..217165351f 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -262,7 +262,7 @@ so the previous database remains available for recovery: exported `pc_scopes` data. If the source predates the three Skill lifecycle tables (`pc_skill_packages`, `pc_agent_skill_targets`, and `pc_skill_publications`), the Profile tables, `pc_topic_memory_work_budgets`, or - `pc_receipt_migration_review`, remove the absent tables from their respective layers. + `pc_receipt_migration_review`, or `pc_context_bootstrap_receipts`, remove the absent tables from their respective layers. When a work-budget table exists, restore it together with Cursors so failure allowances survive the migration. Layer 1 contains parents and tables without foreign keys: @@ -277,7 +277,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` diff --git a/docs/en/rfcs/1429_project_bootstrap_context.md b/docs/en/rfcs/1429_project_bootstrap_context.md new file mode 100644 index 0000000000..1c9322f926 --- /dev/null +++ b/docs/en/rfcs/1429_project_bootstrap_context.md @@ -0,0 +1,70 @@ +# RFC 1429: Bounded Scope Bootstrap Context + +- Status: Implemented +- Issue: [#1429](https://github.com/oceanbase/powercontext/issues/1429) +- Related RFCs: [Scope organization and Agent integration](1345_scope_organization_and_agent_integration.md), [Memory layer](0014_memory_layer_design.md), [Handoff Artifact](0048_handoff_artifact.md), and [Artifact tags](1467_artifact_tags.md) + +## Summary + +PowerContext may provide a small, explicitly curated context package before the first real query of a supported Agent lifecycle. The package is opt-in, deterministic, byte-bounded, cited to immutable content, treated as untrusted history, and delivered at most once for a host-provided stable lifecycle identity. Delivery failure never prevents the Agent from starting. + +Issue #1429 used the product terms Project and Workstream. RFC 1345 subsequently made Scope the only ownership and routing boundary and direct Context References the authorized read set. This RFC therefore implements the requested behavior for one resolved current Scope plus its direct Context References; it does not reintroduce Project or Workstream catalogs. + +## Goals + +- Prepare useful context at `startup`, `resume`, `clear`, `compact`, `restore`, or `fork` without manufacturing a search query. +- Include only reviewed, explicitly selected material and preserve exact authoritative references and content digests. +- Bound work, item count, per-item text, and final UTF-8 bytes without model inference. +- Persist content-free delivery receipts with `pending`, `injected`, `skipped`, or `failed` state. +- Let the first ordinary query identify the last injected receipt so identical Memory entry versions are not returned twice. +- Support both Codex and Claude Code through their maintained `SessionStart` hooks. + +## Non-goals + +Bootstrap is not full Memory recall, a transcript summary, a new instruction tier, or a replacement for `prepare_context`. It does not create or infer a Scope binding. It does not read raw Sources, pending candidates, generated Profile drafts, arbitrary Artifact families, environment variables, repository files, or conversation transcripts. + +## Eligibility profile + +The initial profile is `powercontext.scope-bootstrap.v1`. + +Eligible content is deliberately narrow: + +1. Up to six active current Memory entries explicitly tagged `bootstrap-context`. The current Scope is considered first, followed by at most eight direct Context References in canonical Scope order. At most 32 tagged entries are inspected per participating Scope. +2. At most one committed Handoff Revision supplied by the caller as an exact `{family, artifact_id, revision}` reference in the current Scope. “Latest Handoff” is never selected implicitly. + +The `bootstrap-context` tag is an operator review decision, not an authorization mechanism. Operators must not tag credentials, secrets, personal data, generated instructions that have not been reviewed, or material that should not be injected automatically. The Runtime additionally rejects entries whose kind identifies secret, credential, password, token, or private-key material. Current authorization is still checked for every participating Scope. + +The package renderer includes a prominent untrusted-history policy. Current system and developer instructions, the current user request, repository rules, and live validation always take precedence. Historical Markdown is rendered as quoted data rather than executable instructions. + +## HTTP contract + +`POST /v1/context/bootstrap` accepts the resolved `scope_id`, opt-in `enabled`, profile, lifecycle, integration name, optional stable `event_id`, byte limit, and optional exact Handoff reference. The default is disabled. It returns `powercontext.bootstrap-context.v1` with `ready`, `empty`, or `skipped` status, the bounded content when ready, its digest, exact item references and digests, truncation information, and a delivery receipt. + +A ready package has a `pending` receipt. Before emitting hook output, the host calls `POST /v1/context/bootstrap/receipts` with `injected` or `failed`. A host must claim the `pending`→`injected` transition successfully before writing `additionalContext`; a second `injected` claim is rejected even if the stored state is already `injected`. If the claim fails, the host emits no bootstrap content. Disabled and empty requests produce `skipped` receipts; a terminal event retry also returns `skipped` without a body. + +Receipts store no query, content body, transcript path, raw host event identifier, or host error text. They store only bounded identity metadata, exact content references and digests, byte counts, and state. A supplied event identifier is hashed with the Scope, integration, and lifecycle before persistence. Repeating the same stable event returns the existing result while pending and returns `skipped` after a terminal outcome. If opt-in is revoked while an event is pending, that receipt is terminally skipped without reading Context References. Hosts that expose no stable event identity still get per-invocation receipts but cannot claim restart idempotency. + +`PrepareContextRequest` optionally accepts `bootstrap_receipt_id`. When that receipt belongs to the same Scope and is `injected`, ordinary recall removes only the exact Memory entry versions listed by the receipt. Revised versions remain eligible. An absent, stale, foreign, pending, skipped, or failed receipt is ignored so recall remains fail-open. + +## Rendering and bounds + +The default final limit is 4,096 bytes and the hard maximum is 8,192 bytes. There are at most seven items: one exact Handoff followed by six curated Memory entries. Each item body is capped at 2,000 bytes and truncated only at Unicode character boundaries. Complete Scope, Artifact Revision, Memory entry/version, digest, and truncation metadata remain visible. Items that cannot retain a meaningful body and their citation within the remaining budget are omitted. + +Selection uses exact tag lookup and immutable reads only. It does not embed, rerank, generate, or expand. The participating Scope count, tagged-entry scan, selected item count, per-item text, and final output are all bounded. Ordering and rendering are deterministic for the same exact inputs. + +## Host behavior + +Codex and Claude Code resolve Scope through their existing binding service, then request bootstrap context from `SessionStart`. Their supported lifecycle sources stay distinct in the request and receipt. Both integrations are disabled by default, enforce one wall-clock HTTP budget, validate the complete response schema, persist only the latest receipt ID in the host's plugin data directory, and fail open on timeout, authentication failure, unavailable or older servers, and invalid responses. + +The subsequent `UserPromptSubmit` uses normal query-based recall and passes the saved receipt ID solely as an exact-version deduplication hint. Prompt capture behavior is unchanged. + +## Security and observability + +- Bootstrap never expands Scope authorization. The current Scope and every direct Context Reference considered by the bounded profile require `scope.read`. +- Package text is historical data, not authority. Exact citations make every included claim inspectable. +- Logs and diagnostics use outcome codes and counts only. They do not include package bodies, queries, identifiers supplied by the host, or errors returned by external systems. +- Database unavailability, malformed historical content, hook timeouts, and unsupported server versions all degrade to no injected context. + +## Acceptance + +Implementation is complete when contract and runtime tests cover opt-in defaults, all lifecycle values, exact Handoff selection, curated Memory ordering, Unicode byte bounds, secret-kind exclusion, authorization across Context References, receipt retry and transition semantics, first-query exact-version deduplication, and content-free storage; and both Codex and Claude Code tests cover successful injection, disabled/empty paths, state handoff to the first query, invalid responses, timeouts, and HTTP failures without blocking the host. diff --git a/docs/zh/docs/integrations/claude-code.md b/docs/zh/docs/integrations/claude-code.md index e8320ea989..77835a921c 100644 --- a/docs/zh/docs/integrations/claude-code.md +++ b/docs/zh/docs/integrations/claude-code.md @@ -52,7 +52,7 @@ powercontext doctor claude-code claude ``` -使用 `/hooks` 确认 `UserPromptSubmit` Hook,使用 `/mcp` 确认 `powercontext` Server。 +使用 `/hooks` 确认 `SessionStart` 和 `UserPromptSubmit` Hook,使用 `/mcp` 确认 `powercontext` Server。 两者分别负责采集/召回和显式工具调用,必须都连接成功。`doctor claude-code` 主要检查安装状态, 完整记忆还需完成[Source 与主题验收](../get-started/quickstart.md#4-用普通对话验收-topic-memory)。 @@ -77,6 +77,29 @@ MCP endpoint 来自 setup 保存的 `server_url`,Hook 可被 `POWERCONTEXT_CLA 3. 严格校验 `powercontext.prepared-context.v1`,再通过 `additionalContext` 原样注入; 4. 独立地将 prompt 采集为普通 Content Source 证据。 +启动上下文是独立且默认关闭的 `SessionStart` 路径。在启动 Claude Code 前显式启用: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT=true +claude +``` + +在 `startup`、`resume`、`clear`、`compact` 和 `fork` 事件中,固定 profile +`powercontext.scope-bootstrap.v1` 最多注入 6 条显式标记为 `bootstrap-context` 的 active Memory;当前 Scope +优先,其次是最多 8 个直接 Context References。它不会扫描全部 Memory、Source、transcript、pending Candidate 或生成的 +instruction,也会排除类型类似 secret 的 Memory。内容始终是不可信历史,不能覆盖当前指令。 + +默认预算为 4096 bytes,可通过 `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` 在 512 到 8192 之间调整。系统不会 +自动选择 Handoff;只有确实要交给新 Session 时,才显式指定当前 Scope 中一个 exact committed Handoff: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +通过 Memory entry tag API,只把经过审阅的决策、约束、目标、已验证状态或仓库指南标记为 `bootstrap-context`。 +插件在 `${CLAUDE_PLUGIN_DATA}` 下只保存不含正文的 receipt;成功注入后,第一条普通查询仅排除已注入的 exact Memory +versions,新修订版本仍可召回。准备、校验或回执失败时都会 fail open,不注入任何内容。 + 配置 generation model 后,Source pipeline 可能进一步提取 Memory。提示词采集不会调用 `remember_memory`, Hook 也不会把普通 prompt 标记为 `task-outcome`。 @@ -207,6 +230,9 @@ claude plugin marketplace remove powercontext | `POWERCONTEXT_CLAUDE_ALLOW_INSECURE_HTTP` | `false` | 显式允许 PowerContext 请求使用非环回明文 HTTP | | `POWERCONTEXT_CLAUDE_SCOPE_ID` | 未设置 | 覆盖持久 binding 和 Server 默认 Scope | | `POWERCONTEXT_CLAUDE_AUTHORIZATION` | 未设置 | Hook 与 MCP 请求使用的完整 `Bearer ` header | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT` | `false` | 在支持的 `SessionStart` 事件中注入精选启动 profile | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` | `4096` | 启动上下文总预算;有效范围为 512 到 8192 bytes | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF` | 未设置 | 一个 exact committed Handoff 的 JSON 身份,例如 `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS` | `true` | 把用户 prompt 采集为普通 Source 证据 | | `POWERCONTEXT_CLAUDE_FLUSH_ON_CAPTURE` | `false` | 采集后等待 Source 处理 | | `POWERCONTEXT_CLAUDE_REQUEST_TIMEOUT_SECONDS` | `1` | Hook 单次请求超时 | diff --git a/docs/zh/docs/integrations/codex.md b/docs/zh/docs/integrations/codex.md index b3a549cead..ba14b5cd11 100644 --- a/docs/zh/docs/integrations/codex.md +++ b/docs/zh/docs/integrations/codex.md @@ -24,8 +24,8 @@ powercontext doctor codex 该命令会把仓库添加为 Codex marketplace,安装 PowerContext 插件,并创建用户数据目录。重复执行是安全的。 `--ref` 应与安装 PowerContext 工具时使用的 ref 一致。 -配置完成后开启新的 Codex 会话。通过 `/hooks` 查看 PowerContext `UserPromptSubmit` Hook,并在收到提示时 -授予信任。 +配置完成后开启新的 Codex 会话。通过 `/hooks` 查看 PowerContext `SessionStart` 和 `UserPromptSubmit` Hook, +并在收到提示时授予信任。 ## 理解自动恢复、Memory 和 Handoff @@ -65,6 +65,38 @@ Codex 开始分析提示词前,Hook 只调用一次 `POST /v1/context/prepare` Memory 用于长期保存可复用的决策、约束和状态;Handoff 用于临时移交当前任务,不能用几条 Memory 替代。概念边界见 [理解 Memory 和 Handoff](../workflows/memory-and-handoff.md),操作步骤见[在 Codex 中交接工作](../workflows/handoff-with-codex.md)。 +## 选择启用 Session 启动上下文 + +启动上下文默认关闭。若要在 Codex 的 `startup`、`resume`、`clear` 或 `compact` 事件中,于第一条 prompt 前加载一份 +小而确定的上下文包,请用以下环境启动 Codex: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true +codex +``` + +固定 profile `powercontext.scope-bootstrap.v1` 最多包含 6 条显式标记为 `bootstrap-context` 的 active Memory; +顺序是当前 Scope 优先,其次是最多 8 个直接 Context References。它不会扫描全部 Memory、Source、transcript、pending +Candidate 或生成的 instruction,也会排除类型类似 secret 的 Memory。所有历史文本都标记为不可信,不能覆盖当前的 +用户、developer、system 或仓库指令。 + +默认总预算是 4096 UTF-8 bytes,可在 512 到 8192 之间调整: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES=6144 +``` + +系统不会自动选择 Handoff。若要加入当前 Scope 中一个 exact committed Handoff,请在启动 Codex 前提供其不可变身份: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +通过 Memory entry tag API,只把经过审阅、适合每个新 Session 的决策、约束、目标、已验证状态或仓库指南标记为 +`bootstrap-context`。不含正文的 delivery receipt 让重试保持幂等;成功注入后,第一条普通查询会携带该 receipt, +Runtime 仅排除已经注入的 exact Memory versions,新修订版本仍可正常召回。准备、校验或回执失败时都会 fail open, +不注入任何内容。 + ## 选择标准上下文文本 在启动 Codex 前,将 `POWERCONTEXT_CODEX_CONTEXT_ASSEMBLY` 设置为 JSON 组装对象,即可选择 Memory/Experience @@ -187,6 +219,9 @@ MCP 显示 connected 也不等于 Source 已采集。 | `POWERCONTEXT_CODEX_ALLOW_INSECURE_HTTP` | `false` | 显式允许 Hook 使用非环回明文 HTTP | | `POWERCONTEXT_CODEX_SCOPE_ID` | 未设置 | 显式选择一个已存在 Scope,不再解析 binding 和 Server 默认 Scope | | `POWERCONTEXT_CODEX_AUTHORIZATION` | 未设置 | 完整 `Bearer ` header;setup 会为 Desktop 持久化到 Windows 用户环境 | +| `POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT` | `false` | 在支持的 `SessionStart` 事件中注入精选启动 profile | +| `POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES` | `4096` | 启动上下文总预算;有效范围为 512 到 8192 bytes | +| `POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF` | 未设置 | 一个 exact committed Handoff 的 JSON 身份,例如 `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CODEX_CAPTURE_PROMPTS` | `true` | 把用户提示词采集为 Source 证据 | | `POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE` | `false` | 采集后等待 Source 处理 | | `POWERCONTEXT_CODEX_REQUEST_TIMEOUT_SECONDS` | `1` | Hook 单次请求超时 | diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index 4aba608a5f..c6cfb51c31 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -251,7 +251,7 @@ collation,但不会包含数据库 URL 或凭据。 删除它。由于 `pc_scopes.parent_scope_id` 自引用 `pc_scopes`,导出的 `pc_scopes` 数据必须让祖先 Scope 记录排在 后代记录之前。 源数据库早于三张 Skill 生命周期表(`pc_skill_packages`、`pc_agent_skill_targets` 和 - `pc_skill_publications`)、Profile 表或 `pc_receipt_migration_review` 时,应从对应层删除缺失的表。 + `pc_skill_publications`)、Profile 表、`pc_receipt_migration_review` 或 `pc_context_bootstrap_receipts` 时,应从对应层删除缺失的表。 旧版本没有 `pc_topic_memory_work_budgets` 时可从清单移除该表;若该表存在,必须与 Cursor 一起恢复, 保留已消耗的失败额度,避免迁移后重置累计成本。 @@ -268,7 +268,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` diff --git a/docs/zh/rfcs/1429_project_bootstrap_context.md b/docs/zh/rfcs/1429_project_bootstrap_context.md new file mode 100644 index 0000000000..39f737b81c --- /dev/null +++ b/docs/zh/rfcs/1429_project_bootstrap_context.md @@ -0,0 +1,48 @@ +# RFC 1429:有界 Scope 启动上下文 + +- 状态:已实现 +- Issue:[#1429](https://github.com/oceanbase/powercontext/issues/1429) +- 相关 RFC:[Scope 组织与 Agent 集成](1345_scope_organization_and_agent_integration.md)、[Memory 层](0014_memory_layer_design.md)、[Handoff Artifact](0048_handoff_artifact.md)、[Artifact 标签](1467_artifact_tags.md) + +## 摘要 + +PowerContext 可以在受支持的 Agent 生命周期第一次真实查询之前,提供一个小型、显式策展的上下文包。该能力默认关闭;输出确定、按 UTF-8 字节限额、引用不可变内容,并始终作为不可信历史处理。当宿主提供稳定生命周期标识时,同一边界最多交付一次;任何失败都不得阻止 Agent 启动。 + +Issue #1429 使用了 Project 和 Workstream 术语。后续 RFC 1345 已确定 Scope 是唯一的所有权与路由边界,直接 Context Reference 是授权读取集合。因此本 RFC 在一个已解析的当前 Scope 及其直接 Context Reference 上实现需求,不重新引入 Project 或 Workstream 目录。 + +## 内容资格 + +首个画像版本为 `powercontext.scope-bootstrap.v1`,只允许: + +1. 最多六条当前有效、且被显式标记 `bootstrap-context` 的 Memory 条目。先选择当前 Scope,再按 Scope 的规范顺序选择最多八个直接 Context Reference;每个参与 Scope 最多检查 32 条带标签的条目。 +2. 调用方在当前 Scope 中显式指定的一条精确、已提交 Handoff Revision。服务端不会隐式选择“最新 Handoff”。 + +标签表示运维者已经审核其适合自动注入,但不是授权机制。不得标记凭证、秘密、个人数据、未经审核的生成指令或其他不应自动注入的内容。Runtime 还会排除 kind 表明为 secret、credential、password、token 或 private-key 的条目。每个参与 Scope 仍须通过当前授权检查。 + +完整 transcript、全部 Memory、待审核候选、原始 Source 窗口、生成中的 Profile 以及任意其他 Artifact Family 均不符合资格。选择过程不调用 embedding、rerank、生成或扩展。 + +## API 与交付收据 + +`POST /v1/context/bootstrap` 接收已解析的 `scope_id`、显式开关、画像、生命周期、集成名称、可选稳定 `event_id`、字节上限和可选精确 Handoff 引用。返回 `powercontext.bootstrap-context.v1`,状态为 `ready`、`empty` 或 `skipped`,并携带有界正文、包摘要、精确引用、截断信息和交付收据。 + +`ready` 响应的收据为 `pending`。宿主必须先通过 `POST /v1/context/bootstrap/receipts` 一次性认领 `pending`→`injected` 状态迁移,成功后才能输出 `additionalContext`;第二次认领即使已存状态是 `injected` 也会被拒绝。确认失败时不注入。宿主也可记录 `failed`。关闭和无合格内容会产生 `skipped` 收据;终态事件重试只返回不含正文的 `skipped` 响应。 + +收据不保存查询、正文、transcript 路径、原始宿主事件 ID 或宿主错误文本,只保存有界身份元数据、精确内容引用与摘要、字节数和状态。事件 ID 在持久化前与 Scope、集成及生命周期一同哈希。稳定事件重试在 `pending` 时重建同一精确包,终态后返回 `skipped`;若待交付期间关闭开关,收据会直接终结为跳过且不读取 Context Reference。宿主不提供稳定事件标识时,不宣称具备跨重启幂等性。 + +`PrepareContextRequest` 可携带 `bootstrap_receipt_id`。只有当收据属于相同 Scope 且状态为 `injected` 时,普通查询召回才去掉收据中完全相同的 Memory 条目版本;后续修订版仍可召回。缺失、过期、跨 Scope 或非注入收据均被忽略,保持 fail-open。 + +## 限额与信任边界 + +默认输出 4,096 字节,硬上限 8,192 字节;最多一条 Handoff 加六条 Memory。参与 Scope 数量、带标签条目扫描数、选中项数、单项正文和最终输出均有固定上限。单项正文最多 2,000 字节,只在 Unicode 字符边界截断,Scope、Artifact Revision、Memory 条目与版本、内容摘要及截断标志必须完整保留。无法在剩余预算中同时保留有意义正文与引用的项目会被省略。 + +输出明确声明为不可信历史数据。当前 system/developer 指令、用户请求、仓库规则和实时验证始终优先;历史 Markdown 以引用数据呈现,不成为新的指令层。 + +## 宿主集成与失败策略 + +Codex 和 Claude Code 复用现有 Scope 绑定,在 `SessionStart` 请求启动包,并保留 `startup`、`resume`、`clear`、`compact`、`restore` 和 `fork` 的独立来源语义。两端均默认关闭,使用统一的绝对 HTTP 时间预算,严格校验响应,只在插件持久数据目录保存最近的收据 ID,并在超时、鉴权失败、无权限、服务不可用、旧版本服务或非法响应时不阻断宿主。失败与空结果使用不含正文的诊断事件区分。 + +随后第一次 `UserPromptSubmit` 仍执行普通基于查询的召回,只把该收据 ID 作为精确版本去重提示;现有 prompt 捕获行为不变。 + +## 验收 + +契约与 Runtime 测试必须覆盖:默认关闭、全部生命周期、精确 Handoff、策展 Memory 顺序、Unicode 字节限额、秘密 kind 排除、Context Reference 授权、收据重试与状态迁移、首查询精确版本去重,以及数据库不保存正文。Codex 与 Claude Code 测试必须覆盖成功注入、关闭/空结果、向首查询传递收据、非法响应、超时和 HTTP 失败均不阻塞宿主。 diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index d78fffda62..8a89b560ad 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -143,7 +143,7 @@ id = "prompt-hooks" probe = "json_prompt_hook" [[toolsets.tools]] id = "codex:SessionStart" -capabilities = ["persistent_scope_binding"] +capabilities = ["context_injection", "persistent_scope_binding"] [[toolsets.tools]] id = "codex:PreToolUse" capabilities = ["persistent_scope_binding"] @@ -154,6 +154,9 @@ capabilities = ["source_capture", "context_injection", "flush_or_checkpoint"] id = "codex:Stop" non_profile_reason = "Reports scoped recall-token estimates after completed turns." [[toolsets.tools]] +id = "claude-code:SessionStart" +capabilities = ["context_injection"] +[[toolsets.tools]] id = "claude-code:UserPromptSubmit" capabilities = ["source_capture", "context_injection", "flush_or_checkpoint"] [[toolsets.tools]] @@ -615,9 +618,9 @@ capabilities = ["memory_read", "memory_write", "source_capture", "context_inject profiles = ["minimal", "recommended"] toolsets = ["server-mcp", "prompt-hooks"] [integrations.evidence] -implementation = ["integrations/codex/plugins/powercontext/.mcp.json", "integrations/codex/plugins/powercontext/hooks/recall.py"] +implementation = ["integrations/codex/plugins/powercontext/.mcp.json", "integrations/codex/plugins/powercontext/hooks/session_binding.py", "integrations/codex/plugins/powercontext/hooks/recall.py"] documentation = ["docs/en/docs/integrations/codex.md", "docs/zh/docs/integrations/codex.md"] -tests = ["tests/codex_plugin/test_contract.py", "tests/e2e/test_codex_service_chain.py"] +tests = ["tests/codex_plugin/test_bootstrap.py", "tests/codex_plugin/test_contract.py", "tests/e2e/test_bootstrap_host_service_chain.py", "tests/e2e/test_codex_service_chain.py"] [[integrations]] id = "claude-code" @@ -627,9 +630,9 @@ capabilities = ["memory_read", "memory_write", "source_capture", "context_inject profiles = ["minimal", "recommended"] toolsets = ["server-mcp", "prompt-hooks"] [integrations.evidence] -implementation = ["integrations/claude-code/plugins/powercontext/.mcp.json", "integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py"] +implementation = ["integrations/claude-code/plugins/powercontext/.mcp.json", "integrations/claude-code/plugins/powercontext/hooks/session_start.py", "integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py"] documentation = ["docs/en/docs/integrations/claude-code.md", "docs/zh/docs/integrations/claude-code.md"] -tests = ["tests/claude_code_plugin/test_contract.py", "tests/e2e/test_claude_code_service_chain.py"] +tests = ["tests/claude_code_plugin/test_bootstrap.py", "tests/claude_code_plugin/test_contract.py", "tests/e2e/test_bootstrap_host_service_chain.py", "tests/e2e/test_claude_code_service_chain.py"] [[integrations]] id = "dsh" diff --git a/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json b/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json index c740564a68..cf061339d3 100644 --- a/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json +++ b/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "powercontext", - "version": "0.1.2", + "version": "0.1.3", "description": "Restore project memory and transfer current work through PowerContext.", "author": { "name": "PowerContext Team", diff --git a/integrations/claude-code/plugins/powercontext/README.md b/integrations/claude-code/plugins/powercontext/README.md index 11794f9831..46bcd797cd 100644 --- a/integrations/claude-code/plugins/powercontext/README.md +++ b/integrations/claude-code/plugins/powercontext/README.md @@ -16,6 +16,14 @@ Automatic recall and prompt capture run on `UserPromptSubmit`. The plugin never reads the Claude Code transcript or captures Claude's final response in v1. Prompt Sources are evidence and are never marked as `task-outcome` by the hook. +An opt-in `SessionStart` hook can inject one bounded curated package before the +first prompt on startup, resume, clear, compact, or fork. Enable it with +`POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT=true`. Only active Memory entries tagged +`bootstrap-context` and one explicitly selected exact committed Handoff are +eligible. `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` controls the 512–8192-byte +budget, and `POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF` supplies the Handoff JSON +identity. The plugin persists only a content-free delivery receipt. + Scope is resolved by the Server from an explicit override, the current session or workspace binding, and finally the Server default. Bindings let multiple agents share the same Scope without deriving identities locally. diff --git a/integrations/claude-code/plugins/powercontext/bootstrap_state.py b/integrations/claude-code/plugins/powercontext/bootstrap_state.py new file mode 100644 index 0000000000..643477ede2 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/bootstrap_state.py @@ -0,0 +1,110 @@ +# 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 contextlib import 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: + path = _state_path(session_id) + if path is None: + return 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 + receipt_id = value.get("receipt_id") + if ( + value.get("version") != 1 + or value.get("scope_id") != scope_id + or not isinstance(receipt_id, str) + or not 1 <= len(receipt_id) <= 64 + or not receipt_id.isascii() + or not receipt_id.isprintable() + ): + return None + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + return 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() + 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: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + os.close(descriptor) + with suppress(FileNotFoundError): + temporary.unlink() + + +def clear_receipt(session_id: str | None) -> None: + path = _state_path(session_id) + if path is None: + return + try: + if not path.is_symlink(): + path.unlink(missing_ok=True) + except OSError: + return + + +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_settings.py b/integrations/claude-code/plugins/powercontext/claude_code_settings.py index ed968b2ce0..701694b86a 100644 --- a/integrations/claude-code/plugins/powercontext/claude_code_settings.py +++ b/integrations/claude-code/plugins/powercontext/claude_code_settings.py @@ -45,6 +45,32 @@ def _is_loopback_host(host: str) -> bool: return False +@dataclass(frozen=True, slots=True) +class BootstrapHandoff: + """One explicitly selected exact committed Handoff.""" + + artifact_id: str + revision: int + family: str = "handoff" + + def __post_init__(self) -> None: + if ( + self.family != "handoff" + or not self.artifact_id + or self.artifact_id != self.artifact_id.strip() + or len(self.artifact_id) > 128 + or not self.artifact_id.isascii() + or not self.artifact_id.isprintable() + or not isinstance(self.revision, int) + or isinstance(self.revision, bool) + or self.revision < 1 + ): + raise ValueError("invalid bootstrap Handoff identity") # noqa: TRY003 + + def as_request(self) -> dict[str, object]: + return {"family": self.family, "artifact_id": self.artifact_id, "revision": self.revision} + + @dataclass(frozen=True, slots=True) class ClaudeCodePluginSettings: """Configuration loaded once by a plugin entry point.""" @@ -53,6 +79,9 @@ class ClaudeCodePluginSettings: authorization: str | None = None scope_id: str | None = None context_assembly: dict[str, object] | None = None + bootstrap_context: bool = False + bootstrap_max_bytes: int = 4096 + bootstrap_handoff: BootstrapHandoff | None = None capture_prompts: bool = True flush_on_capture: bool = False request_timeout_seconds: float = 1.0 @@ -81,6 +110,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "scope_id", _optional_text(self.scope_id)) if self.request_timeout_seconds <= 0 or self.http_budget_seconds <= 0: raise ValueError("PowerContext HTTP timeouts must be positive") # noqa: TRY003 + if not 512 <= self.bootstrap_max_bytes <= 8192: + raise ValueError("PowerContext bootstrap_max_bytes must be between 512 and 8192") # noqa: TRY003 if not 1 <= self.flush_max_calls <= 16: raise ValueError("PowerContext flush_max_calls must be between 1 and 16") # noqa: TRY003 @@ -111,6 +142,15 @@ def from_environment(cls, *, server_url: str | None = None) -> ClaudeCodePluginS ), scope_id=_first_environment("POWERCONTEXT_CLAUDE_SCOPE_ID"), context_assembly=_environment_object("POWERCONTEXT_CLAUDE_CONTEXT_ASSEMBLY"), + bootstrap_context=_environment_bool( + "POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT", + default=False, + ), + bootstrap_max_bytes=_environment_int( + "POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES", + default=4096, + ), + bootstrap_handoff=_environment_bootstrap_handoff("POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF"), capture_prompts=_environment_bool( "POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS", "CLAUDE_PLUGIN_OPTION_CAPTURE_PROMPTS", @@ -156,6 +196,20 @@ def _environment_object(name: str) -> dict[str, object] | None: return parsed +def _environment_bootstrap_handoff(name: str) -> BootstrapHandoff | None: + value = _environment_object(name) + if value is None: + return None + if not {"artifact_id", "revision"} <= set(value) or not set(value) <= {"family", "artifact_id", "revision"}: + raise ValueError("PowerContext bootstrap Handoff must be an exact identity") # noqa: TRY003 + artifact_id = value.get("artifact_id") + revision = value.get("revision") + family = value.get("family", "handoff") + if not isinstance(artifact_id, str) or not isinstance(revision, int) or not isinstance(family, str): + raise TypeError("PowerContext bootstrap Handoff must be an exact identity") # noqa: TRY003 + return BootstrapHandoff(artifact_id=artifact_id, revision=revision, family=family) + + def _environment_bool(*names: str, default: bool) -> bool: value = _first_environment(*names) if value is None: @@ -246,4 +300,4 @@ def _http_base_url(value: str, *, allow_insecure_http: bool = False) -> str: return urlunsplit((scheme, netloc, path, "", "")).rstrip("/") -__all__ = ["ClaudeCodePluginSettings"] +__all__ = ["BootstrapHandoff", "ClaudeCodePluginSettings"] diff --git a/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py b/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py new file mode 100644 index 0000000000..20501c88b3 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py @@ -0,0 +1,174 @@ +# 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. + +"""Strict dependency-free validation of the bootstrap HTTP contract.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from hashlib import sha256 +from typing import cast + +SCHEMA = "powercontext.bootstrap-context.v1" +PROFILE = "powercontext.scope-bootstrap.v1" +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +_RESULT_KEYS = { + "schema", + "status", + "reason", + "profile", + "content", + "content_bytes", + "package_digest", + "items", + "truncated", + "receipt", +} +_ITEM_KEYS = { + "kind", + "scope_id", + "artifact", + "entry_id", + "entry_version_id", + "content_digest", + "truncated", +} + + +class InvalidBootstrapResponse(ValueError): + pass + + +def validate_bootstrap_context(value: Mapping[str, object], *, max_bytes: int) -> dict[str, object]: + if set(value) != _RESULT_KEYS or value.get("schema") != SCHEMA or value.get("profile") != PROFILE: + raise InvalidBootstrapResponse + status = value.get("status") + reason = value.get("reason") + content = value.get("content") + content_bytes = value.get("content_bytes") + package_digest = value.get("package_digest") + items = value.get("items") + truncated = value.get("truncated") + receipt = value.get("receipt") + if ( + status not in {"ready", "empty", "skipped"} + or ( + reason is not None + and reason not in {"disabled", "no_eligible_context", "already_delivered", "preparation_failed"} + ) + or not isinstance(content_bytes, int) + or isinstance(content_bytes, bool) + or not isinstance(items, list) + or len(items) > 7 + or not isinstance(truncated, bool) + ): + raise InvalidBootstrapResponse + validated_receipt = validate_delivery_receipt(receipt) + validated_items = [_validate_item(item) for item in items] + if status == "ready": + if ( + reason is not None + or not isinstance(content, str) + or not content.strip() + or content_bytes != len(content.encode("utf-8")) + or content_bytes > max_bytes + or not isinstance(package_digest, str) + or _DIGEST.fullmatch(package_digest) is None + or package_digest != "sha256:" + sha256(content.encode("utf-8")).hexdigest() + or not validated_items + or validated_receipt["state"] != "pending" + ): + raise InvalidBootstrapResponse + elif ( + content is not None + or content_bytes != 0 + or package_digest is not None + or validated_items + or reason is None + or validated_receipt["state"] == "pending" + ): + raise InvalidBootstrapResponse + if status == "empty" and (reason != "no_eligible_context" or validated_receipt["state"] != "skipped"): + raise InvalidBootstrapResponse + if status == "skipped" and ( + reason == "no_eligible_context" + or (reason == "disabled" and validated_receipt["state"] != "skipped") + or (reason == "preparation_failed" and validated_receipt["state"] != "failed") + ): + raise InvalidBootstrapResponse + return dict(value) + + +def validate_delivery_receipt(value: object, *, receipt_id: str | None = None) -> dict[str, str]: + if not isinstance(value, dict) or set(value) != {"receipt_id", "state"}: + raise InvalidBootstrapResponse + identifier, state = value.get("receipt_id"), value.get("state") + if ( + not isinstance(identifier, str) + or not 1 <= len(identifier) <= 64 + or not identifier.isascii() + or not identifier.isprintable() + or state not in {"pending", "injected", "skipped", "failed"} + or (receipt_id is not None and identifier != receipt_id) + ): + raise InvalidBootstrapResponse + return {"receipt_id": identifier, "state": cast(str, state)} + + +def _validate_item(value: object) -> dict[str, object]: + if not isinstance(value, dict) or set(value) != _ITEM_KEYS: + raise InvalidBootstrapResponse + kind = value.get("kind") + scope_id = value.get("scope_id") + artifact = value.get("artifact") + entry_id = value.get("entry_id") + entry_version_id = value.get("entry_version_id") + digest = value.get("content_digest") + if ( + kind not in {"memory_entry", "handoff"} + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(artifact, dict) + or set(artifact) != {"family", "artifact_id", "revision"} + or not _printable_identifier(artifact.get("artifact_id"), maximum=128) + or not isinstance(artifact.get("revision"), int) + or isinstance(artifact.get("revision"), bool) + or cast(int, artifact.get("revision")) < 1 + or not isinstance(digest, str) + or _DIGEST.fullmatch(digest) is None + or not isinstance(value.get("truncated"), bool) + ): + raise InvalidBootstrapResponse + if kind == "memory_entry": + if ( + artifact.get("family") != "memory" + or not _printable_identifier(entry_id, maximum=128) + or not _printable_identifier(entry_version_id, maximum=128) + ): + raise InvalidBootstrapResponse + elif artifact.get("family") != "handoff" or entry_id is not None or entry_version_id is not None: + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _printable_identifier(value: object, *, maximum: int) -> bool: + return ( + isinstance(value, str) + and 1 <= len(value) <= maximum + and value.isascii() + and value.isprintable() + and not value.isspace() + ) diff --git a/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py b/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py index 7f3e8c3d81..11ce9151f4 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py +++ b/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py @@ -23,7 +23,14 @@ from contextlib import contextmanager from pathlib import Path -_FAILURE_OUTCOMES = frozenset({"authentication_failed", "version_mismatch", "server_unavailable", "invalid_response"}) +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) _COOLDOWN_SECONDS = 60.0 diff --git a/integrations/claude-code/plugins/powercontext/hooks/hooks.json b/integrations/claude-code/plugins/powercontext/hooks/hooks.json index d1d2ee05d4..cf6ecc2c33 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/hooks.json +++ b/integrations/claude-code/plugins/powercontext/hooks/hooks.json @@ -1,6 +1,22 @@ { - "description": "Recall relevant memory and capture the current Claude Code prompt as a Source.", + "description": "Bootstrap scoped context, recall relevant memory, and capture the current Claude Code prompt as a Source.", "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact|fork", + "hooks": [ + { + "type": "command", + "command": "python3", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py" + ], + "timeout": 10, + "statusMessage": "Loading PowerContext context" + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py new file mode 100644 index 0000000000..70a6c9822e --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +# 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 package at Claude Code SessionStart.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Mapping +from contextlib import suppress +from pathlib import Path +from time import monotonic +from typing import Any, Protocol, cast +from urllib.error import HTTPError +from urllib.request import Request + +_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 clear_receipt, save_receipt # 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 hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 +from scope_binding_errors import ( # noqa: E402 + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, +) +from workspace_scope import ( # noqa: E402 + bind_response_deadline, + open_bounded, + resolve_scope_id, +) + +_LIFECYCLES = frozenset({"startup", "resume", "clear", "compact", "fork"}) +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) +_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) -> None: + self.status = status + self.path = path + super().__init__(f"PowerContext returned HTTP {status}") + + +def main(settings: ClaudeCodePluginSettings | None = None) -> int: + """Process one SessionStart payload without ever blocking Claude Code.""" + + try: + payload = cast(dict[str, Any], json.load(sys.stdin)) + session_id = payload.get("session_id") + cwd = payload.get("cwd") + source = payload.get("source") + if ( + not isinstance(session_id, str) + or not isinstance(cwd, str) + or not isinstance(source, str) + or source not in _LIFECYCLES + ): + return 0 + settings = ClaudeCodePluginSettings.from_environment() if settings is None else settings + deadline = monotonic() + settings.http_budget_seconds + clear_receipt(session_id) + prepared = _resolve_and_prepare(cwd, session_id, source, payload, settings=settings, deadline=deadline) + if prepared is None: + return 0 + scope_id, response = prepared + if response["status"] != "ready": + if response["status"] == "empty": + receipt = cast(dict[str, str], response["receipt"]) + _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: + save_receipt(session_id, scope_id, receipt_id) + 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_receipt(session_id) + _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 + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response["content"], + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + 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, + 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): + _emit_bootstrap_event("server_unavailable", recovery="powercontext doctor") + except (ScopeBindingStatusError, _HttpStatusError) as error: + _emit_http_failure(error.status) + except (ScopeBindingError, InvalidBootstrapResponse, UnicodeDecodeError, json.JSONDecodeError): + _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", + ) + timeout = min(settings.request_timeout_seconds, _remaining_time(deadline)) + try: + with open_bounded(request, timeout=timeout) as response: + if response.status != 200: + raise _HttpStatusError(response.status, path) + value = json.loads(_read_response(response, deadline=min(deadline, monotonic() + timeout))) + except HTTPError as error: + raise _HttpStatusError(error.code, path) 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 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 _emit_http_failure(status: int) -> None: + if status == 401: + outcome = "authentication_failed" + elif status == 403: + outcome = "authorization_denied" + elif status == 404: + outcome = "version_mismatch" + elif status == 503: + outcome = "server_unavailable" + else: + outcome = "invalid_response" + _emit_bootstrap_event( + outcome, + http_status=status, + recovery="powercontext doctor" if outcome == "server_unavailable" else None, + ) + + +def _emit_bootstrap_event( + outcome: str, + *, + http_status: int | 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: + if outcome in _FAILURE_OUTCOMES and not _should_emit_diagnostic(outcome): + return + 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 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") + + +if __name__ == "__main__": + raise SystemExit(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 71cdee16cc..cdf7e06f65 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -33,6 +33,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) +from bootstrap_state import load_receipt # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from hooks import prepared_context as _prepared_context # noqa: E402 from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 @@ -47,7 +48,7 @@ _REQUEST_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "powercontext-claude-code-plugin/0.1.2", + "User-Agent": "powercontext-claude-code-plugin/0.1.3", } _FAILURE_OUTCOMES = frozenset({"authentication_failed", "version_mismatch", "server_unavailable", "invalid_response"}) @@ -137,12 +138,14 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: return 0 http_deadline = monotonic() + settings.http_budget_seconds + session_id = _payload_identifier(payload, "session_id") scope_id = resolve_scope_id( cwd, - session_id=_payload_identifier(payload, "session_id"), + session_id=session_id, settings=settings, deadline=http_deadline, ) + bootstrap_receipt_id = load_receipt(session_id, scope_id) context = None with suppress(Exception): context = _recall_context( @@ -150,6 +153,7 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: scope_id, settings=settings, deadline=http_deadline, + bootstrap_receipt_id=bootstrap_receipt_id, emitted_diagnostics=emitted_diagnostics, diagnostic_events=diagnostic_events, ) @@ -209,6 +213,7 @@ def _prepare_context( *, settings: ClaudeCodePluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, ) -> Mapping[str, object]: return _post_json( "/v1/context/prepare", @@ -217,6 +222,7 @@ def _prepare_context( "query": query, "max_bytes": _MAX_CONTEXT_BYTES, **({"assembly": settings.context_assembly} if settings.context_assembly is not None else {}), + **({"bootstrap_receipt_id": bootstrap_receipt_id} if bootstrap_receipt_id is not None else {}), }, settings=settings, deadline=deadline, @@ -391,11 +397,22 @@ def _recall_context( *, settings: ClaudeCodePluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, emitted_diagnostics: set[str] | None = None, diagnostic_events: list[dict[str, object]] | None = None, ) -> str | None: try: - prepared = _validate_prepared_context(_prepare_context(query, scope_id, settings=settings, deadline=deadline)) + if bootstrap_receipt_id is None: + response = _prepare_context(query, scope_id, settings=settings, deadline=deadline) + else: + response = _prepare_context( + query, + scope_id, + settings=settings, + deadline=deadline, + bootstrap_receipt_id=bootstrap_receipt_id, + ) + prepared = _validate_prepared_context(response) except _HttpStatusError as error: outcome = _http_failure_outcome(error, operation="context_prepare") if outcome is not None: diff --git a/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py index d84f06b730..f50619b8ab 100644 --- a/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py +++ b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py @@ -56,7 +56,7 @@ def override(method: _MethodT, /) -> _MethodT: _REQUEST_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "powercontext-claude-code-plugin/0.1.2", + "User-Agent": "powercontext-claude-code-plugin/0.1.3", } diff --git a/integrations/codex/plugins/powercontext/README.md b/integrations/codex/plugins/powercontext/README.md index 21890cb293..1fb04b7f15 100644 --- a/integrations/codex/plugins/powercontext/README.md +++ b/integrations/codex/plugins/powercontext/README.md @@ -5,6 +5,8 @@ does not embed storage or start the server. The integration uses each public surface for the job it fits: +- the opt-in `SessionStart` hook injects one bounded, curated bootstrap package + before the first prompt and records a content-free delivery receipt; - the `UserPromptSubmit` hook first calls `POST /v1/context/prepare`, then independently captures the current prompt with `POST /v1/sources/content`; - Streamable HTTP MCP at `http://127.0.0.1:8000/mcp` gives Codex the curated @@ -74,6 +76,14 @@ default Scope. `UserPromptSubmit` uses that binding for recall and capture. Agent-supplied `scope_id` cannot redirect a write. Repository and directory identities are binding lookup inputs only; they never generate a Scope ID. +Bootstrap context is disabled by default. Set +`POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true` to include active Memory entries +explicitly tagged `bootstrap-context`; use +`POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES` for the 512–8192-byte budget and +`POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF` for one exact committed Handoff identity. +The first ordinary recall carries the successful receipt so exact delivered +Memory versions are not repeated. + Set `POWERCONTEXT_CODEX_SCOPE_ID` only when the host must explicitly bind every request to one known Scope. `.mcp.json` is the single Server endpoint configuration consumed by Codex and diff --git a/integrations/codex/plugins/powercontext/bootstrap_state.py b/integrations/codex/plugins/powercontext/bootstrap_state.py new file mode 100644 index 0000000000..2cb1942a62 --- /dev/null +++ b/integrations/codex/plugins/powercontext/bootstrap_state.py @@ -0,0 +1,110 @@ +# 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 contextlib import 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: + path = _state_path(session_id) + if path is None: + return 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 + receipt_id = value.get("receipt_id") + if ( + value.get("version") != 1 + or value.get("scope_id") != scope_id + or not isinstance(receipt_id, str) + or not 1 <= len(receipt_id) <= 64 + or not receipt_id.isascii() + or not receipt_id.isprintable() + ): + return None + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + return 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() + 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: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + os.close(descriptor) + with suppress(FileNotFoundError): + temporary.unlink() + + +def clear_receipt(session_id: str | None) -> None: + path = _state_path(session_id) + if path is None: + return + try: + if not path.is_symlink(): + path.unlink(missing_ok=True) + except OSError: + return + + +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/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index 6838f6232f..3be12e7937 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -70,12 +70,14 @@ "list_memory_entries", "list_remote_skill_targets", "prepare_context", + "prepare_bootstrap_context", "prepare_handoff", "propose_experience", "propose_skill", "propose_skill_package", "publish_remote_skill", "record_skill_usage", + "record_bootstrap_delivery", "record_task_outcome", "reject_artifact_candidate", "remember_memory", diff --git a/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py b/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py new file mode 100644 index 0000000000..e041df8b70 --- /dev/null +++ b/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py @@ -0,0 +1,174 @@ +# 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. + +"""Strict dependency-free validation of the bootstrap HTTP contract.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from hashlib import sha256 +from typing import cast + +SCHEMA = "powercontext.bootstrap-context.v1" +PROFILE = "powercontext.scope-bootstrap.v1" +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +_RESULT_KEYS = { + "schema", + "status", + "reason", + "profile", + "content", + "content_bytes", + "package_digest", + "items", + "truncated", + "receipt", +} +_ITEM_KEYS = { + "kind", + "scope_id", + "artifact", + "entry_id", + "entry_version_id", + "content_digest", + "truncated", +} + + +class InvalidBootstrapResponse(ValueError): + pass + + +def validate_bootstrap_context(value: Mapping[str, object], *, max_bytes: int) -> dict[str, object]: + if set(value) != _RESULT_KEYS or value.get("schema") != SCHEMA or value.get("profile") != PROFILE: + raise InvalidBootstrapResponse + status = value.get("status") + reason = value.get("reason") + content = value.get("content") + content_bytes = value.get("content_bytes") + package_digest = value.get("package_digest") + items = value.get("items") + truncated = value.get("truncated") + receipt = value.get("receipt") + if ( + status not in {"ready", "empty", "skipped"} + or ( + reason is not None + and reason not in {"disabled", "no_eligible_context", "already_delivered", "preparation_failed"} + ) + or not isinstance(content_bytes, int) + or isinstance(content_bytes, bool) + or not isinstance(items, list) + or len(items) > 7 + or not isinstance(truncated, bool) + ): + raise InvalidBootstrapResponse + validated_receipt = validate_delivery_receipt(receipt) + validated_items = [_validate_item(item) for item in items] + if status == "ready": + if ( + reason is not None + or not isinstance(content, str) + or not content.strip() + or content_bytes != len(content.encode("utf-8")) + or content_bytes > max_bytes + or not isinstance(package_digest, str) + or _DIGEST.fullmatch(package_digest) is None + or package_digest != "sha256:" + sha256(content.encode("utf-8")).hexdigest() + or not validated_items + or validated_receipt["state"] != "pending" + ): + raise InvalidBootstrapResponse + elif ( + content is not None + or content_bytes != 0 + or package_digest is not None + or validated_items + or reason is None + or validated_receipt["state"] == "pending" + ): + raise InvalidBootstrapResponse + if status == "empty" and (reason != "no_eligible_context" or validated_receipt["state"] != "skipped"): + raise InvalidBootstrapResponse + if status == "skipped" and ( + reason == "no_eligible_context" + or (reason == "disabled" and validated_receipt["state"] != "skipped") + or (reason == "preparation_failed" and validated_receipt["state"] != "failed") + ): + raise InvalidBootstrapResponse + return dict(value) + + +def validate_delivery_receipt(value: object, *, receipt_id: str | None = None) -> dict[str, str]: + if not isinstance(value, dict) or set(value) != {"receipt_id", "state"}: + raise InvalidBootstrapResponse + identifier, state = value.get("receipt_id"), value.get("state") + if ( + not isinstance(identifier, str) + or not 1 <= len(identifier) <= 64 + or not identifier.isascii() + or not identifier.isprintable() + or state not in {"pending", "injected", "skipped", "failed"} + or (receipt_id is not None and identifier != receipt_id) + ): + raise InvalidBootstrapResponse + return {"receipt_id": identifier, "state": cast(str, state)} + + +def _validate_item(value: object) -> dict[str, object]: + if not isinstance(value, dict) or set(value) != _ITEM_KEYS: + raise InvalidBootstrapResponse + kind = value.get("kind") + scope_id = value.get("scope_id") + artifact = value.get("artifact") + entry_id = value.get("entry_id") + entry_version_id = value.get("entry_version_id") + digest = value.get("content_digest") + if ( + kind not in {"memory_entry", "handoff"} + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(artifact, dict) + or set(artifact) != {"family", "artifact_id", "revision"} + or not _printable_identifier(artifact.get("artifact_id"), maximum=128) + or not isinstance(artifact.get("revision"), int) + or isinstance(artifact.get("revision"), bool) + or cast(int, artifact.get("revision")) < 1 + or not isinstance(digest, str) + or _DIGEST.fullmatch(digest) is None + or not isinstance(value.get("truncated"), bool) + ): + raise InvalidBootstrapResponse + if kind == "memory_entry": + if ( + artifact.get("family") != "memory" + or not _printable_identifier(entry_id, maximum=128) + or not _printable_identifier(entry_version_id, maximum=128) + ): + raise InvalidBootstrapResponse + elif artifact.get("family") != "handoff" or entry_id is not None or entry_version_id is not None: + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _printable_identifier(value: object, *, maximum: int) -> bool: + return ( + isinstance(value, str) + and 1 <= len(value) <= maximum + and value.isascii() + and value.isprintable() + and not value.isspace() + ) diff --git a/integrations/codex/plugins/powercontext/hooks/diagnostics.py b/integrations/codex/plugins/powercontext/hooks/diagnostics.py index 782090f2ff..ad36fc6a2f 100644 --- a/integrations/codex/plugins/powercontext/hooks/diagnostics.py +++ b/integrations/codex/plugins/powercontext/hooks/diagnostics.py @@ -23,7 +23,14 @@ from contextlib import contextmanager from pathlib import Path -_FAILURE_OUTCOMES = frozenset({"authentication_failed", "version_mismatch", "server_unavailable", "invalid_response"}) +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) _COOLDOWN_SECONDS = 60.0 diff --git a/integrations/codex/plugins/powercontext/hooks/hooks.json b/integrations/codex/plugins/powercontext/hooks/hooks.json index 634e4726eb..6634346977 100644 --- a/integrations/codex/plugins/powercontext/hooks/hooks.json +++ b/integrations/codex/plugins/powercontext/hooks/hooks.json @@ -1,14 +1,16 @@ { - "description": "Recall relevant memory and capture the current Codex prompt as a Source.", + "description": "Bootstrap scoped context, recall relevant memory, and capture the current Codex prompt.", "hooks": { "SessionStart": [ { + "matcher": "startup|resume|clear|compact", "hooks": [ { "type": "command", "command": "uv run --frozen --quiet --project \"${PLUGIN_ROOT}\" python \"${PLUGIN_ROOT}/hooks/session_binding.py\"", "timeout": 10, - "statusMessage": "Binding PowerContext" + "statusMessage": "Loading PowerContext context", + "additionalContextLimit": 8192 } ] } diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index 6496d18aad..03af0af62b 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -35,6 +35,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) +from bootstrap_state import 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 scope_binding import bind_response_deadline, open_bounded, resolve_scope_id # noqa: E402 @@ -145,11 +146,13 @@ def main(settings: CodexPluginSettings | None = None) -> int: settings=settings, deadline=http_deadline, ) + bootstrap_receipt_id = load_receipt(session_id, scope_id) context = _recall_context( prompt, scope_id, settings=settings, deadline=http_deadline, + bootstrap_receipt_id=bootstrap_receipt_id, emitted_diagnostics=emitted_diagnostics, diagnostic_events=diagnostic_events, ) @@ -227,6 +230,7 @@ def _prepare_context( *, settings: CodexPluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, ) -> Mapping[str, object]: return _post_json( "/v1/context/prepare", @@ -235,6 +239,7 @@ def _prepare_context( "query": query, "max_bytes": _MAX_CONTEXT_BYTES, **({"assembly": settings.context_assembly} if settings.context_assembly is not None else {}), + **({"bootstrap_receipt_id": bootstrap_receipt_id} if bootstrap_receipt_id is not None else {}), }, settings=settings, deadline=deadline, @@ -409,11 +414,23 @@ def _recall_context( *, settings: CodexPluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, emitted_diagnostics: set[str] | None = None, diagnostic_events: list[dict[str, object]] | None = None, ) -> str | None: try: - prepared = _validate_prepared_context(_prepare_context(query, scope_id, settings=settings, deadline=deadline)) + response = ( + _prepare_context(query, scope_id, settings=settings, deadline=deadline) + if bootstrap_receipt_id is None + else _prepare_context( + query, + scope_id, + settings=settings, + deadline=deadline, + bootstrap_receipt_id=bootstrap_receipt_id, + ) + ) + prepared = _validate_prepared_context(response) except _HttpStatusError as error: outcome = _http_failure_outcome(error, operation="context_prepare") if outcome is not None: diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 3fd371d2cf..5f64557b07 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -13,24 +13,78 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Fix one Codex Session binding without blocking Session startup.""" +"""Bind a Codex Session and optionally inject one bounded bootstrap package.""" from __future__ import annotations import json import sys +from collections.abc import Mapping +from contextlib import suppress from pathlib import Path from time import monotonic -from typing import Any, cast +from typing import Any, Protocol, cast +from urllib.error import HTTPError +from urllib.request import Request _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 scope_binding import ScopeBindingError, resolve_scope_id # noqa: E402 +from bootstrap_state import clear_receipt, save_receipt # noqa: E402 +from hooks.bootstrap_context import ( # noqa: E402 + InvalidBootstrapResponse, + validate_bootstrap_context, + validate_delivery_receipt, +) +from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 +from scope_binding import ( # noqa: E402 + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, + bind_response_deadline, + open_bounded, + resolve_scope_id, +) 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" +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) +_REQUEST_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "powercontext-codex-plugin/0.2.0", +} + + +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) -> None: + self.status = status + self.path = path + super().__init__(f"PowerContext returned HTTP {status}") + def main(settings: CodexPluginSettings | None = None) -> int: try: @@ -40,16 +94,227 @@ def main(settings: CodexPluginSettings | None = None) -> int: if not isinstance(session_id, str) or not isinstance(cwd, str): return 0 settings = CodexPluginSettings() if settings is None else settings - resolve_scope_id( + deadline = monotonic() + settings.http_budget_seconds + source = payload.get("source") + if isinstance(source, str) and source in _LIFECYCLES: + clear_receipt(session_id) + if not isinstance(source, str) or source not in _LIFECYCLES: + return 0 + prepared = _resolve_and_prepare(cwd, session_id, source, payload, settings=settings, deadline=deadline) + if prepared is None: + return 0 + scope_id, response = prepared + if response["status"] != "ready": + if response["status"] == "empty": + receipt = cast(dict[str, str], response["receipt"]) + _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: + save_receipt(session_id, scope_id, receipt_id) + 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_receipt(session_id) + _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 + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response["content"], + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + 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, - deadline=monotonic() + settings.http_budget_seconds, + deadline=deadline, persist_session=True, ) - except (ScopeBindingError, ValueError, OSError, json.JSONDecodeError): - return 0 - return 0 + 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): + _emit_bootstrap_event("server_unavailable", recovery="powercontext doctor") + except (ScopeBindingStatusError, _HttpStatusError) as error: + _emit_http_failure(error.status) + except (ScopeBindingError, InvalidBootstrapResponse, UnicodeDecodeError, json.JSONDecodeError): + _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", + ) + timeout = min(settings.request_timeout_seconds, _remaining_time(deadline)) + try: + with open_bounded(request, timeout=timeout) as response: + if response.status != 200: + raise _HttpStatusError(response.status, path) + value = json.loads(_read_response(response, deadline=min(deadline, monotonic() + timeout))) + except HTTPError as error: + raise _HttpStatusError(error.code, path) 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 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 _emit_http_failure(status: int) -> None: + if status == 401: + outcome = "authentication_failed" + elif status == 403: + outcome = "authorization_denied" + elif status == 404: + outcome = "version_mismatch" + elif status == 503: + outcome = "server_unavailable" + else: + outcome = "invalid_response" + _emit_bootstrap_event( + outcome, + http_status=status, + recovery="powercontext doctor" if outcome == "server_unavailable" else None, + ) + + +def _emit_bootstrap_event( + outcome: str, + *, + http_status: int | 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: + if outcome in _FAILURE_OUTCOMES and not _should_emit_diagnostic(outcome): + return + 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 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") if __name__ == "__main__": diff --git a/integrations/codex/plugins/powercontext/settings.py b/integrations/codex/plugins/powercontext/settings.py index f9f9b9ee32..0cc341e3e9 100644 --- a/integrations/codex/plugins/powercontext/settings.py +++ b/integrations/codex/plugins/powercontext/settings.py @@ -77,6 +77,23 @@ def validate_server_set(self) -> _McpConfiguration: return self +class BootstrapHandoff(BaseModel): + """One explicitly selected exact committed Handoff.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + family: Literal["handoff"] = "handoff" + artifact_id: str = Field(min_length=1, max_length=128) + revision: int = Field(ge=1) + + @field_validator("artifact_id") + @classmethod + def validate_artifact_id(cls, value: str) -> str: + if value != value.strip() or not value.isascii() or not value.isprintable(): + raise ValueError("bootstrap Handoff identity must be printable ASCII") # noqa: TRY003 + return value + + class _McpEndpointSettingsSource(PydanticBaseSettingsSource): """Load the hook endpoint from the same file consumed by Codex MCP.""" @@ -121,6 +138,9 @@ class CodexPluginSettings(BaseSettings): authorization: SecretStr | None = Field(default=None, repr=False) scope_id: str | None = None context_assembly: dict[str, Any] | None = None + bootstrap_context: bool = False + bootstrap_max_bytes: int = Field(default=4096, ge=512, le=8192) + bootstrap_handoff: BootstrapHandoff | None = None capture_prompts: bool = True flush_on_capture: bool = False request_timeout_seconds: float = Field(default=1.0, gt=0) @@ -250,4 +270,4 @@ def _http_base_url(mcp_url: str, *, allow_insecure_http: bool = False) -> str: return urlunsplit((parsed.scheme, parsed.netloc, base_path, "", "")).rstrip("/") -__all__ = ["CodexPluginSettings"] +__all__ = ["BootstrapHandoff", "CodexPluginSettings"] diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 0988d3c908..d97f742697 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -41,6 +41,8 @@ export const OPERATIONS = { submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_bootstrap_context: { method: 'POST', path: '/v1/context/bootstrap', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_bootstrap_delivery: { method: 'POST', path: '/v1/context/bootstrap/receipts', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 0988d3c908..d97f742697 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -41,6 +41,8 @@ export const OPERATIONS = { submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_bootstrap_context: { method: 'POST', path: '/v1/context/bootstrap', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_bootstrap_delivery: { method: 'POST', path: '/v1/context/bootstrap/receipts', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 0988d3c908..d97f742697 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -41,6 +41,8 @@ export const OPERATIONS = { submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_bootstrap_context: { method: 'POST', path: '/v1/context/bootstrap', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_bootstrap_delivery: { method: 'POST', path: '/v1/context/bootstrap/receipts', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 6aa9de6b8d..237c87ee5c 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -882,6 +882,78 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/context/bootstrap: + post: + tags: [context] + summary: Prepare bounded context for an Agent lifecycle boundary + description: >- + Prepare an opt-in, deterministic bootstrap package without manufacturing a query. Only explicitly curated + active Memory entries and one caller-selected exact committed Handoff are eligible. Returned historical text + is untrusted data and grants no authority. A ready receipt remains pending until the host records delivery. + operationId: prepare_bootstrap_context + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BootstrapContextRequest" + responses: + "200": + description: A bounded package ready for host injection, or a normal empty/skipped result. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/BootstrapContext" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/context/bootstrap/receipts: + post: + tags: [context] + summary: Record a bootstrap delivery outcome + description: >- + Atomically claim one pending content-free receipt as injected or failed. A second injected claim is rejected + with 422, so concurrent lifecycle hooks cannot both inject the same package. A terminal outcome never changes. + operationId: record_bootstrap_delivery + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecordBootstrapDeliveryRequest" + responses: + "200": + description: Current durable delivery state. + content: + application/json: + schema: + $ref: "#/components/schemas/BootstrapDeliveryReceipt" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" /v1/work/contracts/create: post: tags: [work] @@ -6896,6 +6968,81 @@ components: content_bytes: type: integer minimum: 0 + BootstrapContextItem: + type: object + additionalProperties: false + required: [kind, scope_id, artifact, entry_id, entry_version_id, content_digest, truncated] + properties: + kind: + $ref: "#/components/schemas/BootstrapContextItemKind" + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + entry_id: + type: string + minLength: 1 + maxLength: 128 + nullable: true + entry_version_id: + type: string + minLength: 1 + maxLength: 128 + nullable: true + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + truncated: + type: boolean + BootstrapDeliveryReceipt: + type: object + additionalProperties: false + required: [receipt_id, state] + properties: + receipt_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + state: + $ref: "#/components/schemas/BootstrapReceiptState" + BootstrapContext: + type: object + additionalProperties: false + required: [schema, status, reason, profile, content, content_bytes, package_digest, items, truncated, receipt] + properties: + schema: + $ref: "#/components/schemas/BootstrapContextSchema" + status: + $ref: "#/components/schemas/BootstrapContextStatus" + reason: + allOf: + - $ref: "#/components/schemas/BootstrapSkipReason" + nullable: true + profile: + $ref: "#/components/schemas/BootstrapContextProfile" + content: + type: string + nullable: true + content_bytes: + type: integer + minimum: 0 + package_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + nullable: true + items: + type: array + maxItems: 7 + items: + $ref: "#/components/schemas/BootstrapContextItem" + truncated: + type: boolean + receipt: + $ref: "#/components/schemas/BootstrapDeliveryReceipt" EntryChange: type: object additionalProperties: false @@ -8403,6 +8550,70 @@ components: default: 8000 assembly: $ref: "#/components/schemas/ContextAssembly" + bootstrap_receipt_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + description: >- + Optional exact receipt for the last successfully injected bootstrap package. Only identical Memory entry + versions from an injected same-Scope receipt are removed from this query result. + BootstrapContextRequest: + type: object + additionalProperties: false + required: [scope_id, lifecycle, integration] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + enabled: + type: boolean + default: false + profile: + allOf: + - $ref: "#/components/schemas/BootstrapContextProfile" + default: powercontext.scope-bootstrap.v1 + lifecycle: + $ref: "#/components/schemas/BootstrapContextLifecycle" + integration: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + event_id: + type: string + minLength: 1 + maxLength: 512 + pattern: '^[\x20-\x7E]+$' + description: Optional stable host event identity; persisted only as a digest. + max_bytes: + type: integer + minimum: 512 + maximum: 8192 + default: 4096 + handoff: + $ref: "#/components/schemas/ArtifactReference" + description: Optional exact committed Handoff Revision in the current Scope. + RecordBootstrapDeliveryRequest: + type: object + additionalProperties: false + required: [scope_id, receipt_id, outcome] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + receipt_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + outcome: + type: string + enum: [injected, failed] ContextAssemblySection: type: object additionalProperties: false @@ -9625,6 +9836,27 @@ components: PreparedContextStatus: type: string enum: [ready, empty] + BootstrapContextSchema: + type: string + enum: [powercontext.bootstrap-context.v1] + BootstrapContextProfile: + type: string + enum: [powercontext.scope-bootstrap.v1] + BootstrapContextLifecycle: + type: string + enum: [startup, resume, clear, compact, restore, fork] + BootstrapContextStatus: + type: string + enum: [ready, empty, skipped] + BootstrapContextItemKind: + type: string + enum: [memory_entry, handoff] + BootstrapReceiptState: + type: string + enum: [pending, injected, skipped, failed] + BootstrapSkipReason: + type: string + enum: [disabled, no_eligible_context, already_delivered, preparation_failed] EntryChangeOperation: type: string enum: [add, revise, deactivate, reactivate] diff --git a/scripts/integration_manifest.py b/scripts/integration_manifest.py index bcf66f9320..0c155ab165 100644 --- a/scripts/integration_manifest.py +++ b/scripts/integration_manifest.py @@ -589,6 +589,7 @@ def _is_complete_hook(integration_id: str, event: str, scripts: set[Path]) -> bo ("codex", "SessionStart"): "session_binding.py", ("codex", "PreToolUse"): "bind_tools.py", ("codex", "Stop"): "token_savings.py", + ("claude-code", "SessionStart"): "session_start.py", }.get((integration_id, event)) return expected_script is not None and any( script.is_file() and script.name == expected_script for script in scripts diff --git a/src/powercontext/builtin/persistence/bootstrap_receipts.py b/src/powercontext/builtin/persistence/bootstrap_receipts.py new file mode 100644 index 0000000000..f2e787201a --- /dev/null +++ b/src/powercontext/builtin/persistence/bootstrap_receipts.py @@ -0,0 +1,213 @@ +# 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. + +"""Content-free persistence for bootstrap delivery receipts.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import cast + +from pydantic import TypeAdapter +from sqlalchemy import insert, select, update +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from powercontext.builtin.persistence.codec import stored_bytes +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.tables import CONTEXT_BOOTSTRAP_RECEIPTS_TABLE +from powercontext.builtin.runtime.models import ( + BootstrapContextItem, + BootstrapContextLifecycle, + BootstrapContextProfile, + BootstrapReceiptState, + BootstrapSkipReason, +) + +_ITEMS = TypeAdapter(tuple[BootstrapContextItem, ...]) + + +@dataclass(frozen=True) +class StoredBootstrapReceipt: + receipt_id: str + scope_id: str + event_key: str | None + integration: str + lifecycle: BootstrapContextLifecycle + profile: BootstrapContextProfile + state: BootstrapReceiptState + reason: BootstrapSkipReason | None + max_bytes: int + package_digest: str | None + content_bytes: int + truncated: bool + items: tuple[BootstrapContextItem, ...] + + +class BootstrapReceiptRepository: + """Reserve stable events and apply monotonic delivery outcomes.""" + + def __init__(self, database: AsyncDatabase) -> None: + self._database = database + + async def by_event(self, event_key: str) -> StoredBootstrapReceipt | None: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.event_key == event_key + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _stored_receipt(row) + + async def create(self, receipt: StoredBootstrapReceipt) -> tuple[StoredBootstrapReceipt, bool]: + values = _values(receipt) + async with self._database.transaction() as connection: + if receipt.event_key is None: + await connection.execute(insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).values(**values)) + return receipt, True + if connection.dialect.name == "sqlite": + statement = ( + sqlite_insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE) + .values(**values) + .on_conflict_do_nothing(index_elements=["event_key"]) + ) + elif connection.dialect.name == "mysql": + statement = mysql_insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).values(**values).prefix_with("IGNORE") + else: + statement = insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).values(**values) + result = await connection.execute(statement) + if result.rowcount == 1: + return receipt, True + row = ( + ( + await connection.execute( + select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.event_key == receipt.event_key + ) + ) + ) + .mappings() + .one() + ) + return _stored_receipt(row), False + + async def record_delivery( + self, + scope_id: str, + receipt_id: str, + outcome: BootstrapReceiptState, + ) -> tuple[StoredBootstrapReceipt | None, bool]: + if outcome not in {"injected", "failed", "skipped"}: + raise ValueError("invalid bootstrap delivery outcome") # noqa: TRY003 + async with self._database.transaction() as connection: + table = CONTEXT_BOOTSTRAP_RECEIPTS_TABLE + changed = await connection.execute( + update(table) + .where(table.c.scope_id == scope_id, table.c.receipt_id == receipt_id, table.c.state == "pending") + .values(state=outcome) + ) + row = ( + ( + await connection.execute( + select(table).where(table.c.scope_id == scope_id, table.c.receipt_id == receipt_id) + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + return None, False + stored = _stored_receipt(row) + claimed = changed.rowcount == 1 + return (replace(stored, state=outcome) if claimed else stored), claimed + + async def injected_memory_keys(self, scope_id: str, receipt_id: str) -> frozenset[tuple[object, ...]]: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.state, + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c["items"], + ).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.scope_id == scope_id, + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id == receipt_id, + ) + ) + ) + .mappings() + .one_or_none() + ) + if row is None or row["state"] != "injected": + return frozenset() + items = _load_items(row["items"], receipt_id) + return frozenset( + ( + item.scope_id, + item.artifact.artifact_id, + item.artifact.revision, + item.entry_id, + item.entry_version_id, + ) + for item in items + if item.kind == "memory_entry" + ) + + +def _values(receipt: StoredBootstrapReceipt) -> dict[str, object]: + return { + "receipt_id": receipt.receipt_id, + "scope_id": receipt.scope_id, + "event_key": receipt.event_key, + "integration": receipt.integration, + "lifecycle": receipt.lifecycle, + "profile": receipt.profile, + "state": receipt.state, + "reason": receipt.reason, + "max_bytes": receipt.max_bytes, + "package_digest": receipt.package_digest, + "content_bytes": receipt.content_bytes, + "truncated": receipt.truncated, + "items": _ITEMS.dump_json(receipt.items), + } + + +def _load_items(value: object, receipt_id: str) -> tuple[BootstrapContextItem, ...]: + return _ITEMS.validate_json(stored_bytes(value, column=f"bootstrap receipt {receipt_id} items"), strict=True) + + +def _stored_receipt(row: object) -> StoredBootstrapReceipt: + values = cast(dict[str, object], row) + receipt_id = cast(str, values["receipt_id"]) + return StoredBootstrapReceipt( + receipt_id=receipt_id, + scope_id=cast(str, values["scope_id"]), + event_key=cast(str | None, values["event_key"]), + integration=cast(str, values["integration"]), + lifecycle=cast(BootstrapContextLifecycle, values["lifecycle"]), + profile=cast(BootstrapContextProfile, values["profile"]), + state=cast(BootstrapReceiptState, values["state"]), + reason=cast(BootstrapSkipReason | None, values["reason"]), + max_bytes=cast(int, values["max_bytes"]), + package_digest=cast(str | None, values["package_digest"]), + content_bytes=cast(int, values["content_bytes"]), + truncated=cast(bool, values["truncated"]), + items=_load_items(values["items"], receipt_id), + ) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 530d3f13de..5aaf007836 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -1090,6 +1090,32 @@ def _entry_text_type(): Column("reason", String(64), nullable=False), ) +CONTEXT_BOOTSTRAP_RECEIPTS_TABLE = Table( + "pc_context_bootstrap_receipts", + SHARED_METADATA, + Column("receipt_id", identity_string(64), primary_key=True), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + Column("event_key", identity_string(64), nullable=True), + Column("integration", identity_string(64), nullable=False), + Column("lifecycle", identity_string(16), nullable=False), + Column("profile", identity_string(64), nullable=False), + Column("state", identity_string(16), nullable=False), + Column("reason", identity_string(32), nullable=True), + Column("max_bytes", Integer, nullable=False), + Column("package_digest", identity_string(71), nullable=True), + Column("content_bytes", Integer, nullable=False), + Column("truncated", Boolean, nullable=False), + Column("items", _canonical_payload_type(), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), + UniqueConstraint("event_key", name="uq_pc_context_bootstrap_receipts_event"), + CheckConstraint( + "state IN ('pending', 'injected', 'skipped', 'failed')", + name="ck_pc_context_bootstrap_receipts_state", + ), + CheckConstraint("max_bytes >= 512 AND max_bytes <= 8192", name="ck_pc_context_bootstrap_receipts_budget"), + CheckConstraint("content_bytes >= 0", name="ck_pc_context_bootstrap_receipts_content_bytes"), +) + # The recurrence ledger is append-only: every correction is a new row, and the # only write path is the existing task-outcome incubation window. RECURRENCE_MATCH_TABLE = Table( @@ -1187,5 +1213,10 @@ def _entry_text_type(): + MEMORY_TABLES + STATISTICS_TABLES + RECURRENCE_TABLES - + (ARTIFACT_TAGS_TABLE, DREAM_RUNS_TABLE, RECEIPT_MIGRATION_REVIEW_TABLE) + + ( + ARTIFACT_TAGS_TABLE, + DREAM_RUNS_TABLE, + RECEIPT_MIGRATION_REVIEW_TABLE, + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE, + ) ) diff --git a/src/powercontext/builtin/runtime/__init__.py b/src/powercontext/builtin/runtime/__init__.py index 9dfbf5a718..d1d85842c8 100644 --- a/src/powercontext/builtin/runtime/__init__.py +++ b/src/powercontext/builtin/runtime/__init__.py @@ -48,6 +48,7 @@ SkillGenerationOrigin, ) from powercontext.builtin.runtime.application import ( + BootstrapApplication, BuiltinRuntime, ExperienceApplication, ExternalSkillApplication, @@ -58,6 +59,7 @@ ReviewApplication, ScheduledExperienceProcessor, ScheduledSourceProcessor, + ScopedBootstrapApplication, ScopedExperienceApplication, ScopedExternalSkillApplication, ScopedHandoffApplication, @@ -101,6 +103,10 @@ from powercontext.builtin.runtime.errors import InvalidRuntimeRequestError, TopicMemoryProcessingUnavailableError from powercontext.builtin.runtime.models import ( ApproveArtifactCandidateRequest, + BootstrapContext, + BootstrapContextItem, + BootstrapContextRequest, + BootstrapDeliveryReceipt, CaptureSource, CommitConnectorCheckpoint, ConnectorCheckpointState, @@ -136,6 +142,7 @@ PreparedContext, ProposeExperienceRequest, ProposeSkillRequest, + RecordBootstrapDeliveryRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, @@ -200,6 +207,11 @@ "ArtifactProcessingWorkerCompletion", "ArtifactProcessingWorkerFailure", "ArtifactProcessingWorkerOutcome", + "BootstrapApplication", + "BootstrapContext", + "BootstrapContextItem", + "BootstrapContextRequest", + "BootstrapDeliveryReceipt", "BuiltinConfig", "BuiltinConfigurationError", "BuiltinRuntime", @@ -293,6 +305,7 @@ "RecallTokenMeasurement", "RecallTokenStatistics", "RecallTokenValue", + "RecordBootstrapDeliveryRequest", "RejectArtifactCandidateRequest", "RememberMemoryRequest", "RemoteIngestion", @@ -314,6 +327,7 @@ "ScheduledExperienceProcessor", "ScheduledSourceProcessor", "ScopeStatistics", + "ScopedBootstrapApplication", "ScopedDreamApplication", "ScopedExperienceApplication", "ScopedExternalSkillApplication", diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index db06bed911..aae2bebeac 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -17,14 +17,17 @@ from __future__ import annotations import asyncio +import json import logging from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractContextManager, asynccontextmanager, nullcontext from dataclasses import dataclass, replace from datetime import UTC, datetime +from hashlib import sha256 from pathlib import Path from time import perf_counter from typing import TYPE_CHECKING, Any, Protocol +from uuid import uuid4 from pydantic import BaseModel, JsonValue, ValidationError @@ -133,6 +136,10 @@ ArtifactGovernance, ArtifactLifecycleState, ) +from powercontext.builtin.persistence.bootstrap_receipts import ( + BootstrapReceiptRepository, + StoredBootstrapReceipt, +) from powercontext.builtin.persistence.skill_publications import SkillPublication from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.records import ( @@ -156,9 +163,20 @@ ScopeCacheObserver, ScopeEvictor, ) +from powercontext.builtin.runtime.bootstrap_context import ( + BootstrapBuild, + BootstrapCandidate, + build_bootstrap_context, +) from powercontext.builtin.runtime.errors import InvalidRuntimeRequestError, TopicMemoryProcessingUnavailableError from powercontext.builtin.runtime.models import ( ApproveArtifactCandidateRequest, + BootstrapContext, + BootstrapContextItem, + BootstrapContextRequest, + BootstrapDeliveryReceipt, + BootstrapReceiptState, + BootstrapSkipReason, CaptureSource, CommitConnectorCheckpoint, ConnectorCheckpointState, @@ -186,6 +204,7 @@ PreparedContext, ProposeExperienceRequest, ProposeSkillRequest, + RecordBootstrapDeliveryRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, @@ -256,7 +275,14 @@ StatisticsPeriod, ) from powercontext.builtin.statistics.aggregation import aggregate_statistics -from powercontext.builtin.tags import ArtifactTagSet, TagFilter, TagQuery, TagQueryPage, TagTarget +from powercontext.builtin.tags import ( + ArtifactTagSet, + TagFilter, + TaggedMemoryCitation, + TagQuery, + TagQueryPage, + TagTarget, +) from powercontext.builtin.work import ( HANDOFF_BOUNDARY_SOURCE_KIND, HANDOFF_RECEIPT_SOURCE_KIND, @@ -949,6 +975,21 @@ async def _prepare_build( topic_reuse=topic_reuse, round_zero=round_zero, ) + if request.bootstrap_receipt_id is not None and self._runtime._bootstrap_receipts is not None: + injected = await self._runtime._bootstrap_receipts.injected_memory_keys( + self.scope_id, + request.bootstrap_receipt_id, + ) + if injected: + memory_candidates = [ + replace( + candidates, + hits=tuple( + hit for hit in candidates.hits if _memory_identity(candidates.scope_id, hit) not in injected + ), + ) + for candidates in memory_candidates + ] with self._runtime._stage( "context.build", attributes={ @@ -1605,6 +1646,341 @@ def for_scope(self, scope_id: str, /) -> ScopedContextApplication: return ScopedContextApplication(self._runtime, scope_id) +_BOOTSTRAP_TAG = "bootstrap-context" +_BOOTSTRAP_MEMORY_LIMIT = 6 +_BOOTSTRAP_CONTEXT_REFERENCE_LIMIT = 8 +_BOOTSTRAP_TAG_SCAN_LIMIT = 32 +_SECRET_MEMORY_KINDS = ( + "secret", + "credential", + "password", + "token", + "api key", + "api-key", + "apikey", + "access key", + "private key", + "private-key", +) + + +class ScopedBootstrapApplication: + """Prepare and account for one Scope's lifecycle bootstrap context.""" + + def __init__(self, runtime: BuiltinRuntime, scope_id: str) -> None: + self._runtime = runtime + self.scope_id = validate_scope_id(scope_id) + + async def prepare( + self, + request: BootstrapContextRequest, + /, + *, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None = None, + ) -> BootstrapContext: + repository = self._repository() + async with self._runtime._scope_operation(self.scope_id) as scope: + scope_ids = (self.scope_id, *scope.context_references[:_BOOTSTRAP_CONTEXT_REFERENCE_LIMIT]) + event_key = _bootstrap_event_key(self.scope_id, request) + if not request.enabled: + return await self._disabled_result(request, event_key, repository, authorize_scopes) + if event_key is not None: + existing = await repository.by_event(event_key) + if existing is not None: + if authorize_scopes is not None: + stored_scope_ids = tuple( + dict.fromkeys((*scope_ids, *(item.scope_id for item in existing.items))) + ) + await authorize_scopes(stored_scope_ids) + return await self._existing_result(existing) + if authorize_scopes is not None: + await authorize_scopes(scope_ids) + candidates = await self._select_candidates(request, scope_ids) + build = build_bootstrap_context(candidates, request.max_bytes) + if build.content is None: + stored, created = await repository.create( + self._stored_receipt( + request, + event_key=event_key, + state="skipped", + reason="no_eligible_context", + truncated=build.truncated, + ) + ) + if not created: + return await self._existing_result(stored) + return BootstrapContext( + status="empty", + reason="no_eligible_context", + content=None, + content_bytes=0, + truncated=build.truncated, + receipt=_delivery_receipt(stored), + ) + + stored, created = await repository.create( + self._stored_receipt( + request, + event_key=event_key, + state="pending", + content=build.content, + items=build.items, + truncated=build.truncated, + ) + ) + if not created: + return await self._existing_result(stored) + return _ready_bootstrap(stored, build.content, build) + + async def _disabled_result( + self, + request: BootstrapContextRequest, + event_key: str | None, + repository: BootstrapReceiptRepository, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, + ) -> BootstrapContext: + if authorize_scopes is not None: + await authorize_scopes((self.scope_id,)) + stored = await repository.by_event(event_key) if event_key is not None else None + if stored is None: + stored, _ = await repository.create( + self._stored_receipt(request, event_key=event_key, state="skipped", reason="disabled") + ) + if stored.state == "pending": + stored, _ = await repository.record_delivery(self.scope_id, stored.receipt_id, "skipped") + if stored is None: + raise InvalidRuntimeRequestError("bootstrap-receipt") + return self._terminal_result(stored, reason="disabled" if stored.state == "skipped" else "already_delivered") + + async def record_delivery(self, request: RecordBootstrapDeliveryRequest, /) -> BootstrapDeliveryReceipt: + async with self._runtime._scope_operation(self.scope_id): + stored, claimed = await self._repository().record_delivery( + self.scope_id, request.receipt_id, request.outcome + ) + if stored is None: + raise InvalidRuntimeRequestError("bootstrap-receipt") + if request.outcome == "injected" and not claimed: + raise InvalidRuntimeRequestError("bootstrap-delivery-already-claimed") + return _delivery_receipt(stored) + + async def _select_candidates( + self, + request: BootstrapContextRequest, + scope_ids: tuple[str, ...], + ) -> tuple[BootstrapCandidate, ...]: + records = self._runtime._records() + candidates: list[BootstrapCandidate] = [] + if request.handoff is not None: + handoff = await records.get_artifact_revision( + self.scope_id, + "handoff", + request.handoff.artifact_id, + request.handoff.revision, + ) + candidates.append( + BootstrapCandidate( + item=BootstrapContextItem( + kind="handoff", + scope_id=self.scope_id, + artifact=request.handoff, + content_digest=handoff.content_digest, + ), + content=json.dumps(handoff.content, ensure_ascii=False, indent=2, sort_keys=True), + ) + ) + + remaining = _BOOTSTRAP_MEMORY_LIMIT + for scope_id in scope_ids: + if remaining == 0: + break + page = await records.query_tags( + scope_id, + TagQuery( + tags=(_BOOTSTRAP_TAG,), + families=("memory",), + target_types=("memory_entry",), + include_inactive=False, + limit=_BOOTSTRAP_TAG_SCAN_LIMIT, + ), + caller="bootstrap-context", + ) + for tagged in page.items: + reference = tagged.reference + if not isinstance(reference, TaggedMemoryCitation): + continue + entry = await records.current_memory_entry( + scope_id, reference.memory_ref.artifact_id, reference.entry_id + ) + if entry.entry_version_id != reference.entry_version_id or _secret_memory_kind(entry.kind): + continue + candidates.append( + BootstrapCandidate( + item=BootstrapContextItem( + kind="memory_entry", + scope_id=scope_id, + artifact=reference.memory_ref, + entry_id=reference.entry_id, + entry_version_id=reference.entry_version_id, + content_digest=f"sha256:{entry.entry_content_hash}", + ), + content=f"Kind: {json.dumps(entry.kind, ensure_ascii=True)}\nText:\n{entry.text}", + ) + ) + remaining -= 1 + if remaining == 0: + break + return tuple(candidates) + + async def _existing_result(self, stored: StoredBootstrapReceipt) -> BootstrapContext: + if stored.scope_id != self.scope_id or stored.state != "pending": + return self._terminal_result(stored, reason="already_delivered") + try: + candidates = tuple([await self._candidate_from_item(item) for item in stored.items]) + build = build_bootstrap_context(candidates, stored.max_bytes) + content = _require_reconstructed_bootstrap(stored, build.content, build) + except Exception as error: + log_safely( + logger, + logging.ERROR, + "Bootstrap receipt reconstruction failed", + exc_info=error, + extra={"event": "context.bootstrap.reconstruct_failed", "outcome": "failure", "unit": "context"}, + ) + failed, _ = await self._repository().record_delivery(self.scope_id, stored.receipt_id, "failed") + return self._terminal_result(failed or stored, reason="preparation_failed") + return _ready_bootstrap(stored, content, build) + + async def _candidate_from_item(self, item: BootstrapContextItem) -> BootstrapCandidate: + records = self._runtime._records() + if item.kind == "handoff": + record = await records.get_artifact_revision( + item.scope_id, + item.artifact.family, + item.artifact.artifact_id, + item.artifact.revision, + ) + if record.content_digest != item.content_digest: + raise ValueError("bootstrap Handoff digest changed") # noqa: TRY003 + content = json.dumps(record.content, ensure_ascii=False, indent=2, sort_keys=True) + else: + if item.entry_id is None or item.entry_version_id is None: + raise ValueError("bootstrap Memory citation is incomplete") # noqa: TRY003 + citation = MemoryCitation( + memory_ref=item.artifact, + entry_id=item.entry_id, + entry_version_id=item.entry_version_id, + ) + async with self._runtime._context(item.scope_id) as context: + entry = await context.artifacts.memory.validate_citation(citation) + if f"sha256:{entry.entry_content_hash}" != item.content_digest: + raise ValueError("bootstrap Memory entry changed") # noqa: TRY003 + content = f"Kind: {json.dumps(entry.kind, ensure_ascii=True)}\nText:\n{entry.text}" + return BootstrapCandidate(item=item.model_copy(update={"truncated": False}), content=content) + + def _stored_receipt( + self, + request: BootstrapContextRequest, + *, + event_key: str | None, + state: BootstrapReceiptState, + reason: BootstrapSkipReason | None = None, + content: str | None = None, + items: tuple[BootstrapContextItem, ...] = (), + truncated: bool = False, + ) -> StoredBootstrapReceipt: + encoded = b"" if content is None else content.encode("utf-8") + return StoredBootstrapReceipt( + receipt_id=f"bcr_{uuid4().hex}", + scope_id=self.scope_id, + event_key=event_key, + integration=request.integration, + lifecycle=request.lifecycle, + profile=request.profile, + state=state, + reason=reason, + max_bytes=request.max_bytes, + package_digest=None if content is None else _package_digest(content), + content_bytes=len(encoded), + truncated=truncated, + items=items, + ) + + def _terminal_result(self, stored: StoredBootstrapReceipt, *, reason: BootstrapSkipReason) -> BootstrapContext: + return BootstrapContext( + status="skipped", + reason=reason, + profile=stored.profile, + content=None, + content_bytes=0, + truncated=False, + receipt=_delivery_receipt(stored), + ) + + def _repository(self) -> BootstrapReceiptRepository: + if self._runtime._bootstrap_receipts is None: + raise _RuntimeStateError("bootstrap-receipts") + return self._runtime._bootstrap_receipts + + +class BootstrapApplication: + """Select scoped bootstrap operations.""" + + def __init__(self, runtime: BuiltinRuntime) -> None: + self._runtime = runtime + + def for_scope(self, scope_id: str, /) -> ScopedBootstrapApplication: + return ScopedBootstrapApplication(self._runtime, scope_id) + + +def _bootstrap_event_key(scope_id: str, request: BootstrapContextRequest) -> str | None: + if request.event_id is None: + return None + value = "\0".join((scope_id, request.integration, request.lifecycle, request.event_id)) + return sha256(value.encode()).hexdigest() + + +def _secret_memory_kind(kind: str) -> bool: + normalized = " ".join(kind.casefold().replace("_", " ").split()) + return any(marker in normalized for marker in _SECRET_MEMORY_KINDS) + + +def _package_digest(content: str) -> str: + return "sha256:" + sha256(content.encode("utf-8")).hexdigest() + + +def _require_reconstructed_bootstrap( + stored: StoredBootstrapReceipt, + content: str | None, + build: BootstrapBuild, +) -> str: + if ( + content is None + or build.items != stored.items + or len(content.encode("utf-8")) != stored.content_bytes + or _package_digest(content) != stored.package_digest + or build.truncated != stored.truncated + ): + raise ValueError("bootstrap receipt cannot be reconstructed") # noqa: TRY003 + return content + + +def _delivery_receipt(stored: StoredBootstrapReceipt) -> BootstrapDeliveryReceipt: + return BootstrapDeliveryReceipt(receipt_id=stored.receipt_id, state=stored.state) + + +def _ready_bootstrap(stored: StoredBootstrapReceipt, content: str, build: BootstrapBuild) -> BootstrapContext: + return BootstrapContext( + status="ready", + profile=stored.profile, + content=content, + content_bytes=stored.content_bytes, + package_digest=stored.package_digest, + items=build.items, + truncated=build.truncated, + receipt=_delivery_receipt(stored), + ) + + class ScopedExperienceApplication: """Propose and exactly read Experience Artifacts in one scope.""" @@ -2903,6 +3279,7 @@ def __init__( remote_skill_distribution: RemoteSkillDistributionService | None = None, statistics_service: StatisticsServiceFactory | None = None, record_service: RecordService | None = None, + bootstrap_receipts: BootstrapReceiptRepository | None = None, prompt_service: PromptService | None = None, recall_token_estimator: RecallTokenEstimator | None = None, recall_effort_sink: RecallEffortSink | None = None, @@ -2958,6 +3335,7 @@ def __init__( self._remote_skill_distribution = remote_skill_distribution self._statistics_service = statistics_service self._record_service = record_service + self._bootstrap_receipts = bootstrap_receipts self._prompt_service = prompt_service self._recall_token_estimator = recall_token_estimator self._recall_effort_sink = recall_effort_sink @@ -2988,6 +3366,7 @@ def __init__( self.sources = SourceApplication(self) self.ingestion = RemoteIngestionApplication(self, remote_ingestion) self.context = ContextApplication(self) + self.bootstrap = BootstrapApplication(self) self.experience = ExperienceApplication(self) self.dream = DreamApplication(self) self.external_skills = ExternalSkillApplication(self) diff --git a/src/powercontext/builtin/runtime/bootstrap_context.py b/src/powercontext/builtin/runtime/bootstrap_context.py new file mode 100644 index 0000000000..2548ec588a --- /dev/null +++ b/src/powercontext/builtin/runtime/bootstrap_context.py @@ -0,0 +1,167 @@ +# 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. + +"""Pure selection and rendering for bounded lifecycle bootstrap context.""" + +from __future__ import annotations + +import json +import unicodedata +from collections.abc import Sequence +from dataclasses import dataclass, replace + +from powercontext.builtin.runtime.models import BootstrapContextItem +from powercontext.builtin.runtime.prepared_text import TRUST_POLICY + +_BEGIN_MARKER = "BEGIN_POWERCONTEXT_BOOTSTRAP_CONTEXT_V1" +_END_MARKER = "END_POWERCONTEXT_BOOTSTRAP_CONTEXT_V1" +_MAX_ITEM_BODY_BYTES = 2000 +_MIN_TRUNCATED_BODY_BYTES = 64 +_TRUNCATION_MARKER = "…" + + +@dataclass(frozen=True) +class BootstrapCandidate: + """Exact public metadata plus untrusted text used only for the current build.""" + + item: BootstrapContextItem + content: str + + +@dataclass(frozen=True) +class BootstrapBuild: + content: str | None + items: tuple[BootstrapContextItem, ...] = () + truncated: bool = False + + +def build_bootstrap_context(candidates: Sequence[BootstrapCandidate], max_bytes: int) -> BootstrapBuild: + """Fit complete citations and the longest safe body prefixes into one UTF-8 budget.""" + + included: list[BootstrapCandidate] = [] + omitted = False + for candidate in candidates[:7]: + fitted = _fit_candidate(included, candidate, max_bytes) + if fitted is None: + omitted = True + continue + included.append(fitted) + if not included: + return BootstrapBuild(content=None, truncated=omitted) + content = _render(included) + if len(content.encode("utf-8")) > max_bytes: + raise RuntimeError("bootstrap renderer exceeded its byte budget") # noqa: TRY003 + items = tuple(candidate.item for candidate in included) + return BootstrapBuild( + content=content, + items=items, + truncated=omitted or any(item.truncated for item in items), + ) + + +def _fit_candidate( + included: Sequence[BootstrapCandidate], + candidate: BootstrapCandidate, + max_bytes: int, +) -> BootstrapCandidate | None: + chunks = _body_chunks(candidate.content) + capped = _longest_prefix(chunks, _MAX_ITEM_BODY_BYTES) + complete_truncated = len(capped) < len(chunks) + if complete_truncated: + capped = _longest_prefix(chunks, _MAX_ITEM_BODY_BYTES - len(_TRUNCATION_MARKER.encode("utf-8"))) + complete = replace( + candidate, + item=candidate.item.model_copy(update={"truncated": complete_truncated}), + content="".join(capped) + (_TRUNCATION_MARKER if complete_truncated else ""), + ) + if _fits((*included, complete), max_bytes): + return complete + + lower, upper = 0, len(capped) + best: BootstrapCandidate | None = None + while lower <= upper: + middle = (lower + upper) // 2 + shortened = replace( + candidate, + item=candidate.item.model_copy(update={"truncated": True}), + content="".join(capped[:middle]) + _TRUNCATION_MARKER, + ) + if _fits((*included, shortened), max_bytes): + best = shortened + lower = middle + 1 + else: + upper = middle - 1 + if best is None or len(best.content.encode("utf-8")) < _MIN_TRUNCATED_BODY_BYTES: + return None + return best + + +def _longest_prefix(chunks: Sequence[str], max_bytes: int) -> tuple[str, ...]: + used = 0 + selected: list[str] = [] + for chunk in chunks: + size = len(chunk.encode("utf-8")) + if used + size > max_bytes: + break + selected.append(chunk) + used += size + return tuple(selected) + + +def _body_chunks(content: str) -> tuple[str, ...]: + normalized = content.replace("\r\n", "\n").replace("\r", "\n").replace("\u2028", "\n").replace("\u2029", "\n") + return tuple( + _visible_escape(character) + if character != "\n" and unicodedata.category(character) in {"Cc", "Cf"} + else character + for character in normalized + ) + + +def _visible_escape(character: str) -> str: + codepoint = ord(character) + return f"\\u{codepoint:04x}" if codepoint <= 0xFFFF else f"\\U{codepoint:08x}" + + +def _fits(candidates: Sequence[BootstrapCandidate], max_bytes: int) -> bool: + return len(_render(candidates).encode("utf-8")) <= max_bytes + + +def _quoted(value: str) -> str: + return json.dumps(value, ensure_ascii=True) + + +def _render(candidates: Sequence[BootstrapCandidate]) -> str: + parts = ["# PowerContext bootstrap context", TRUST_POLICY, _BEGIN_MARKER] + for number, candidate in enumerate(candidates, start=1): + item = candidate.item + artifact = item.artifact + title = "Committed Handoff" if item.kind == "handoff" else "Curated Memory" + metadata = [ + f"Scope: {_quoted(item.scope_id)}", + f"Artifact: family={_quoted(artifact.family)}, id={_quoted(artifact.artifact_id)}, revision={artifact.revision}", + ] + if item.entry_id is not None and item.entry_version_id is not None: + metadata.append(f"Entry: id={_quoted(item.entry_id)}, version={_quoted(item.entry_version_id)}") + metadata.extend(( + f"Content digest: {_quoted(item.content_digest)}", + f"Truncated: {'yes' if item.truncated else 'no'}", + )) + parts.extend(( + f"## {title} {number}", + "\n".join(f" {line}" for line in metadata), + "\n".join(f"> {line}" for line in candidate.content.split("\n")), + )) + parts.append(_END_MARKER) + return "\n\n".join(parts) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 22e2408ae9..b5e47f483b 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -76,6 +76,7 @@ UsageReportingEmbeddingModel, UsageReportingStructuredGenerator, ) +from powercontext.builtin.persistence.bootstrap_receipts import BootstrapReceiptRepository from powercontext.builtin.persistence.dream_schema import ensure_dream_schema from powercontext.builtin.persistence.memory_index import CompositeMemoryIndex, MemoryIndex from powercontext.builtin.persistence.oceanbase.experience_index import OceanBaseExperienceFTSIndex @@ -522,6 +523,7 @@ async def run_profile(scope_id, high): remote_skill_distribution=contexts.remote_skill_distribution(), statistics_service=contexts.statistics, record_service=contexts.records, + bootstrap_receipts=BootstrapReceiptRepository(contexts.database), prompt_service=contexts.prompts, recall_token_estimator=contexts.estimate_recall_tokens, recall_effort_sink=recall_effort_sink, diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 1128032ff5..989b916f0a 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -17,6 +17,7 @@ from __future__ import annotations from collections.abc import Mapping +from hashlib import sha256 from typing import Annotated, Any, Literal, TypeAlias from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator @@ -56,9 +57,22 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] +BootstrapContextSchema: TypeAlias = Literal["powercontext.bootstrap-context.v1"] +BootstrapContextProfile: TypeAlias = Literal["powercontext.scope-bootstrap.v1"] +BootstrapContextLifecycle: TypeAlias = Literal["startup", "resume", "clear", "compact", "restore", "fork"] +BootstrapContextStatus: TypeAlias = Literal["ready", "empty", "skipped"] +BootstrapReceiptState: TypeAlias = Literal["pending", "injected", "skipped", "failed"] +BootstrapSkipReason: TypeAlias = Literal[ + "disabled", + "no_eligible_context", + "already_delivered", + "preparation_failed", +] ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal PREPARED_CONTEXT_SCHEMA: PreparedContextSchema = "powercontext.prepared-context.v1" +BOOTSTRAP_CONTEXT_SCHEMA: BootstrapContextSchema = "powercontext.bootstrap-context.v1" +BOOTSTRAP_CONTEXT_PROFILE: BootstrapContextProfile = "powercontext.scope-bootstrap.v1" class _PreparedContextModel(BaseModel): @@ -227,6 +241,7 @@ class PrepareContextRequest(_PreparedContextModel): query: Annotated[str, Field(min_length=1, max_length=8192)] max_bytes: Annotated[int, Field(ge=512, le=32768)] = 8000 assembly: ContextAssembly | None = None + bootstrap_receipt_id: Annotated[str, Field(min_length=1, max_length=64)] | None = None @model_validator(mode="before") @classmethod @@ -242,6 +257,13 @@ def validate_query(cls, value: str) -> str: raise ValueError("query must contain non-whitespace content") # noqa: TRY003 return value + @field_validator("bootstrap_receipt_id") + @classmethod + def validate_bootstrap_receipt_id(cls, value: str | None) -> str | None: + if value is not None and (value != value.strip() or not value.isascii() or not value.isprintable()): + raise ValueError("bootstrap_receipt_id must be a printable exact identifier") # noqa: TRY003 + return value + class PreparedContext(_PreparedContextModel): """Ephemeral context ready for direct injection into one Agent turn.""" @@ -264,6 +286,117 @@ def validate_content(self) -> PreparedContext: return self +class BootstrapContextRequest(_PreparedContextModel): + """Prepare curated context for one host lifecycle boundary without a fake query.""" + + enabled: bool = False + profile: BootstrapContextProfile = BOOTSTRAP_CONTEXT_PROFILE + lifecycle: BootstrapContextLifecycle + integration: Annotated[str, Field(min_length=1, max_length=64)] + event_id: Annotated[str, Field(min_length=1, max_length=512)] | None = None + max_bytes: Annotated[int, Field(ge=512, le=8192)] = 4096 + handoff: ArtifactRef | None = None + + @field_validator("integration", "event_id") + @classmethod + def validate_host_identity(cls, value: str | None) -> str | None: + if value is not None and (value != value.strip() or not value.isascii() or not value.isprintable()): + raise ValueError("host identity must be printable ASCII without surrounding whitespace") # noqa: TRY003 + return value + + @field_validator("handoff") + @classmethod + def validate_handoff(cls, value: ArtifactRef | None) -> ArtifactRef | None: + if value is not None and value.family != "handoff": + raise ValueError("bootstrap handoff must use the handoff family") # noqa: TRY003 + return value + + +class BootstrapContextItem(_PreparedContextModel): + """One delivered item pinned to exact authoritative content.""" + + kind: Literal["memory_entry", "handoff"] + scope_id: Annotated[str, Field(min_length=1, max_length=256)] + artifact: ArtifactRef + entry_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None + entry_version_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None + content_digest: Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] + truncated: bool = False + + @model_validator(mode="after") + def validate_reference(self) -> BootstrapContextItem: + has_entry = self.entry_id is not None or self.entry_version_id is not None + if self.kind == "memory_entry": + if not (self.entry_id is not None and self.entry_version_id is not None): + raise ValueError("memory bootstrap items require an exact entry version") # noqa: TRY003 + if self.artifact.family != "memory": + raise ValueError("memory bootstrap items require a Memory Artifact") # noqa: TRY003 + elif has_entry or self.artifact.family != "handoff": + raise ValueError("handoff bootstrap items require only an exact Handoff Artifact") # noqa: TRY003 + return self + + +class BootstrapDeliveryReceipt(_PreparedContextModel): + """Content-free durable delivery state exposed to a host integration.""" + + receipt_id: Annotated[str, Field(min_length=1, max_length=64)] + state: BootstrapReceiptState + + +class BootstrapContext(_PreparedContextModel): + """A final bounded bootstrap package or a normal non-delivery result.""" + + schema_version: BootstrapContextSchema = Field(default=BOOTSTRAP_CONTEXT_SCHEMA, alias="schema") + status: BootstrapContextStatus + reason: BootstrapSkipReason | None = None + profile: BootstrapContextProfile = BOOTSTRAP_CONTEXT_PROFILE + content: str | None + content_bytes: Annotated[int, Field(ge=0)] + package_digest: Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] | None = None + items: Annotated[tuple[BootstrapContextItem, ...], Field(max_length=7, strict=False)] = () + truncated: bool = False + receipt: BootstrapDeliveryReceipt + + @model_validator(mode="after") + def validate_result(self) -> BootstrapContext: + if self.status == "ready": + if ( + self.reason is not None + or self.receipt.state != "pending" + or self.content is None + or not self.content.strip() + or self.package_digest is None + or not self.items + or len(self.content.encode("utf-8")) != self.content_bytes + or self.package_digest != f"sha256:{sha256(self.content.encode('utf-8')).hexdigest()}" + ): + raise ValueError("ready bootstrap context is incomplete") # noqa: TRY003 + return self + if self.content is not None or self.content_bytes != 0 or self.package_digest is not None or self.items: + raise ValueError("non-ready bootstrap context must not contain package data") # noqa: TRY003 + if self.status == "empty" and self.reason != "no_eligible_context": + raise ValueError("empty bootstrap context requires the no-content reason") # noqa: TRY003 + if self.status == "empty" and self.receipt.state != "skipped": + raise ValueError("empty bootstrap context requires a skipped receipt") # noqa: TRY003 + if self.status == "skipped" and self.reason is None: + raise ValueError("skipped bootstrap context requires a reason") # noqa: TRY003 + if self.status == "skipped" and ( + self.reason == "no_eligible_context" + or (self.reason == "disabled" and self.receipt.state != "skipped") + or (self.reason == "preparation_failed" and self.receipt.state != "failed") + or (self.reason == "already_delivered" and self.receipt.state == "pending") + ): + raise ValueError("skipped bootstrap context has an inconsistent receipt") # noqa: TRY003 + return self + + +class RecordBootstrapDeliveryRequest(_PreparedContextModel): + """Finalize one pending delivery receipt without accepting content or error text.""" + + receipt_id: Annotated[str, Field(min_length=1, max_length=64)] + outcome: Literal["injected", "failed"] + + class MemoryEntryRecord(BaseModel): """An exact entry version together with its state in one Revision.""" diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index e0bde40ee5..fec1c970dd 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -49,6 +49,9 @@ ArtifactPublication, ArtifactRevision, ArtifactRevisionPage, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -142,6 +145,7 @@ ReadinessResponse, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -266,6 +270,7 @@ LIST_REMOTE_SKILL_TARGETS, LIST_SCOPES, LIST_SOURCES, + PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -276,6 +281,7 @@ PUT_PROFILE_POLICY, QUERY_ARTIFACT_TAGS, RECONCILE_REMOTE_SKILLS, + RECORD_BOOTSTRAP_DELIVERY, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, @@ -917,6 +923,19 @@ async def prepare_context(self, request: PrepareContextRequest) -> PreparedConte return await self._request(PREPARE_CONTEXT, request) + async def prepare_bootstrap_context(self, request: BootstrapContextRequest) -> BootstrapContext: + """Prepare curated bounded context for one Agent lifecycle boundary.""" + + return await self._request(PREPARE_BOOTSTRAP_CONTEXT, request) + + async def record_bootstrap_delivery( + self, + request: RecordBootstrapDeliveryRequest, + ) -> BootstrapDeliveryReceipt: + """Finalize one content-free bootstrap delivery receipt.""" + + return await self._request(RECORD_BOOTSTRAP_DELIVERY, request) + async def prepare_handoff(self, request: PrepareHandoffRequest) -> HandoffDraft: """Generate one inspectable Handoff Draft from exact evidence.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 13fa21c671..4617931b4e 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -67,6 +67,17 @@ AssignableSubjectType, AuthorizationNote, BaseArtifactFamily, + BootstrapContext, + BootstrapContextItem, + BootstrapContextItemKind, + BootstrapContextLifecycle, + BootstrapContextProfile, + BootstrapContextRequest, + BootstrapContextSchema, + BootstrapContextStatus, + BootstrapDeliveryReceipt, + BootstrapReceiptState, + BootstrapSkipReason, BuiltinPromptInstructions, CandidateFamily, CandidateFamilyCount, @@ -276,6 +287,7 @@ ReceiverReadinessCheckStatus, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -457,6 +469,17 @@ "AssignableSubjectType", "AuthorizationNote", "BaseArtifactFamily", + "BootstrapContext", + "BootstrapContextItem", + "BootstrapContextItemKind", + "BootstrapContextLifecycle", + "BootstrapContextProfile", + "BootstrapContextRequest", + "BootstrapContextSchema", + "BootstrapContextStatus", + "BootstrapDeliveryReceipt", + "BootstrapReceiptState", + "BootstrapSkipReason", "BuiltinPromptInstructions", "CandidateFamily", "CandidateFamilyCount", @@ -666,6 +689,7 @@ "ReceiverReadinessCheckStatus", "ReconcileRemoteSkillsRequest", "ReconcileRemoteSkillsResponse", + "RecordBootstrapDeliveryRequest", "RecordRemoteSkillReceiptRequest", "RecordSkillUsageRequest", "RecordTaskOutcomeRequest", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 7773fd051a..81190688d7 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -1408,6 +1408,20 @@ class MemoryCitation(BaseModel): entry_version_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] +class Outcome1(StrEnum): + INJECTED = "injected" + FAILED = "failed" + + +class RecordBootstrapDeliveryRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + receipt_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + outcome: Outcome1 + + class ContextAssemblyFamily(StrEnum): MEMORY = "memory" EXPERIENCE = "experience" @@ -2037,6 +2051,48 @@ class PreparedContextStatus(StrEnum): EMPTY = "empty" +class BootstrapContextSchema(StrEnum): + POWERCONTEXT_BOOTSTRAP_CONTEXT_V1 = "powercontext.bootstrap-context.v1" + + +class BootstrapContextProfile(StrEnum): + POWERCONTEXT_SCOPE_BOOTSTRAP_V1 = "powercontext.scope-bootstrap.v1" + + +class BootstrapContextLifecycle(StrEnum): + STARTUP = "startup" + RESUME = "resume" + CLEAR = "clear" + COMPACT = "compact" + RESTORE = "restore" + FORK = "fork" + + +class BootstrapContextStatus(StrEnum): + READY = "ready" + EMPTY = "empty" + SKIPPED = "skipped" + + +class BootstrapContextItemKind(StrEnum): + MEMORY_ENTRY = "memory_entry" + HANDOFF = "handoff" + + +class BootstrapReceiptState(StrEnum): + PENDING = "pending" + INJECTED = "injected" + SKIPPED = "skipped" + FAILED = "failed" + + +class BootstrapSkipReason(StrEnum): + DISABLED = "disabled" + NO_ELIGIBLE_CONTEXT = "no_eligible_context" + ALREADY_DELIVERED = "already_delivered" + PREPARATION_FAILED = "preparation_failed" + + class EntryChangeOperation(StrEnum): ADD = "add" REVISE = "revise" @@ -2810,6 +2866,43 @@ class PreparedContext(BaseModel): content_bytes: Annotated[StrictInt, Field(ge=0)] +class BootstrapContextItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: BootstrapContextItemKind + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact: ArtifactReference + entry_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + entry_version_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + truncated: StrictBool + + +class BootstrapDeliveryReceipt(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + receipt_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + state: BootstrapReceiptState + + +class BootstrapContext(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + schema_: Annotated[BootstrapContextSchema, Field(alias="schema")] + status: BootstrapContextStatus + reason: Annotated[BootstrapSkipReason | None, Field(...)] + profile: BootstrapContextProfile + content: Annotated[StrictStr | None, Field(...)] + content_bytes: Annotated[StrictInt, Field(ge=0)] + package_digest: Annotated[StrictStr | None, Field(pattern="^sha256:[0-9a-f]{64}$")] + items: Annotated[list[BootstrapContextItem], Field(max_length=7)] + truncated: StrictBool + receipt: BootstrapDeliveryReceipt + + class EntryChange(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3027,6 +3120,30 @@ class MemoryRevisionChanges(BaseModel): changes: list[EntryChange] +class BootstrapContextRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + enabled: StrictBool = False + profile: BootstrapContextProfile = BootstrapContextProfile.POWERCONTEXT_SCOPE_BOOTSTRAP_V1 + lifecycle: BootstrapContextLifecycle + integration: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + event_id: Annotated[ + StrictStr | None, + Field( + description="Optional stable host event identity; persisted only as a digest.", + max_length=512, + min_length=1, + pattern="^[\\x20-\\x7E]+$", + ), + ] = None + max_bytes: Annotated[StrictInt, Field(ge=512, le=8192)] = 4096 + handoff: Annotated[ + ArtifactReference | None, Field(description="Optional exact committed Handoff Revision in the current Scope.") + ] = None + + class ContextAssemblySection(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3653,6 +3770,15 @@ class PrepareContextRequest(BaseModel): query: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] max_bytes: Annotated[StrictInt, Field(ge=512, le=32768)] = 8000 assembly: ContextAssembly | None = None + bootstrap_receipt_id: Annotated[ + StrictStr | None, + Field( + description="Optional exact receipt for the last successfully injected bootstrap package. Only identical Memory entry versions from an injected same-Scope receipt are removed from this query result.", + max_length=64, + min_length=1, + pattern="^[\\x21-\\x7E]+$", + ), + ] = None class GeneratedCandidateResponse(BaseModel): diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index abcc35ad5f..6be9a5000b 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -28,6 +28,9 @@ ArtifactRevisionPage, ArtifactTagPage, ArtifactTagSet, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -121,6 +124,7 @@ ReadinessResponse, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -798,6 +802,56 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) +PREPARE_BOOTSTRAP_CONTEXT = Operation[BootstrapContextRequest, BootstrapContext]( + method="POST", + path="/v1/context/bootstrap", + operation_id="prepare_bootstrap_context", + request_type=BootstrapContextRequest, + request_location="body", + path_parameters=(), + response_type=BootstrapContext, + success_status=200, + summary="Prepare bounded context for an Agent lifecycle boundary", + tags=("context",), + scope_mode="current", + responses={ + 200: { + "description": "A bounded package ready for host injection, or a normal empty/skipped result.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +RECORD_BOOTSTRAP_DELIVERY = Operation[RecordBootstrapDeliveryRequest, BootstrapDeliveryReceipt]( + method="POST", + path="/v1/context/bootstrap/receipts", + operation_id="record_bootstrap_delivery", + request_type=RecordBootstrapDeliveryRequest, + request_location="body", + path_parameters=(), + response_type=BootstrapDeliveryReceipt, + success_status=200, + summary="Record a bootstrap delivery outcome", + tags=("context",), + scope_mode="current", + responses={ + 200: {"description": "Current durable delivery state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + CREATE_WORK_CONTRACT = Operation[CreateWorkContractRequest, WorkSourceReceipt]( method="POST", path="/v1/work/contracts/create", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index ee5007b575..15fcf7bd81 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -828,6 +828,89 @@ "x-powercontext-scope-mode": "current", } }, + "/v1/context/bootstrap": { + "post": { + "tags": ["context"], + "summary": "Prepare bounded context for an Agent lifecycle boundary", + "description": "Prepare an opt-in, deterministic " + "bootstrap package without " + "manufacturing a query. Only " + "explicitly curated active Memory " + "entries and one caller-selected " + "exact committed Handoff are " + "eligible. Returned historical text " + "is untrusted data and grants no " + "authority. A ready receipt remains " + "pending until the host records " + "delivery.", + "operationId": "prepare_bootstrap_context", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BootstrapContextRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "A bounded package ready for host injection, or a normal empty/skipped result.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BootstrapContext"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/context/bootstrap/receipts": { + "post": { + "tags": ["context"], + "summary": "Record a bootstrap delivery outcome", + "description": "Atomically claim one " + "pending content-free " + "receipt as injected or " + "failed. A second injected " + "claim is rejected with 422, " + "so concurrent lifecycle " + "hooks cannot both inject " + "the same package. A " + "terminal outcome never " + "changes.", + "operationId": "record_bootstrap_delivery", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RecordBootstrapDeliveryRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Current durable delivery state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BootstrapDeliveryReceipt"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, "/v1/work/contracts/create": { "post": { "tags": ["work"], @@ -6403,6 +6486,69 @@ "type": "object", "required": ["schema", "status", "content", "content_bytes"], }, + "BootstrapContextItem": { + "properties": { + "kind": {"$ref": "#/components/schemas/BootstrapContextItemKind"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "entry_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "entry_version_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "truncated": {"type": "boolean"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "kind", + "scope_id", + "artifact", + "entry_id", + "entry_version_id", + "content_digest", + "truncated", + ], + }, + "BootstrapDeliveryReceipt": { + "properties": { + "receipt_id": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "state": {"$ref": "#/components/schemas/BootstrapReceiptState"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["receipt_id", "state"], + }, + "BootstrapContext": { + "properties": { + "schema": {"$ref": "#/components/schemas/BootstrapContextSchema"}, + "status": {"$ref": "#/components/schemas/BootstrapContextStatus"}, + "reason": {"allOf": [{"$ref": "#/components/schemas/BootstrapSkipReason"}], "nullable": True}, + "profile": {"$ref": "#/components/schemas/BootstrapContextProfile"}, + "content": {"type": "string", "nullable": True}, + "content_bytes": {"type": "integer", "minimum": 0.0}, + "package_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "nullable": True}, + "items": { + "items": {"$ref": "#/components/schemas/BootstrapContextItem"}, + "type": "array", + "maxItems": 7, + }, + "truncated": {"type": "boolean"}, + "receipt": {"$ref": "#/components/schemas/BootstrapDeliveryReceipt"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "schema", + "status", + "reason", + "profile", + "content", + "content_bytes", + "package_digest", + "items", + "truncated", + "receipt", + ], + }, "EntryChange": { "properties": { "op": {"$ref": "#/components/schemas/EntryChangeOperation"}, @@ -7519,11 +7665,80 @@ "query": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, "assembly": {"$ref": "#/components/schemas/ContextAssembly"}, + "bootstrap_receipt_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "description": "Optional " + "exact " + "receipt " + "for " + "the " + "last " + "successfully " + "injected " + "bootstrap " + "package. " + "Only " + "identical " + "Memory " + "entry " + "versions " + "from " + "an " + "injected " + "same-Scope " + "receipt " + "are " + "removed " + "from " + "this " + "query " + "result.", + }, }, "additionalProperties": False, "type": "object", "required": ["scope_id", "query"], }, + "BootstrapContextRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "enabled": {"type": "boolean", "default": False}, + "profile": { + "allOf": [{"$ref": "#/components/schemas/BootstrapContextProfile"}], + "default": "powercontext.scope-bootstrap.v1", + }, + "lifecycle": {"$ref": "#/components/schemas/BootstrapContextLifecycle"}, + "integration": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "event_id": { + "type": "string", + "maxLength": 512, + "minLength": 1, + "pattern": "^[\\x20-\\x7E]+$", + "description": "Optional stable host event identity; persisted only as a digest.", + }, + "max_bytes": {"type": "integer", "maximum": 8192.0, "minimum": 512.0, "default": 4096}, + "handoff": { + "$ref": "#/components/schemas/ArtifactReference", + "description": "Optional exact committed Handoff Revision in the current Scope.", + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "lifecycle", "integration"], + }, + "RecordBootstrapDeliveryRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "receipt_id": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "outcome": {"type": "string", "enum": ["injected", "failed"]}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "receipt_id", "outcome"], + }, "ContextAssemblySection": { "properties": { "family": {"$ref": "#/components/schemas/ContextAssemblyFamily"}, @@ -8934,6 +9149,19 @@ "CandidateStatus": {"type": "string", "enum": ["pending", "approved", "rejected"]}, "PreparedContextSchema": {"type": "string", "enum": ["powercontext.prepared-context.v1"]}, "PreparedContextStatus": {"type": "string", "enum": ["ready", "empty"]}, + "BootstrapContextSchema": {"type": "string", "enum": ["powercontext.bootstrap-context.v1"]}, + "BootstrapContextProfile": {"type": "string", "enum": ["powercontext.scope-bootstrap.v1"]}, + "BootstrapContextLifecycle": { + "type": "string", + "enum": ["startup", "resume", "clear", "compact", "restore", "fork"], + }, + "BootstrapContextStatus": {"type": "string", "enum": ["ready", "empty", "skipped"]}, + "BootstrapContextItemKind": {"type": "string", "enum": ["memory_entry", "handoff"]}, + "BootstrapReceiptState": {"type": "string", "enum": ["pending", "injected", "skipped", "failed"]}, + "BootstrapSkipReason": { + "type": "string", + "enum": ["disabled", "no_eligible_context", "already_delivered", "preparation_failed"], + }, "EntryChangeOperation": {"type": "string", "enum": ["add", "revise", "deactivate", "reactivate"]}, "FlushStatus": {"type": "string", "enum": ["idle", "processed"]}, "TopicMemoryFlushStatus": {"type": "string", "enum": ["accepted", "idle"]}, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index fc3a9540bb..73ab0b80d4 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -226,6 +226,9 @@ from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest as RuntimeApproveArtifactCandidateRequest, ) +from powercontext.builtin.runtime import BootstrapContext as RuntimeBootstrapContext +from powercontext.builtin.runtime import BootstrapContextRequest as RuntimeBootstrapContextRequest +from powercontext.builtin.runtime import BootstrapDeliveryReceipt as RuntimeBootstrapDeliveryReceipt from powercontext.builtin.runtime import ( CommitConnectorCheckpoint as RuntimeCommitConnectorCheckpoint, ) @@ -266,6 +269,7 @@ ProposeExperienceRequest as RuntimeProposeExperienceRequest, ) from powercontext.builtin.runtime import ProposeSkillRequest as RuntimeProposeSkillRequest +from powercontext.builtin.runtime import RecordBootstrapDeliveryRequest as RuntimeRecordBootstrapDeliveryRequest from powercontext.builtin.runtime import ( RejectArtifactCandidateRequest as RuntimeRejectArtifactCandidateRequest, ) @@ -399,6 +403,9 @@ ArtifactRevision, ArtifactRevisionPage, BaseArtifactFamily, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, CandidatePermissions, Capabilities, CaptureContentSourceRequest, @@ -495,6 +502,7 @@ ReadinessStatus, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -701,6 +709,7 @@ LIST_SCOPES, LIST_SOURCES, OPENAPI_VERSION, + PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -711,6 +720,7 @@ PUT_PROFILE_POLICY, QUERY_ARTIFACT_TAGS, RECONCILE_REMOTE_SKILLS, + RECORD_BOOTSTRAP_DELIVERY, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, @@ -917,6 +927,26 @@ class _ContextApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedContextApplication: ... +class _ScopedBootstrapApplication(Protocol): + async def prepare( + self, + request: RuntimeBootstrapContextRequest, + /, + *, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None = None, + ) -> RuntimeBootstrapContext: ... + + async def record_delivery( + self, + request: RuntimeRecordBootstrapDeliveryRequest, + /, + ) -> RuntimeBootstrapDeliveryReceipt: ... + + +class _BootstrapApplication(Protocol): + def for_scope(self, scope_id: str, /) -> _ScopedBootstrapApplication: ... + + class _ScopedExperienceApplication(Protocol): async def propose(self, request: RuntimeProposeExperienceRequest, /) -> ExperienceCandidate: ... @@ -1215,6 +1245,7 @@ class ServerApplication(Protocol): records: _RecordApplication ingestion: _RemoteIngestionApplication context: _ContextApplication + bootstrap: _BootstrapApplication experience: _ExperienceApplication external_skills: _ExternalSkillApplication handoff: _HandoffApplication @@ -1406,6 +1437,8 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, FLUSH_MEMORY, flush_memory) _add_route(app, REMEMBER_MEMORY, remember_memory) _add_route(app, SEARCH_MEMORY, search_memory) + _add_route(app, PREPARE_BOOTSTRAP_CONTEXT, prepare_bootstrap_context) + _add_route(app, RECORD_BOOTSTRAP_DELIVERY, record_bootstrap_delivery) _add_route(app, PREPARE_CONTEXT, prepare_context) _add_route(app, CREATE_WORK_CONTRACT, create_work_contract) _add_route(app, HANDOFF_CURRENT_WORK, handoff_current_work) @@ -2906,6 +2939,44 @@ async def authorize_scopes(scope_ids: tuple[str, ...]) -> None: return mapping.prepared_context_response(result) +async def prepare_bootstrap_context( + request: BootstrapContextRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> BootstrapContext: + prepared_request = mapping.bootstrap_context_request(request) + scoped = application.bootstrap.for_scope(request.scope_id) + access = access_control_for_mode( + http_request.app.state.access_control, + mode=http_request.app.state.access_mode, + ) + if access is None: + result = await scoped.prepare(prepared_request) + else: + + async def authorize_scopes(scope_ids: tuple[str, ...]) -> None: + await access.require_all( + _require_principal(), + tuple((AccessAction.SCOPE_READ, ResourceRef.scope(scope_id)) for scope_id in scope_ids), + context=_access_audit_context(PREPARE_BOOTSTRAP_CONTEXT.operation_id), + ) + for scope_id in scope_ids[1:]: + await require_scope_content_ready(http_request, scope_id) + + result = await scoped.prepare(prepared_request, authorize_scopes=authorize_scopes) + return mapping.bootstrap_context_response(result) + + +async def record_bootstrap_delivery( + request: RecordBootstrapDeliveryRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> BootstrapDeliveryReceipt: + result = await application.bootstrap.for_scope(request.scope_id).record_delivery( + mapping.record_bootstrap_delivery_request(request) + ) + return mapping.bootstrap_delivery_response(result) + + async def create_work_contract( request: CreateWorkContractRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -4299,6 +4370,7 @@ def _add_route( "list_memory_entries", "list_memory_changes", "prepare_context", + "prepare_bootstrap_context", "list_managed_skills", "list_artifact_candidates", "get_artifact_candidate", diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 7dfccc17db..52140fb401 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -91,6 +91,9 @@ from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest as RuntimeApproveArtifactCandidateRequest, ) +from powercontext.builtin.runtime import BootstrapContext as RuntimeBootstrapContext +from powercontext.builtin.runtime import BootstrapContextRequest as RuntimeBootstrapContextRequest +from powercontext.builtin.runtime import BootstrapDeliveryReceipt as RuntimeBootstrapDeliveryReceipt from powercontext.builtin.runtime import ( CommitConnectorCheckpoint as RuntimeCommitConnectorCheckpoint, ) @@ -131,6 +134,7 @@ ProposeExperienceRequest as RuntimeProposeExperienceRequest, ) from powercontext.builtin.runtime import ProposeSkillRequest as RuntimeProposeSkillRequest +from powercontext.builtin.runtime import RecordBootstrapDeliveryRequest as RuntimeRecordBootstrapDeliveryRequest from powercontext.builtin.runtime import ( RejectArtifactCandidateRequest as RuntimeRejectArtifactCandidateRequest, ) @@ -186,6 +190,7 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactReference, + BootstrapDeliveryReceipt, CandidateFamily, CandidateStatus, CaptureContentSourceRequest, @@ -281,6 +286,8 @@ WorkSourceKind, WorkSourceReceipt, ) +from powercontext.http import BootstrapContext as TransportBootstrapContext +from powercontext.http import BootstrapContextRequest as TransportBootstrapContextRequest from powercontext.http import ConnectorBinding as HttpConnectorBinding from powercontext.http import ( FailureRecord as TransportFailureRecord, @@ -338,6 +345,7 @@ from powercontext.http import ( PreparedHandoff as TransportPreparedHandoff, ) +from powercontext.http import RecordBootstrapDeliveryRequest as TransportRecordBootstrapDeliveryRequest from powercontext.http import ( RememberMemoryRequest as TransportRememberMemoryRequest, ) @@ -688,6 +696,18 @@ def prepare_context_request(value: TransportPrepareContextRequest) -> PrepareCon return PrepareContextRequest.model_validate_json(value.model_dump_json(exclude={"scope_id"}, exclude_unset=True)) +def bootstrap_context_request(value: TransportBootstrapContextRequest) -> RuntimeBootstrapContextRequest: + return RuntimeBootstrapContextRequest.model_validate_json( + value.model_dump_json(exclude={"scope_id"}, exclude_unset=True) + ) + + +def record_bootstrap_delivery_request( + value: TransportRecordBootstrapDeliveryRequest, +) -> RuntimeRecordBootstrapDeliveryRequest: + return RuntimeRecordBootstrapDeliveryRequest.model_validate_json(value.model_dump_json(exclude={"scope_id"})) + + def activate_handoff_request(value: ActivateHandoffRequest) -> ActivateHandoff: return ActivateHandoff( boundary_source=runtime_source_reference(value.boundary_source), @@ -850,6 +870,14 @@ def prepared_context_response(value: PreparedContext) -> TransportPreparedContex }) +def bootstrap_context_response(value: RuntimeBootstrapContext) -> TransportBootstrapContext: + return TransportBootstrapContext.model_validate_json(value.model_dump_json(by_alias=True)) + + +def bootstrap_delivery_response(value: RuntimeBootstrapDeliveryReceipt) -> BootstrapDeliveryReceipt: + return BootstrapDeliveryReceipt.model_validate_json(value.model_dump_json()) + + def entries_response(value: MemoryEntriesPage) -> ListMemoryEntriesResponse: return ListMemoryEntriesResponse( memory=None if value.memory_ref is None else artifact_reference(value.memory_ref), diff --git a/tests/builtin/runtime/test_bootstrap_context.py b/tests/builtin/runtime/test_bootstrap_context.py new file mode 100644 index 0000000000..29017c51fa --- /dev/null +++ b/tests/builtin/runtime/test_bootstrap_context.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. + +from __future__ import annotations + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.runtime.bootstrap_context import BootstrapCandidate, build_bootstrap_context +from powercontext.builtin.runtime.models import BootstrapContextItem + + +def test_bootstrap_item_body_limit_includes_the_truncation_marker() -> None: + item = BootstrapContextItem( + kind="memory_entry", + scope_id="scope:test", + artifact=ArtifactRef(family="memory", artifact_id="memory", revision=1), + entry_id="entry", + entry_version_id="version", + content_digest="sha256:" + ("0" * 64), + ) + + build = build_bootstrap_context((BootstrapCandidate(item=item, content="x" * 2001),), max_bytes=8192) + + assert build.content is not None + body = next(line.removeprefix("> ") for line in build.content.splitlines() if line.startswith("> ")) + assert body == ("x" * 1997) + "…" + assert len(body.encode("utf-8")) == 2000 + assert build.items[0].truncated is True diff --git a/tests/claude_code_plugin/conftest.py b/tests/claude_code_plugin/conftest.py index 7a6801d927..b3bea7bbfa 100644 --- a/tests/claude_code_plugin/conftest.py +++ b/tests/claude_code_plugin/conftest.py @@ -26,8 +26,10 @@ CLAUDE_CODE_ROOT = REPOSITORY_ROOT / "integrations" / "claude-code" PLUGIN_ROOT = CLAUDE_CODE_ROOT / "plugins" / "powercontext" _PLUGIN_MODULE_NAMES = ( + "bootstrap_state", "claude_code_settings", "hooks", + "hooks.bootstrap_context", "hooks.prepared_context", "scripts", "scripts.workspace_scope", @@ -78,6 +80,15 @@ def hook_module(plugin_imports: None) -> ModuleType: @pytest.fixture(autouse=True) 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")) + + +@pytest.fixture +def session_start_module(plugin_imports: None) -> ModuleType: + return _load_module( + "powercontext_claude_code_session_start", + PLUGIN_ROOT / "hooks" / "session_start.py", + ) @pytest.fixture diff --git a/tests/claude_code_plugin/test_bootstrap.py b/tests/claude_code_plugin/test_bootstrap.py new file mode 100644 index 0000000000..21636d6471 --- /dev/null +++ b/tests/claude_code_plugin/test_bootstrap.py @@ -0,0 +1,316 @@ +# 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. + +from __future__ import annotations + +import io +import json +import sys +from hashlib import sha256 +from pathlib import Path +from types import ModuleType + +import pytest + + +def _ready(content: str = "curated bootstrap context") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "ready", + "reason": None, + "profile": "powercontext.scope-bootstrap.v1", + "content": content, + "content_bytes": len(content.encode()), + "package_digest": "sha256:" + sha256(content.encode()).hexdigest(), + "items": [ + { + "kind": "memory_entry", + "scope_id": "scope:test", + "artifact": {"family": "memory", "artifact_id": "memory", "revision": 3}, + "entry_id": "entry-1", + "entry_version_id": "entry-version-1", + "content_digest": "sha256:" + "a" * 64, + "truncated": False, + } + ], + "truncated": False, + "receipt": {"receipt_id": "bcr_test", "state": "pending"}, + } + + +def _skipped(reason: str = "disabled") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "skipped", + "reason": reason, + "profile": "powercontext.scope-bootstrap.v1", + "content": None, + "content_bytes": 0, + "package_digest": None, + "items": [], + "truncated": False, + "receipt": {"receipt_id": "bcr_skipped", "state": "skipped"}, + } + + +def _empty() -> dict[str, object]: + value = _skipped("no_eligible_context") + value["status"] = "empty" + return value + + +def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( + session_start_module: ModuleType, + hook_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + requests: list[tuple[str, dict[str, object]]] = [] + + def post(path, payload, **_kwargs): + requests.append((path, payload)) + if path.endswith("/receipts"): + return {"receipt_id": "bcr_test", "state": "injected"} + return _ready() + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "SessionStart", + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + settings = session_start_module.ClaudeCodePluginSettings(bootstrap_context=True) + + assert session_start_module.main(settings) == 0 + assert json.loads(output.getvalue()) == { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "curated bootstrap context", + } + } + assert requests[0] == ( + "/v1/context/bootstrap", + { + "scope_id": "scope:test", + "enabled": True, + "profile": "powercontext.scope-bootstrap.v1", + "lifecycle": "startup", + "integration": "claude-code", + "max_bytes": 4096, + "event_id": "event-1", + }, + ) + assert requests[1] == ( + "/v1/context/bootstrap/receipts", + {"scope_id": "scope:test", "receipt_id": "bcr_test", "outcome": "injected"}, + ) + + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + state = state_files[0].read_text() + assert "curated bootstrap context" not in state + assert "event-1" not in state + assert "session-1" not in state + + prepared_receipts: list[str | None] = [] + + def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): + prepared_receipts.append(bootstrap_receipt_id) + return {"schema": "powercontext.prepared-context.v1", "status": "empty", "content": None, "content_bytes": 0} + + monkeypatch.setattr(hook_module, "_prepare_context", prepare) + monkeypatch.setattr(hook_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies?", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert hook_module.main(hook_module.ClaudeCodePluginSettings(capture_prompts=False)) == 0 + assert prepared_receipts == ["bcr_test"] + + +@pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "fork"]) +def test_session_start_preserves_distinct_lifecycle_sources( + lifecycle: str, + session_start_module: 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, + "_post_json", + lambda _path, payload, **_kwargs: requests.append(payload) or _skipped(), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": lifecycle, "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings()) == 0 + assert output.getvalue() == "" + assert requests[0]["lifecycle"] == lifecycle + assert requests[0]["enabled"] is False + + +def test_session_start_fails_open_and_does_not_inject_when_delivery_cannot_be_recorded( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + def post(path, _payload, **_kwargs): + nonlocal calls + calls += 1 + if path == "/v1/context/bootstrap": + return _ready() + raise OSError + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "resume", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert calls == 3 + assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" + + +def test_session_start_rejects_malformed_bootstrap_without_blocking( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert json.loads(errors.getvalue())["outcome"] == "invalid_response" + + +@pytest.mark.parametrize("result", [_empty(), TimeoutError()]) +def test_session_start_empty_or_timeout_fails_open( + result: dict[str, object] | TimeoutError, + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def post(*_args, **_kwargs): + if isinstance(result, BaseException): + raise result + return result + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == ("server_unavailable" if isinstance(result, BaseException) else "empty") + assert diagnostic["profile"] == "powercontext.scope-bootstrap.v1" + assert "content" not in diagnostic + + +@pytest.mark.parametrize( + ("http_status", "outcome"), + [ + (401, "authentication_failed"), + (403, "authorization_denied"), + (404, "version_mismatch"), + (503, "server_unavailable"), + (422, "invalid_response"), + ], +) +def test_session_start_classifies_http_failures_without_blocking( + http_status: int, + outcome: str, + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Response: + status = http_status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + monkeypatch.setattr(session_start_module, "open_bounded", lambda *_args, **_kwargs: Response()) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + errors = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == outcome + assert diagnostic["http_status"] == http_status + assert "content" not in diagnostic diff --git a/tests/claude_code_plugin/test_contract.py b/tests/claude_code_plugin/test_contract.py index e39c8dea46..46ac11abf1 100644 --- a/tests/claude_code_plugin/test_contract.py +++ b/tests/claude_code_plugin/test_contract.py @@ -37,7 +37,7 @@ def test_repository_exposes_a_claude_marketplace() -> None: "name": "powercontext", "source": "./integrations/claude-code/plugins/powercontext", "description": "Restore project memory and transfer current work from Claude Code", - "version": "0.1.2", + "version": "0.1.3", "category": "Productivity", } ] @@ -47,7 +47,7 @@ def test_plugin_uses_standard_component_discovery() -> None: manifest = json.loads((PLUGIN_ROOT / ".claude-plugin" / "plugin.json").read_text()) assert manifest["name"] == "powercontext" - assert manifest["version"] == "0.1.2" + assert manifest["version"] == "0.1.3" assert "hooks" not in manifest assert "mcpServers" not in manifest assert (PLUGIN_ROOT / "hooks" / "hooks.json").is_file() @@ -58,7 +58,12 @@ def test_plugin_uses_standard_component_discovery() -> None: def test_hook_uses_exec_form_and_does_not_capture_stop() -> None: configuration = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) - assert set(configuration["hooks"]) == {"UserPromptSubmit"} + assert set(configuration["hooks"]) == {"SessionStart", "UserPromptSubmit"} + session_start = configuration["hooks"]["SessionStart"][0] + assert session_start["matcher"] == "startup|resume|clear|compact|fork" + session_hook = session_start["hooks"][0] + assert session_hook["command"] == "python3" + assert session_hook["args"] == ["${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py"] hook = configuration["hooks"]["UserPromptSubmit"][0]["hooks"][0] assert hook["command"] == "python3" assert hook["args"] == ["${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py"] diff --git a/tests/codex_plugin/conftest.py b/tests/codex_plugin/conftest.py index b270bb531f..4f10acb80b 100644 --- a/tests/codex_plugin/conftest.py +++ b/tests/codex_plugin/conftest.py @@ -47,6 +47,7 @@ def recall_module() -> ModuleType: def isolated_diagnostic_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: 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")) monkeypatch.delenv("POWERCONTEXT_CODEX_AUTHORIZATION", raising=False) monkeypatch.delenv("POWERCONTEXT_CODEX_SCOPE_ID", raising=False) monkeypatch.delenv("POWERCONTEXT_CLIENT_API_TOKEN", raising=False) diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py new file mode 100644 index 0000000000..bfe5af3bec --- /dev/null +++ b/tests/codex_plugin/test_bootstrap.py @@ -0,0 +1,314 @@ +# 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. + +from __future__ import annotations + +import io +import json +import sys +from hashlib import sha256 +from pathlib import Path +from types import ModuleType + +import pytest + + +def _ready(content: str = "curated bootstrap context") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "ready", + "reason": None, + "profile": "powercontext.scope-bootstrap.v1", + "content": content, + "content_bytes": len(content.encode()), + "package_digest": "sha256:" + sha256(content.encode()).hexdigest(), + "items": [ + { + "kind": "memory_entry", + "scope_id": "scope:test", + "artifact": {"family": "memory", "artifact_id": "memory", "revision": 3}, + "entry_id": "entry-1", + "entry_version_id": "entry-version-1", + "content_digest": "sha256:" + "a" * 64, + "truncated": False, + } + ], + "truncated": False, + "receipt": {"receipt_id": "bcr_test", "state": "pending"}, + } + + +def _skipped(reason: str = "disabled") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "skipped", + "reason": reason, + "profile": "powercontext.scope-bootstrap.v1", + "content": None, + "content_bytes": 0, + "package_digest": None, + "items": [], + "truncated": False, + "receipt": {"receipt_id": "bcr_skipped", "state": "skipped"}, + } + + +def _empty() -> dict[str, object]: + value = _skipped("no_eligible_context") + value["status"] = "empty" + return value + + +def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( + session_binding_module: ModuleType, + recall_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + requests: list[tuple[str, dict[str, object]]] = [] + + def post(path, payload, **_kwargs): + requests.append((path, payload)) + if path.endswith("/receipts"): + return {"receipt_id": "bcr_test", "state": "injected"} + return _ready() + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "SessionStart", + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + settings = session_binding_module.CodexPluginSettings(bootstrap_context=True) + + assert session_binding_module.main(settings) == 0 + assert json.loads(output.getvalue()) == { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "curated bootstrap context", + } + } + assert requests[0][0] == "/v1/context/bootstrap" + assert requests[0][1] == { + "scope_id": "scope:test", + "enabled": True, + "profile": "powercontext.scope-bootstrap.v1", + "lifecycle": "startup", + "integration": "codex", + "max_bytes": 4096, + "event_id": "event-1", + } + assert requests[1] == ( + "/v1/context/bootstrap/receipts", + {"scope_id": "scope:test", "receipt_id": "bcr_test", "outcome": "injected"}, + ) + + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + state = state_files[0].read_text() + assert "curated bootstrap context" not in state + assert "event-1" not in state + assert "session-1" not in state + + prepared_payloads: list[str | None] = [] + + def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): + prepared_payloads.append(bootstrap_receipt_id) + return {"schema": "powercontext.prepared-context.v1", "status": "empty", "content": None, "content_bytes": 0} + + monkeypatch.setattr(recall_module, "_prepare_context", prepare) + monkeypatch.setattr(recall_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies?", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert recall_module.main(recall_module.CodexPluginSettings(capture_prompts=False)) == 0 + assert prepared_payloads == ["bcr_test"] + + +@pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "restore", "fork"]) +def test_session_start_preserves_distinct_lifecycle_sources( + lifecycle: str, + session_binding_module: 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, + "_post_json", + lambda path, payload, **_kwargs: requests.append(payload) or _skipped(), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": lifecycle, "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_binding_module.main() == 0 + assert output.getvalue() == "" + assert requests[0]["lifecycle"] == lifecycle + assert requests[0]["enabled"] is False + + +def test_session_start_fails_open_and_does_not_inject_when_delivery_cannot_be_recorded( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + def post(path, _payload, **_kwargs): + nonlocal calls + calls += 1 + if path == "/v1/context/bootstrap": + return _ready() + raise OSError + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "resume", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert calls == 3 + assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" + + +def test_session_start_rejects_malformed_bootstrap_without_blocking( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert json.loads(errors.getvalue())["outcome"] == "invalid_response" + + +@pytest.mark.parametrize("result", [_empty(), TimeoutError()]) +def test_session_start_empty_or_timeout_fails_open( + result: dict[str, object] | TimeoutError, + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def post(*_args, **_kwargs): + if isinstance(result, BaseException): + raise result + return result + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == ("server_unavailable" if isinstance(result, BaseException) else "empty") + assert diagnostic["profile"] == "powercontext.scope-bootstrap.v1" + assert "content" not in diagnostic + + +@pytest.mark.parametrize( + ("http_status", "outcome"), + [ + (401, "authentication_failed"), + (403, "authorization_denied"), + (404, "version_mismatch"), + (503, "server_unavailable"), + (422, "invalid_response"), + ], +) +def test_session_start_classifies_http_failures_without_blocking( + http_status: int, + outcome: str, + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Response: + status = http_status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + monkeypatch.setattr(session_binding_module, "open_bounded", lambda *_args, **_kwargs: Response()) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + errors = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == outcome + assert diagnostic["http_status"] == http_status + assert "content" not in diagnostic diff --git a/tests/codex_plugin/test_contract.py b/tests/codex_plugin/test_contract.py index 8a91b9dfee..2148905c04 100644 --- a/tests/codex_plugin/test_contract.py +++ b/tests/codex_plugin/test_contract.py @@ -266,7 +266,11 @@ def test_codex_settings_environment_authorization_overrides_stored_authorization def test_codex_hooks_fix_session_and_data_plane_bindings() -> None: configuration = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) - assert "session_binding.py" in configuration["hooks"]["SessionStart"][0]["hooks"][0]["command"] + session_start = configuration["hooks"]["SessionStart"][0] + assert session_start["matcher"] == "startup|resume|clear|compact" + session_hook = session_start["hooks"][0] + assert "session_binding.py" in session_hook["command"] + assert session_hook["additionalContextLimit"] == 8192 pre_tool_use = configuration["hooks"]["PreToolUse"][0] assert pre_tool_use["matcher"] == "mcp__powercontext__.*" assert "bind_tools.py" in pre_tool_use["hooks"][0]["command"] diff --git a/tests/e2e/test_access_control_regressions.py b/tests/e2e/test_access_control_regressions.py index 455b10868b..c8d3ef902d 100644 --- a/tests/e2e/test_access_control_regressions.py +++ b/tests/e2e/test_access_control_regressions.py @@ -217,6 +217,96 @@ async def scenario(): asyncio.run(scenario()) +def test_bootstrap_retry_reauthorizes_exact_items_after_context_references_change(tmp_path): + async def scenario(): + async with _server(tmp_path) as (_, client, _): + shared = await _scope(client) + remembered = await client.post( + "/v1/memory/remember", + json={ + "scope_id": shared, + "kind": "constraint", + "text": "PRIVATE bootstrap constraint.", + }, + ) + assert remembered.status_code == 200, remembered.text + citation = remembered.json()["entry"]["citation"] + memory_id = citation["memory_ref"]["artifact_id"] + entry_id = citation["entry_id"] + tags_path = f"/v1/scopes/{shared}/artifacts/memory/{memory_id}/entries/{entry_id}/tags" + tags = await client.get(tags_path) + assert tags.status_code == 200, tags.text + tagged = await client.put( + tags_path, + headers={"If-Match": tags.headers["ETag"]}, + json={"tags": ["bootstrap-context"]}, + ) + assert tagged.status_code == 200, tagged.text + + created = await client.post( + "/v1/scopes", + json={ + "title": "Current bootstrap scope", + "summary": "References reviewed bootstrap context", + "idempotency_key": "bootstrap-current", + "context_references": [shared], + }, + ) + assert created.status_code == 201, created.text + current = created.json()["scope_id"] + await _grant(client, current, "bob", "scope.viewer") + headers = {"Authorization": "Bearer bob"} + payload = { + "scope_id": current, + "enabled": True, + "lifecycle": "resume", + "integration": "access-regression", + "event_id": "stable-bootstrap-event", + } + disabled = await client.post( + "/v1/context/bootstrap", + headers=headers, + json={**payload, "enabled": False, "event_id": "disabled-bootstrap-event"}, + ) + assert disabled.status_code == 200, disabled.text + assert disabled.json()["status"] == "skipped" + assert "PRIVATE bootstrap constraint" not in disabled.text + denied = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert denied.status_code == 403 + assert "PRIVATE bootstrap constraint" not in denied.text + + grant = await _grant(client, shared, "bob", "scope.viewer") + pending = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert pending.status_code == 200, pending.text + assert pending.json()["status"] == "ready" + assert "PRIVATE bootstrap constraint" in pending.json()["content"] + + updated = await client.put( + f"/v1/scopes/{current}", + json={ + "expected_version": created.json()["version"], + "title": created.json()["title"], + "summary": created.json()["summary"], + "context_references": [], + }, + ) + assert updated.status_code == 200, updated.text + revoked = await client.post( + "/v1/access/bindings/revoke", + json={ + "binding_id": grant["binding_id"], + "expected_version": grant["version"], + "idempotency_key": "revoke-bootstrap-reader", + }, + ) + assert revoked.status_code == 200, revoked.text + retry = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert retry.status_code == 403 + assert "PRIVATE bootstrap constraint" not in retry.text + + asyncio.run(scenario()) + + async def _grant(client, scope_id, principal, role, resource=None): response = await client.post( "/v1/access/bindings/create", diff --git a/tests/e2e/test_bootstrap_context.py b/tests/e2e/test_bootstrap_context.py new file mode 100644 index 0000000000..cec9b799aa --- /dev/null +++ b/tests/e2e/test_bootstrap_context.py @@ -0,0 +1,394 @@ +# 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. + +"""End-to-end acceptance for curated lifecycle bootstrap context.""" + +from __future__ import annotations + +import asyncio + +import httpx +from sqlalchemy import select + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.tables import CONTEXT_BOOTSTRAP_RECEIPTS_TABLE +from powercontext.client import PowerContextClient +from powercontext.http import ( + BootstrapContextRequest, + ContextReference, + CreateArtifactRequest, + CreateScopeRequest, + CreateSourceRequest, + ListMemoryEntriesRequest, + PrepareContextRequest, + RecordBootstrapDeliveryRequest, + RememberMemoryRequest, + ReplaceArtifactTagsRequest, + ReviseMemoryEntryRequest, +) +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, MetricsConfig, ServerSettings + + +def test_bootstrap_is_opt_in_exact_bounded_idempotent_and_deduplicates_first_query(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'bootstrap.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Bootstrap", summary="Lifecycle context", idempotency_key="bootstrap") + ) + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="decision", + text="BOOTSTRAP-ONLY: regenerate the OpenAPI bindings before contract tests. " + + "中文上下文。" * 120, + ) + ) + assert remembered.entry is not None + entry = remembered.entry + tags = await client.get_memory_entry_tags( + scope.scope_id, + entry.citation.memory_ref.artifact_id, + entry.citation.entry_id, + ) + assert tags is not None + await client.replace_memory_entry_tags( + scope.scope_id, + entry.citation.memory_ref.artifact_id, + entry.citation.entry_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["bootstrap-context"]}), + expected_etag=tags.etag, + ) + secret = await client.remember_memory( + RememberMemoryRequest(scope_id=scope.scope_id, kind="credential", text="do-not-inject-secret") + ) + assert secret.entry is not None + secret_tags = await client.get_memory_entry_tags( + scope.scope_id, + secret.entry.citation.memory_ref.artifact_id, + secret.entry.citation.entry_id, + ) + assert secret_tags is not None + await client.replace_memory_entry_tags( + scope.scope_id, + secret.entry.citation.memory_ref.artifact_id, + secret.entry.citation.entry_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["bootstrap-context"]}), + expected_etag=secret_tags.etag, + ) + + disabled = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "disabled-event", + }) + ) + assert disabled.status == "skipped" + assert disabled.reason == "disabled" + assert disabled.receipt.state == "skipped" + + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "stable-event-1", + "max_bytes": 1024, + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + assert prepared.content is not None + assert "BOOTSTRAP-ONLY" in prepared.content + assert "do-not-inject-secret" not in prepared.content + assert prepared.content_bytes == len(prepared.content.encode("utf-8")) <= 1024 + assert prepared.truncated is True + assert len(prepared.items) == 1 + assert prepared.items[0].entry_version_id == entry.citation.entry_version_id + assert prepared.items[0].truncated is True + + retried = await client.prepare_bootstrap_context(request) + assert retried.content == prepared.content + assert retried.receipt == prepared.receipt + recorded = await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "injected", + }) + ) + assert recorded.state == "injected" + duplicate_claim = await transport.post( + "/v1/context/bootstrap/receipts", + json={"scope_id": scope.scope_id, "receipt_id": prepared.receipt.receipt_id, "outcome": "injected"}, + ) + assert duplicate_claim.status_code == 422 + repeated_record = await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "failed", + }) + ) + assert repeated_record.state == "injected" + terminal_retry = await client.prepare_bootstrap_context(request) + assert terminal_retry.status == "skipped" + assert terminal_retry.reason == "already_delivered" + assert terminal_retry.receipt.state == "injected" + + ordinary = await client.prepare_context( + PrepareContextRequest(scope_id=scope.scope_id, query="regenerate OpenAPI bindings contract tests") + ) + assert ordinary.status == "ready" + deduplicated = await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="regenerate OpenAPI bindings contract tests", + bootstrap_receipt_id=prepared.receipt.receipt_id, + ) + ) + assert deduplicated.status == "empty" + + current_entries = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id=scope.scope_id)) + current_citation = next( + item.citation for item in current_entries.entries if item.citation.entry_id == entry.citation.entry_id + ) + revised = await client.revise_memory_entry( + ReviseMemoryEntryRequest( + scope_id=scope.scope_id, + citation=current_citation, + kind="decision", + text="BOOTSTRAP-ONLY: regenerate both Python and JavaScript bindings.", + ) + ) + assert revised.entry is not None + after_revision = await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="regenerate Python JavaScript bindings", + bootstrap_receipt_id=prepared.receipt.receipt_id, + ) + ) + assert after_revision.status == "ready" + assert ( + after_revision.content is not None and revised.entry.citation.entry_version_id in after_revision.content + ) + + pending_retry_request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "stable-event-2", + "max_bytes": 1024, + }) + pending_before_revision = await client.prepare_bootstrap_context(pending_retry_request) + assert pending_before_revision.status == "ready" + changed_again = await client.revise_memory_entry( + ReviseMemoryEntryRequest( + scope_id=scope.scope_id, + citation=revised.entry.citation, + kind="decision", + text="SECOND-REVISION: this must not change a pending bootstrap retry.", + ) + ) + assert changed_again.entry is not None + stable_retry = await client.prepare_bootstrap_context(pending_retry_request) + assert stable_retry.receipt == pending_before_revision.receipt + assert stable_retry.content == pending_before_revision.content + assert stable_retry.items == pending_before_revision.items + assert stable_retry.content is not None and "SECOND-REVISION" not in stable_retry.content + + disabled_retry = await client.prepare_bootstrap_context( + pending_retry_request.model_copy(update={"enabled": False}) + ) + assert disabled_retry.status == "skipped" + assert disabled_retry.reason == "disabled" + assert disabled_retry.content is None + assert disabled_retry.receipt.state == "skipped" + assert disabled_retry.receipt.receipt_id == pending_before_revision.receipt.receipt_id + reenabled_same_event = await client.prepare_bootstrap_context(pending_retry_request) + assert reenabled_same_event.status == "skipped" + assert reenabled_same_event.reason == "already_delivered" + + database = app.state.application._bootstrap_receipts._database + async with database.transaction() as connection: + row = ( + ( + await connection.execute( + select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id == prepared.receipt.receipt_id + ) + ) + ) + .mappings() + .one() + ) + serialized = repr(dict(row)) + assert "BOOTSTRAP-ONLY" not in serialized + assert "regenerate OpenAPI" not in serialized + assert "stable-event-1" not in serialized + + asyncio.run(scenario()) + + +def test_bootstrap_includes_only_the_explicit_exact_handoff(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'handoff-bootstrap.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Handoff", summary="Exact bootstrap", idempotency_key="handoff-bootstrap") + ) + evidence = await client.create_source(scope.scope_id, CreateSourceRequest(content="Verified state")) + created = await client.create_artifact( + scope.scope_id, + CreateArtifactRequest.model_validate({ + "family": "handoff", + "content": { + "schema": "powercontext.handoff.v1", + "objective": "Continue exact bootstrap work.", + "state": [ + { + "text": "The bootstrap API is implemented.", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": evidence.source_id}, + } + ], + } + ], + "disposition": "continuable", + "next_action": None, + "omissions": [], + }, + }), + ) + exact = {"family": "handoff", "artifact_id": created.artifact_id, "revision": created.revision} + without_selection = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + }) + ) + assert without_selection.status == "empty" + selected = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "handoff": exact, + }) + ) + assert selected.status == "ready" + assert selected.content is not None and "Continue exact bootstrap work." in selected.content + assert [(item.kind, item.artifact.revision) for item in selected.items] == [("handoff", 1)] + + asyncio.run(scenario()) + + +def test_bootstrap_considers_only_the_first_eight_direct_context_references(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'bounded-references.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + references = [ + await client.create_scope( + CreateScopeRequest( + title=f"Reference {index}", summary="Bounded scan", idempotency_key=f"ref-{index}" + ) + ) + for index in range(9) + ] + last_reference = max(scope.scope_id for scope in references) + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=last_reference, + kind="decision", + text="NINTH-REFERENCE: this is outside the bootstrap scan.", + ) + ) + assert remembered.entry is not None + citation = remembered.entry.citation + tags = await client.get_memory_entry_tags( + last_reference, citation.memory_ref.artifact_id, citation.entry_id + ) + assert tags is not None + await client.replace_memory_entry_tags( + last_reference, + citation.memory_ref.artifact_id, + citation.entry_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["bootstrap-context"]}), + expected_etag=tags.etag, + ) + current = await client.create_scope( + CreateScopeRequest( + title="Current", + summary="Nine direct references", + idempotency_key="current-bounded", + context_references=[ContextReference(root=scope.scope_id) for scope in references], + ) + ) + request = BootstrapContextRequest.model_validate({ + "scope_id": current.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + }) + bounded = await client.prepare_bootstrap_context(request) + assert bounded.status == "empty" + assert bounded.reason == "no_eligible_context" + + direct = await client.prepare_bootstrap_context(request.model_copy(update={"scope_id": last_reference})) + assert direct.status == "ready" + assert direct.content is not None and "NINTH-REFERENCE" in direct.content + + asyncio.run(scenario()) diff --git a/tests/e2e/test_bootstrap_host_service_chain.py b/tests/e2e/test_bootstrap_host_service_chain.py new file mode 100644 index 0000000000..1f169edbd3 --- /dev/null +++ b/tests/e2e/test_bootstrap_host_service_chain.py @@ -0,0 +1,225 @@ +# 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. + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path + +import httpx +import pytest +import uvicorn + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.client import PowerContextClient +from powercontext.http import ( + CreateScopeRequest, + RememberMemoryRequest, + ReplaceArtifactTagsRequest, +) +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, MetricsConfig, ServerSettings + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PLUGIN_ROOTS = { + "codex": PROJECT_ROOT / "integrations" / "codex" / "plugins" / "powercontext", + "claude-code": PROJECT_ROOT / "integrations" / "claude-code" / "plugins" / "powercontext", +} +BOOTSTRAP_TEXT = "HOST-REAL-BOOTSTRAP: use the exact packaged startup context chain." + + +@pytest.mark.parametrize("host", ["codex", "claude-code"]) +def test_packaged_host_hooks_complete_the_live_bootstrap_chain(tmp_path: Path, host: str) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / f'{host}.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + address, port = listener.getsockname() + base_url = f"http://{address}:{port}" + server = uvicorn.Server(uvicorn.Config(app, log_level="critical", lifespan="on")) + thread = threading.Thread(target=server.run, kwargs={"sockets": [listener]}, daemon=True) + thread.start() + try: + _wait_until_started(server, thread) + scope_id = asyncio.run(_seed_bootstrap_memory(base_url, host)) + plugin = _copy_plugin(tmp_path, host, base_url) + environment = _hook_environment(tmp_path, host, base_url, scope_id) + session_id = f"{host}-bootstrap-session" + + started = _run_hook( + plugin, + _session_start_script(host), + environment, + { + "hook_event_name": "SessionStart", + "source": "startup", + "session_id": session_id, + "cwd": str(PROJECT_ROOT), + }, + ) + bootstrap = json.loads(started.stdout)["hookSpecificOutput"] + assert bootstrap["hookEventName"] == "SessionStart" + assert BOOTSTRAP_TEXT in bootstrap["additionalContext"] + + first_prompt = _run_hook( + plugin, + _user_prompt_script(host), + environment, + _user_prompt_payload(host, session_id), + ) + assert first_prompt.stdout == "" + assert '"outcome":"empty"' in first_prompt.stderr + + ordinary_recall = _run_hook( + plugin, + _user_prompt_script(host), + environment, + _user_prompt_payload(host, f"{session_id}-without-bootstrap"), + ) + recalled = json.loads(ordinary_recall.stdout)["hookSpecificOutput"] + assert recalled["hookEventName"] == "UserPromptSubmit" + assert BOOTSTRAP_TEXT in recalled["additionalContext"] + finally: + server.should_exit = True + thread.join(timeout=10) + listener.close() + assert not thread.is_alive() + + +async def _seed_bootstrap_memory(base_url: str, host: str) -> str: + async with ( + httpx.AsyncClient(trust_env=False) as transport, + PowerContextClient(base_url, http_client=transport, trust_transport_security=True) as client, + ): + scope = await client.create_scope( + CreateScopeRequest( + title=f"{host} bootstrap acceptance", + summary="Live packaged hook acceptance for lifecycle bootstrap context.", + idempotency_key=f"{host}-bootstrap-host-service-chain", + ) + ) + remembered = await client.remember_memory( + RememberMemoryRequest(scope_id=scope.scope_id, kind="decision", text=BOOTSTRAP_TEXT) + ) + assert remembered.entry is not None + citation = remembered.entry.citation + tags = await client.get_memory_entry_tags( + scope.scope_id, + citation.memory_ref.artifact_id, + citation.entry_id, + ) + assert tags is not None + await client.replace_memory_entry_tags( + scope.scope_id, + citation.memory_ref.artifact_id, + citation.entry_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["bootstrap-context"]}), + expected_etag=tags.etag, + ) + return scope.scope_id + + +def _copy_plugin(tmp_path: Path, host: str, base_url: str) -> Path: + plugin = tmp_path / f"{host}-plugin" + shutil.copytree(PLUGIN_ROOTS[host], plugin, ignore=shutil.ignore_patterns("__pycache__", ".venv")) + if host == "codex": + configuration_path = plugin / ".mcp.json" + configuration = json.loads(configuration_path.read_text(encoding="utf-8")) + configuration["mcpServers"]["powercontext"]["url"] = f"{base_url}/mcp" + configuration_path.write_text(json.dumps(configuration), encoding="utf-8") + return plugin + + +def _hook_environment(tmp_path: Path, host: str, base_url: str, scope_id: str) -> dict[str, str]: + environment: dict[str, str] = dict(os.environ) + environment.update({"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"}) + if host == "codex": + environment.pop("POWERCONTEXT_CODEX_AUTHORIZATION", None) + environment.update({ + "POWERCONTEXT_CODEX_SCOPE_ID": scope_id, + "POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT": "true", + "POWERCONTEXT_CODEX_CAPTURE_PROMPTS": "false", + "POWERCONTEXT_CODEX_HTTP_BUDGET_SECONDS": "10", + "POWERCONTEXT_CODEX_REQUEST_TIMEOUT_SECONDS": "5", + "POWERCONTEXT_BOOTSTRAP_STATE_DIR": str(tmp_path / "codex-state"), + }) + else: + environment.pop("POWERCONTEXT_CLAUDE_AUTHORIZATION", None) + environment.update({ + "POWERCONTEXT_CLAUDE_SERVER_URL": base_url, + "POWERCONTEXT_CLAUDE_SCOPE_ID": scope_id, + "POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT": "true", + "POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS": "false", + "POWERCONTEXT_CLAUDE_HTTP_BUDGET_SECONDS": "10", + "POWERCONTEXT_CLAUDE_REQUEST_TIMEOUT_SECONDS": "5", + "POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR": str(tmp_path / "claude-state"), + }) + return environment + + +def _session_start_script(host: str) -> str: + return "session_binding.py" if host == "codex" else "session_start.py" + + +def _user_prompt_script(host: str) -> str: + return "recall.py" if host == "codex" else "user_prompt_submit.py" + + +def _user_prompt_payload(host: str, session_id: str) -> dict[str, str]: + payload = { + "hook_event_name": "UserPromptSubmit", + "cwd": str(PROJECT_ROOT), + "prompt": BOOTSTRAP_TEXT, + "session_id": session_id, + } + payload["turn_id" if host == "codex" else "prompt_id"] = "first-prompt" + return payload + + +def _run_hook( + plugin: Path, + script: str, + environment: dict[str, str], + payload: dict[str, str], +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(plugin / "hooks" / script)], + cwd=PROJECT_ROOT, + env=environment, + input=json.dumps(payload), + text=True, + capture_output=True, + check=True, + timeout=15, + ) + + +def _wait_until_started(server: uvicorn.Server, thread: threading.Thread) -> None: + deadline = time.monotonic() + 10 + while thread.is_alive() and not server.started and time.monotonic() < deadline: + time.sleep(0.01) + assert server.started diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index a6a81a5c54..d6843296c8 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -29,6 +29,9 @@ ArtifactCreated, ArtifactReference, ArtifactRevision, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, CaptureContentSourceRequest, CaptureContentSourceResponse, CommitHandoffRequest, @@ -67,6 +70,7 @@ PrepareHandoffRequest, ProposeExperienceRequest, ProposeSkillRequest, + RecordBootstrapDeliveryRequest, RecordTaskOutcomeRequest, ResolveExternalSkillRequest, ReviseArtifactCandidateRequest, @@ -123,6 +127,7 @@ LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, LIST_REMOTE_SKILL_TARGETS, + PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -131,6 +136,7 @@ PUBLISH_ARTIFACT, PUBLISH_REMOTE_SKILL, RECONCILE_REMOTE_SKILLS, + RECORD_BOOTSTRAP_DELIVERY, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, @@ -470,11 +476,41 @@ def test_prepared_context_is_a_generic_typed_operation_outside_the_mcp_memory_to contract = yaml.safe_load(CONTRACT_PATH.read_text()) schemas = contract["components"]["schemas"] - assert set(schemas["PrepareContextRequest"]["properties"]) == {"scope_id", "query", "max_bytes", "assembly"} + assert set(schemas["PrepareContextRequest"]["properties"]) == { + "scope_id", + "query", + "max_bytes", + "assembly", + "bootstrap_receipt_id", + } assert set(schemas["PreparedContext"]["properties"]) == {"schema", "status", "content", "content_bytes"} assert not {"memory", "mode", "selection"} & set(schemas["PreparedContext"]["properties"]) +def test_bootstrap_context_has_typed_prepare_and_delivery_operations() -> None: + assert PREPARE_BOOTSTRAP_CONTEXT.path == "/v1/context/bootstrap" + assert PREPARE_BOOTSTRAP_CONTEXT.request_type is BootstrapContextRequest + assert PREPARE_BOOTSTRAP_CONTEXT.response_type is BootstrapContext + assert PREPARE_BOOTSTRAP_CONTEXT.success_status == 200 + assert RECORD_BOOTSTRAP_DELIVERY.path == "/v1/context/bootstrap/receipts" + assert RECORD_BOOTSTRAP_DELIVERY.request_type is RecordBootstrapDeliveryRequest + assert RECORD_BOOTSTRAP_DELIVERY.response_type is BootstrapDeliveryReceipt + + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + schemas = contract["components"]["schemas"] + assert schemas["BootstrapContextRequest"]["properties"]["enabled"]["default"] is False + assert schemas["BootstrapContextRequest"]["properties"]["max_bytes"]["maximum"] == 8192 + assert schemas["BootstrapContext"]["properties"]["items"]["maxItems"] == 7 + assert schemas["BootstrapContextLifecycle"]["enum"] == [ + "startup", + "resume", + "clear", + "compact", + "restore", + "fork", + ] + + def test_experience_skill_and_review_operations_are_typed_and_family_routed() -> None: review_operations = ( LIST_ARTIFACT_CANDIDATES, From 9736ac4ef16a3396b58adc94113d887a8c4ecf0b Mon Sep 17 00:00:00 2001 From: chenxu wang <150030641+wangchenxuya@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:35:49 -0400 Subject: [PATCH 2/7] fix(context): harden bootstrap receipt semantics --- docs/en/docs/integrations/claude-code.md | 6 +- docs/en/docs/integrations/codex.md | 7 +- .../en/rfcs/1429_project_bootstrap_context.md | 10 +- docs/zh/docs/integrations/claude-code.md | 5 +- docs/zh/docs/integrations/codex.md | 5 +- .../zh/rfcs/1429_project_bootstrap_context.md | 10 +- .../plugins/powercontext/README.md | 6 +- .../plugins/powercontext/bootstrap_state.py | 3 +- .../powercontext/hooks/bootstrap_context.py | 11 +- .../powercontext/hooks/session_start.py | 58 ++- .../powercontext/hooks/user_prompt_submit.py | 3 +- .../powercontext/.codex-plugin/plugin.json | 2 +- .../codex/plugins/powercontext/README.md | 6 +- .../plugins/powercontext/bootstrap_state.py | 3 +- .../powercontext/hooks/bootstrap_context.py | 11 +- .../plugins/powercontext/hooks/recall.py | 5 +- .../powercontext/hooks/session_binding.py | 63 +++- .../codex/plugins/powercontext/pyproject.toml | 2 +- .../codex/plugins/powercontext/uv.lock | 2 +- openapi/powercontext.yaml | 12 +- .../builtin/persistence/bootstrap_receipts.py | 23 +- .../builtin/persistence/tables.py | 3 + .../builtin/runtime/application.py | 207 +++++++---- src/powercontext/builtin/runtime/models.py | 33 +- src/powercontext/http/_generated/models.py | 8 +- src/powercontext/http/_generated/schema.py | 70 +++- tests/claude_code_plugin/test_bootstrap.py | 135 +++++++ tests/codex_plugin/test_bootstrap.py | 131 +++++++ tests/e2e/test_bootstrap_context.py | 349 +++++++++++++++++- tests/e2e/test_recall_sufficiency_gate.py | 6 + 30 files changed, 1012 insertions(+), 183 deletions(-) diff --git a/docs/en/docs/integrations/claude-code.md b/docs/en/docs/integrations/claude-code.md index 9e2e92eb10..2f0563dd33 100644 --- a/docs/en/docs/integrations/claude-code.md +++ b/docs/en/docs/integrations/claude-code.md @@ -104,8 +104,10 @@ export POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revis Use the Memory-entry tag API to add `bootstrap-context` only to reviewed decisions, constraints, objectives, verified state, or repository guidance. The plugin stores a content-free receipt under `${CLAUDE_PLUGIN_DATA}`; successful -delivery lets the first ordinary query suppress only the exact Memory versions already injected. Revised versions remain -eligible. Preparation, validation, and receipt failures fail open and inject nothing. +delivery lets the first ordinary query suppress only fully delivered exact Memory entry versions. Entries truncated +during bootstrap and revised versions remain eligible. Preparation, validation, and receipt failures fail open and +inject nothing. Failures before a new lifecycle package is established, including invalid configuration, also clear +any stale saved receipt. The Source pipeline may later extract Memory when a generation model is configured. Prompt capture does not call `remember_memory`, and the Hook never labels an ordinary prompt as `task-outcome`. diff --git a/docs/en/docs/integrations/codex.md b/docs/en/docs/integrations/codex.md index 5682784a96..33f1cee459 100644 --- a/docs/en/docs/integrations/codex.md +++ b/docs/en/docs/integrations/codex.md @@ -101,9 +101,10 @@ export POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revisi Use the Memory-entry tag API to add `bootstrap-context` only to reviewed decisions, constraints, objectives, verified state, or repository guidance suitable for every new Session. A content-free delivery receipt makes retries idempotent; -after successful injection, the first ordinary query sends that receipt so the Runtime suppresses only the exact Memory -versions already delivered. A revised version remains eligible for recall. Preparation, validation, and receipt failures -all fail open and inject nothing. +after successful injection, the first ordinary query sends that receipt so the Runtime suppresses only fully delivered +exact Memory entry versions. Entries truncated during bootstrap and revised versions remain eligible for recall. +Preparation, validation, and receipt failures all fail open and inject nothing. +Failures before a new lifecycle package is established, including invalid configuration, also clear any stale saved receipt. ## Choose standard context text diff --git a/docs/en/rfcs/1429_project_bootstrap_context.md b/docs/en/rfcs/1429_project_bootstrap_context.md index 1c9322f926..620ef65791 100644 --- a/docs/en/rfcs/1429_project_bootstrap_context.md +++ b/docs/en/rfcs/1429_project_bootstrap_context.md @@ -32,7 +32,7 @@ Eligible content is deliberately narrow: 1. Up to six active current Memory entries explicitly tagged `bootstrap-context`. The current Scope is considered first, followed by at most eight direct Context References in canonical Scope order. At most 32 tagged entries are inspected per participating Scope. 2. At most one committed Handoff Revision supplied by the caller as an exact `{family, artifact_id, revision}` reference in the current Scope. “Latest Handoff” is never selected implicitly. -The `bootstrap-context` tag is an operator review decision, not an authorization mechanism. Operators must not tag credentials, secrets, personal data, generated instructions that have not been reviewed, or material that should not be injected automatically. The Runtime additionally rejects entries whose kind identifies secret, credential, password, token, or private-key material. Current authorization is still checked for every participating Scope. +The `bootstrap-context` tag is an operator review decision, not an authorization mechanism. Operators must not tag credentials, secrets, personal data, generated instructions that have not been reviewed, or material that should not be injected automatically. The Runtime additionally rejects entries whose normalized kind identifies secret, credential, password, token, API-key, access-key, or private-key material, including punctuation and separator variants. Current authorization is still checked for every participating Scope. The package renderer includes a prominent untrusted-history policy. Current system and developer instructions, the current user request, repository rules, and live validation always take precedence. Historical Markdown is rendered as quoted data rather than executable instructions. @@ -42,9 +42,9 @@ The package renderer includes a prominent untrusted-history policy. Current syst A ready package has a `pending` receipt. Before emitting hook output, the host calls `POST /v1/context/bootstrap/receipts` with `injected` or `failed`. A host must claim the `pending`→`injected` transition successfully before writing `additionalContext`; a second `injected` claim is rejected even if the stored state is already `injected`. If the claim fails, the host emits no bootstrap content. Disabled and empty requests produce `skipped` receipts; a terminal event retry also returns `skipped` without a body. -Receipts store no query, content body, transcript path, raw host event identifier, or host error text. They store only bounded identity metadata, exact content references and digests, byte counts, and state. A supplied event identifier is hashed with the Scope, integration, and lifecycle before persistence. Repeating the same stable event returns the existing result while pending and returns `skipped` after a terminal outcome. If opt-in is revoked while an event is pending, that receipt is terminally skipped without reading Context References. Hosts that expose no stable event identity still get per-invocation receipts but cannot claim restart idempotency. +Receipts store no query, content body, transcript path, raw host event identifier, or host error text. They store only bounded identity metadata, exact content references and digests, byte counts, creation time, and state. A supplied event identifier is hashed with the Scope, integration, and lifecycle before persistence. Package-shaping fields are separately fingerprinted: reusing the same stable event with a different profile, byte budget, or exact Handoff is rejected instead of returning a package built for another request. Repeating an otherwise identical stable event returns the existing result while pending and returns `skipped` after a terminal outcome. If opt-in is revoked while an event is pending, that receipt is terminally skipped without reading Context References. Receipts expire after 30 days and are pruned opportunistically. Disabled or empty invocations without a stable event receive an ephemeral response receipt but do not create a database row. Hosts that expose no stable event identity cannot claim restart idempotency. -`PrepareContextRequest` optionally accepts `bootstrap_receipt_id`. When that receipt belongs to the same Scope and is `injected`, ordinary recall removes only the exact Memory entry versions listed by the receipt. Revised versions remain eligible. An absent, stale, foreign, pending, skipped, or failed receipt is ignored so recall remains fail-open. +`PrepareContextRequest` optionally accepts `bootstrap_receipt_id`. When that receipt belongs to the same Scope and is `injected`, ordinary recall removes only fully delivered exact Memory entry versions listed by the receipt. This happens before candidate limiting and recall-sufficiency assessment, so delivered entries neither satisfy the gate nor displace eligible replacements. Entry identity is independent of a parent Memory Revision advanced by unrelated entries. A partially delivered entry and any revised version remain eligible. An absent, stale, foreign, pending, skipped, or failed receipt is ignored so recall remains fail-open. ## Rendering and bounds @@ -54,7 +54,7 @@ Selection uses exact tag lookup and immutable reads only. It does not embed, rer ## Host behavior -Codex and Claude Code resolve Scope through their existing binding service, then request bootstrap context from `SessionStart`. Their supported lifecycle sources stay distinct in the request and receipt. Both integrations are disabled by default, enforce one wall-clock HTTP budget, validate the complete response schema, persist only the latest receipt ID in the host's plugin data directory, and fail open on timeout, authentication failure, unavailable or older servers, and invalid responses. +Codex and Claude Code resolve Scope through their existing binding service, then request bootstrap context from `SessionStart`. Their supported lifecycle sources stay distinct in the request and receipt. Both integrations are disabled by default, enforce one wall-clock HTTP budget, validate the complete response schema, persist only the latest receipt ID in the host's plugin data directory, retain an already-saved matching receipt when the same terminal injected event is repeated, and fail open on invalid configuration, timeout, authentication failure, unavailable or older servers, and invalid responses. A consumed or cleared receipt is never recreated from terminal server state. Any failure before a new lifecycle package is established clears a stale local receipt. The subsequent `UserPromptSubmit` uses normal query-based recall and passes the saved receipt ID solely as an exact-version deduplication hint. Prompt capture behavior is unchanged. @@ -67,4 +67,4 @@ The subsequent `UserPromptSubmit` uses normal query-based recall and passes the ## Acceptance -Implementation is complete when contract and runtime tests cover opt-in defaults, all lifecycle values, exact Handoff selection, curated Memory ordering, Unicode byte bounds, secret-kind exclusion, authorization across Context References, receipt retry and transition semantics, first-query exact-version deduplication, and content-free storage; and both Codex and Claude Code tests cover successful injection, disabled/empty paths, state handoff to the first query, invalid responses, timeouts, and HTTP failures without blocking the host. +Implementation is complete when contract and runtime tests cover opt-in defaults, all lifecycle values, exact Handoff selection, curated Memory ordering, Unicode byte bounds, normalized secret-kind exclusion, authorization across Context References, receipt retry, request-mismatch and transition semantics, bounded retention, package-level omissions, pre-gate first-query exact-version deduplication across unrelated Memory Revisions, continued recall of partially delivered entries, and content-free storage; and both Codex and Claude Code tests cover successful injection, disabled/empty paths, repeated terminal events, state handoff to the first query, invalid configuration and responses, timeouts, and HTTP failures without blocking the host. diff --git a/docs/zh/docs/integrations/claude-code.md b/docs/zh/docs/integrations/claude-code.md index 77835a921c..0b677a5acd 100644 --- a/docs/zh/docs/integrations/claude-code.md +++ b/docs/zh/docs/integrations/claude-code.md @@ -97,8 +97,9 @@ export POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revis ``` 通过 Memory entry tag API,只把经过审阅的决策、约束、目标、已验证状态或仓库指南标记为 `bootstrap-context`。 -插件在 `${CLAUDE_PLUGIN_DATA}` 下只保存不含正文的 receipt;成功注入后,第一条普通查询仅排除已注入的 exact Memory -versions,新修订版本仍可召回。准备、校验或回执失败时都会 fail open,不注入任何内容。 +插件在 `${CLAUDE_PLUGIN_DATA}` 下只保存不含正文的 receipt;成功注入后,第一条普通查询仅排除正文已完整交付的 +exact Memory entry versions;启动时被截短的条目和新修订版本仍可召回。准备、校验或回执失败时都会 fail open, +不注入任何内容。在新的生命周期包建立前发生失败(包括配置非法)时,还会清除之前保存的过期 receipt。 配置 generation model 后,Source pipeline 可能进一步提取 Memory。提示词采集不会调用 `remember_memory`, Hook 也不会把普通 prompt 标记为 `task-outcome`。 diff --git a/docs/zh/docs/integrations/codex.md b/docs/zh/docs/integrations/codex.md index ba14b5cd11..33fe7b1bea 100644 --- a/docs/zh/docs/integrations/codex.md +++ b/docs/zh/docs/integrations/codex.md @@ -94,8 +94,9 @@ export POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revisi 通过 Memory entry tag API,只把经过审阅、适合每个新 Session 的决策、约束、目标、已验证状态或仓库指南标记为 `bootstrap-context`。不含正文的 delivery receipt 让重试保持幂等;成功注入后,第一条普通查询会携带该 receipt, -Runtime 仅排除已经注入的 exact Memory versions,新修订版本仍可正常召回。准备、校验或回执失败时都会 fail open, -不注入任何内容。 +Runtime 仅排除正文已完整交付的 exact Memory entry versions;启动时被截短的条目和新修订版本仍可正常召回。 +准备、校验或回执失败时都会 fail open,不注入任何内容。 +在新的生命周期包建立前发生失败(包括配置非法)时,还会清除之前保存的过期 receipt。 ## 选择标准上下文文本 diff --git a/docs/zh/rfcs/1429_project_bootstrap_context.md b/docs/zh/rfcs/1429_project_bootstrap_context.md index 39f737b81c..bb39558146 100644 --- a/docs/zh/rfcs/1429_project_bootstrap_context.md +++ b/docs/zh/rfcs/1429_project_bootstrap_context.md @@ -17,7 +17,7 @@ Issue #1429 使用了 Project 和 Workstream 术语。后续 RFC 1345 已确定 1. 最多六条当前有效、且被显式标记 `bootstrap-context` 的 Memory 条目。先选择当前 Scope,再按 Scope 的规范顺序选择最多八个直接 Context Reference;每个参与 Scope 最多检查 32 条带标签的条目。 2. 调用方在当前 Scope 中显式指定的一条精确、已提交 Handoff Revision。服务端不会隐式选择“最新 Handoff”。 -标签表示运维者已经审核其适合自动注入,但不是授权机制。不得标记凭证、秘密、个人数据、未经审核的生成指令或其他不应自动注入的内容。Runtime 还会排除 kind 表明为 secret、credential、password、token 或 private-key 的条目。每个参与 Scope 仍须通过当前授权检查。 +标签表示运维者已经审核其适合自动注入,但不是授权机制。不得标记凭证、秘密、个人数据、未经审核的生成指令或其他不应自动注入的内容。Runtime 还会先归一化标点和分隔符,再排除 kind 表明为 secret、credential、password、token、API-key、access-key 或 private-key 的条目。每个参与 Scope 仍须通过当前授权检查。 完整 transcript、全部 Memory、待审核候选、原始 Source 窗口、生成中的 Profile 以及任意其他 Artifact Family 均不符合资格。选择过程不调用 embedding、rerank、生成或扩展。 @@ -27,9 +27,9 @@ Issue #1429 使用了 Project 和 Workstream 术语。后续 RFC 1345 已确定 `ready` 响应的收据为 `pending`。宿主必须先通过 `POST /v1/context/bootstrap/receipts` 一次性认领 `pending`→`injected` 状态迁移,成功后才能输出 `additionalContext`;第二次认领即使已存状态是 `injected` 也会被拒绝。确认失败时不注入。宿主也可记录 `failed`。关闭和无合格内容会产生 `skipped` 收据;终态事件重试只返回不含正文的 `skipped` 响应。 -收据不保存查询、正文、transcript 路径、原始宿主事件 ID 或宿主错误文本,只保存有界身份元数据、精确内容引用与摘要、字节数和状态。事件 ID 在持久化前与 Scope、集成及生命周期一同哈希。稳定事件重试在 `pending` 时重建同一精确包,终态后返回 `skipped`;若待交付期间关闭开关,收据会直接终结为跳过且不读取 Context Reference。宿主不提供稳定事件标识时,不宣称具备跨重启幂等性。 +收据不保存查询、正文、transcript 路径、原始宿主事件 ID 或宿主错误文本,只保存有界身份元数据、精确内容引用与摘要、字节数、创建时间和状态。事件 ID 在持久化前与 Scope、集成及生命周期一同哈希;画像、字节预算和精确 Handoff 等会影响包内容的字段另行计算指纹。用不同的这些字段复用同一稳定事件会被拒绝,而不会误返回为另一请求生成的包。其余字段相同的稳定事件重试在 `pending` 时重建同一精确包,终态后返回 `skipped`;若待交付期间关闭开关,收据会直接终结为跳过且不读取 Context Reference。收据保留 30 天并在后续请求中顺带清理;无稳定事件标识的关闭或空结果只返回临时收据,不新增数据库行。宿主不提供稳定事件标识时,不宣称具备跨重启幂等性。 -`PrepareContextRequest` 可携带 `bootstrap_receipt_id`。只有当收据属于相同 Scope 且状态为 `injected` 时,普通查询召回才去掉收据中完全相同的 Memory 条目版本;后续修订版仍可召回。缺失、过期、跨 Scope 或非注入收据均被忽略,保持 fail-open。 +`PrepareContextRequest` 可携带 `bootstrap_receipt_id`。只有当收据属于相同 Scope 且状态为 `injected` 时,普通查询召回才去掉收据中完整交付的精确 Memory 条目版本。过滤发生在候选限额和召回充分性判断之前,因此已交付条目既不会误让 gate 判定充分,也不会挤掉仍合格的候选。条目身份不受其他条目推动父 Memory Revision 前进的影响;正文只交付了前缀的条目及后续修订版仍可召回。缺失、过期、跨 Scope 或非注入收据均被忽略,保持 fail-open。 ## 限额与信任边界 @@ -39,10 +39,10 @@ Issue #1429 使用了 Project 和 Workstream 术语。后续 RFC 1345 已确定 ## 宿主集成与失败策略 -Codex 和 Claude Code 复用现有 Scope 绑定,在 `SessionStart` 请求启动包,并保留 `startup`、`resume`、`clear`、`compact`、`restore` 和 `fork` 的独立来源语义。两端均默认关闭,使用统一的绝对 HTTP 时间预算,严格校验响应,只在插件持久数据目录保存最近的收据 ID,并在超时、鉴权失败、无权限、服务不可用、旧版本服务或非法响应时不阻断宿主。失败与空结果使用不含正文的诊断事件区分。 +Codex 和 Claude Code 复用现有 Scope 绑定,在 `SessionStart` 请求启动包,并保留 `startup`、`resume`、`clear`、`compact`、`restore` 和 `fork` 的独立来源语义。两端均默认关闭,使用统一的绝对 HTTP 时间预算,严格校验响应,只在插件持久数据目录保存最近的收据 ID;同一已注入终态事件重放时仅保留本地已经存在且匹配的收据,已经消费或清除的收据不会根据服务端终态重建;配置非法、超时、鉴权失败、无权限、服务不可用、旧版本服务或非法响应均不阻断宿主。在新生命周期包建立前发生的任何失败都会清除过期的本地收据。失败与空结果使用不含正文的诊断事件区分。 随后第一次 `UserPromptSubmit` 仍执行普通基于查询的召回,只把该收据 ID 作为精确版本去重提示;现有 prompt 捕获行为不变。 ## 验收 -契约与 Runtime 测试必须覆盖:默认关闭、全部生命周期、精确 Handoff、策展 Memory 顺序、Unicode 字节限额、秘密 kind 排除、Context Reference 授权、收据重试与状态迁移、首查询精确版本去重,以及数据库不保存正文。Codex 与 Claude Code 测试必须覆盖成功注入、关闭/空结果、向首查询传递收据、非法响应、超时和 HTTP 失败均不阻塞宿主。 +契约与 Runtime 测试必须覆盖:默认关闭、全部生命周期、精确 Handoff、策展 Memory 顺序、Unicode 字节限额、分隔符归一化后的秘密 kind 排除、Context Reference 授权、收据重试、请求冲突、状态迁移与有界保留、包级遗漏、在 gate 前完成的跨无关 Memory Revision 首查询精确版本去重、部分交付条目继续可召回,以及数据库不保存正文。Codex 与 Claude Code 测试必须覆盖成功注入、关闭/空结果、终态事件重放、向首查询传递收据、非法配置与响应、超时和 HTTP 失败均不阻塞宿主。 diff --git a/integrations/claude-code/plugins/powercontext/README.md b/integrations/claude-code/plugins/powercontext/README.md index 46bcd797cd..141a690978 100644 --- a/integrations/claude-code/plugins/powercontext/README.md +++ b/integrations/claude-code/plugins/powercontext/README.md @@ -22,7 +22,11 @@ first prompt on startup, resume, clear, compact, or fork. Enable it with `bootstrap-context` and one explicitly selected exact committed Handoff are eligible. `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` controls the 512–8192-byte budget, and `POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF` supplies the Handoff JSON -identity. The plugin persists only a content-free delivery receipt. +identity. The plugin persists only a content-free delivery receipt. The first +ordinary recall suppresses fully delivered exact Memory entry versions while +leaving entries truncated during bootstrap eligible. +Invalid configuration or another failure before a new package is established +clears any stale saved receipt and injects nothing. Scope is resolved by the Server from an explicit override, the current session or workspace binding, and finally the Server default. Bindings let multiple diff --git a/integrations/claude-code/plugins/powercontext/bootstrap_state.py b/integrations/claude-code/plugins/powercontext/bootstrap_state.py index 643477ede2..69d615491e 100644 --- a/integrations/claude-code/plugins/powercontext/bootstrap_state.py +++ b/integrations/claude-code/plugins/powercontext/bootstrap_state.py @@ -41,8 +41,7 @@ def load_receipt(session_id: str | None, scope_id: str) -> str | None: or value.get("scope_id") != scope_id or not isinstance(receipt_id, str) or not 1 <= len(receipt_id) <= 64 - or not receipt_id.isascii() - or not receipt_id.isprintable() + or not all("\x21" <= character <= "\x7e" for character in receipt_id) ): return None except (OSError, ValueError, TypeError, json.JSONDecodeError): diff --git a/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py b/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py index 20501c88b3..5dd74d46c3 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py +++ b/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py @@ -116,15 +116,12 @@ def validate_delivery_receipt(value: object, *, receipt_id: str | None = None) - raise InvalidBootstrapResponse identifier, state = value.get("receipt_id"), value.get("state") if ( - not isinstance(identifier, str) - or not 1 <= len(identifier) <= 64 - or not identifier.isascii() - or not identifier.isprintable() + not _printable_identifier(identifier, maximum=64) or state not in {"pending", "injected", "skipped", "failed"} or (receipt_id is not None and identifier != receipt_id) ): raise InvalidBootstrapResponse - return {"receipt_id": identifier, "state": cast(str, state)} + return {"receipt_id": cast(str, identifier), "state": cast(str, state)} def _validate_item(value: object) -> dict[str, object]: @@ -168,7 +165,5 @@ def _printable_identifier(value: object, *, maximum: int) -> bool: return ( isinstance(value, str) and 1 <= len(value) <= maximum - and value.isascii() - and value.isprintable() - and not value.isspace() + and all("\x21" <= character <= "\x7e" for character in value) ) diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py index 70a6c9822e..071633b6d8 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/session_start.py +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -32,7 +32,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import clear_receipt, save_receipt # noqa: E402 +from bootstrap_state import clear_receipt, load_receipt, save_receipt # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from hooks.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, @@ -103,16 +103,20 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: or source not in _LIFECYCLES ): return 0 - settings = ClaudeCodePluginSettings.from_environment() if settings is None else settings + try: + settings = ClaudeCodePluginSettings.from_environment() if settings is None else settings + except Exception: + clear_receipt(session_id) + return 0 deadline = monotonic() + settings.http_budget_seconds - clear_receipt(session_id) prepared = _resolve_and_prepare(cwd, session_id, source, payload, settings=settings, deadline=deadline) if prepared is None: + clear_receipt(session_id) return 0 scope_id, response = prepared if response["status"] != "ready": + receipt = _synchronize_non_ready_receipt(session_id, scope_id, response) if response["status"] == "empty": - receipt = cast(dict[str, str], response["receipt"]) _emit_bootstrap_event( "empty", http_status=200, @@ -147,17 +151,22 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: deadline=deadline, ) return 0 - json.dump( - { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": response["content"], - } - }, - sys.stdout, - separators=(",", ":"), - ) - sys.stdout.write("\n") + try: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response["content"], + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + except Exception: + clear_receipt(session_id) + _emit_bootstrap_event("delivery_failed") + return 0 except Exception: return 0 return 0 @@ -271,6 +280,25 @@ def _require_injected(receipt: Mapping[str, str]) -> None: raise InvalidBootstrapResponse +def _synchronize_non_ready_receipt( + session_id: str, + scope_id: str, + response: Mapping[str, object], +) -> 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 load_receipt(session_id, scope_id) == receipt["receipt_id"] + ): + return receipt + clear_receipt(session_id) + return receipt + + def _emit_http_failure(status: int) -> None: if status == 401: outcome = "authentication_failed" 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 cdf7e06f65..34407d5f94 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -33,7 +33,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import load_receipt # noqa: E402 +from bootstrap_state import clear_receipt, load_receipt # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from hooks import prepared_context as _prepared_context # noqa: E402 from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 @@ -146,6 +146,7 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: deadline=http_deadline, ) bootstrap_receipt_id = load_receipt(session_id, scope_id) + clear_receipt(session_id) context = None with suppress(Exception): context = _recall_context( diff --git a/integrations/codex/plugins/powercontext/.codex-plugin/plugin.json b/integrations/codex/plugins/powercontext/.codex-plugin/plugin.json index 7ac78ed996..e02ef6b97e 100644 --- a/integrations/codex/plugins/powercontext/.codex-plugin/plugin.json +++ b/integrations/codex/plugins/powercontext/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "powercontext", - "version": "0.2.0", + "version": "0.3.0", "description": "Restore project memory and transfer current work from Codex.", "author": { "name": "PowerContext Team", diff --git a/integrations/codex/plugins/powercontext/README.md b/integrations/codex/plugins/powercontext/README.md index 1fb04b7f15..a3cbc98862 100644 --- a/integrations/codex/plugins/powercontext/README.md +++ b/integrations/codex/plugins/powercontext/README.md @@ -81,8 +81,10 @@ Bootstrap context is disabled by default. Set explicitly tagged `bootstrap-context`; use `POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES` for the 512–8192-byte budget and `POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF` for one exact committed Handoff identity. -The first ordinary recall carries the successful receipt so exact delivered -Memory versions are not repeated. +The first ordinary recall carries the successful receipt so fully delivered +exact Memory entry versions are not repeated; truncated entries remain eligible. +Invalid configuration or another failure before a new package is established +clears any stale saved receipt and injects nothing. Set `POWERCONTEXT_CODEX_SCOPE_ID` only when the host must explicitly bind every request to one known Scope. diff --git a/integrations/codex/plugins/powercontext/bootstrap_state.py b/integrations/codex/plugins/powercontext/bootstrap_state.py index 2cb1942a62..3beb18c5cb 100644 --- a/integrations/codex/plugins/powercontext/bootstrap_state.py +++ b/integrations/codex/plugins/powercontext/bootstrap_state.py @@ -41,8 +41,7 @@ def load_receipt(session_id: str | None, scope_id: str) -> str | None: or value.get("scope_id") != scope_id or not isinstance(receipt_id, str) or not 1 <= len(receipt_id) <= 64 - or not receipt_id.isascii() - or not receipt_id.isprintable() + or not all("\x21" <= character <= "\x7e" for character in receipt_id) ): return None except (OSError, ValueError, TypeError, json.JSONDecodeError): diff --git a/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py b/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py index e041df8b70..3c5ba60f52 100644 --- a/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py +++ b/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py @@ -116,15 +116,12 @@ def validate_delivery_receipt(value: object, *, receipt_id: str | None = None) - raise InvalidBootstrapResponse identifier, state = value.get("receipt_id"), value.get("state") if ( - not isinstance(identifier, str) - or not 1 <= len(identifier) <= 64 - or not identifier.isascii() - or not identifier.isprintable() + not _printable_identifier(identifier, maximum=64) or state not in {"pending", "injected", "skipped", "failed"} or (receipt_id is not None and identifier != receipt_id) ): raise InvalidBootstrapResponse - return {"receipt_id": identifier, "state": cast(str, state)} + return {"receipt_id": cast(str, identifier), "state": cast(str, state)} def _validate_item(value: object) -> dict[str, object]: @@ -168,7 +165,5 @@ def _printable_identifier(value: object, *, maximum: int) -> bool: return ( isinstance(value, str) and 1 <= len(value) <= maximum - and value.isascii() - and value.isprintable() - and not value.isspace() + and all("\x21" <= character <= "\x7e" for character in value) ) diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index 03af0af62b..bba8b74091 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -35,7 +35,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import load_receipt # noqa: E402 +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 scope_binding import bind_response_deadline, open_bounded, resolve_scope_id # noqa: E402 @@ -50,7 +50,7 @@ _REQUEST_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "powercontext-codex-plugin/0.2.0", + "User-Agent": "powercontext-codex-plugin/0.3.0", } _FAILURE_OUTCOMES = frozenset({"authentication_failed", "version_mismatch", "server_unavailable", "invalid_response"}) @@ -147,6 +147,7 @@ def main(settings: CodexPluginSettings | None = None) -> int: deadline=http_deadline, ) bootstrap_receipt_id = load_receipt(session_id, scope_id) + clear_receipt(session_id) context = _recall_context( prompt, scope_id, diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 5f64557b07..6167b29723 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -32,7 +32,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import clear_receipt, save_receipt # noqa: E402 +from bootstrap_state import clear_receipt, load_receipt, save_receipt # noqa: E402 from hooks.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, validate_bootstrap_context, @@ -65,7 +65,7 @@ _REQUEST_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "powercontext-codex-plugin/0.2.0", + "User-Agent": "powercontext-codex-plugin/0.3.0", } @@ -93,20 +93,23 @@ def main(settings: CodexPluginSettings | None = None) -> int: cwd = payload.get("cwd") if not isinstance(session_id, str) or not isinstance(cwd, str): return 0 - settings = CodexPluginSettings() if settings is None else settings - deadline = monotonic() + settings.http_budget_seconds source = payload.get("source") - if isinstance(source, str) and source in _LIFECYCLES: - clear_receipt(session_id) if not isinstance(source, str) or source not in _LIFECYCLES: return 0 + try: + settings = CodexPluginSettings() if settings is None else settings + except Exception: + clear_receipt(session_id) + 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_receipt(session_id) return 0 scope_id, response = prepared if response["status"] != "ready": + receipt = _synchronize_non_ready_receipt(session_id, scope_id, response) if response["status"] == "empty": - receipt = cast(dict[str, str], response["receipt"]) _emit_bootstrap_event( "empty", http_status=200, @@ -141,17 +144,22 @@ def main(settings: CodexPluginSettings | None = None) -> int: deadline=deadline, ) return 0 - json.dump( - { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": response["content"], - } - }, - sys.stdout, - separators=(",", ":"), - ) - sys.stdout.write("\n") + try: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response["content"], + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + except Exception: + clear_receipt(session_id) + _emit_bootstrap_event("delivery_failed") + return 0 except Exception: return 0 return 0 @@ -266,6 +274,25 @@ def _require_injected(receipt: Mapping[str, str]) -> None: raise InvalidBootstrapResponse +def _synchronize_non_ready_receipt( + session_id: str, + scope_id: str, + response: Mapping[str, object], +) -> 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 load_receipt(session_id, scope_id) == receipt["receipt_id"] + ): + return receipt + clear_receipt(session_id) + return receipt + + def _emit_http_failure(status: int) -> None: if status == 401: outcome = "authentication_failed" diff --git a/integrations/codex/plugins/powercontext/pyproject.toml b/integrations/codex/plugins/powercontext/pyproject.toml index 2f30b8336d..3aa10b7625 100644 --- a/integrations/codex/plugins/powercontext/pyproject.toml +++ b/integrations/codex/plugins/powercontext/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "powercontext-codex-plugin-runtime" -version = "0.2.0" +version = "0.3.0" requires-python = ">=3.11,<4.0" dependencies = [ "pydantic-settings>=2.7,<3", diff --git a/integrations/codex/plugins/powercontext/uv.lock b/integrations/codex/plugins/powercontext/uv.lock index 6c5591c1d9..a370fa9221 100644 --- a/integrations/codex/plugins/powercontext/uv.lock +++ b/integrations/codex/plugins/powercontext/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "powercontext-codex-plugin-runtime" -version = "0.2.0" +version = "0.3.0" source = { virtual = "." } dependencies = [ { name = "pydantic-settings" }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 237c87ee5c..765edf15cb 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -890,6 +890,7 @@ paths: Prepare an opt-in, deterministic bootstrap package without manufacturing a query. Only explicitly curated active Memory entries and one caller-selected exact committed Handoff are eligible. Returned historical text is untrusted data and grants no authority. A ready receipt remains pending until the host records delivery. + Reusing one stable event identity with different package-shaping fields is rejected with 422. operationId: prepare_bootstrap_context x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} x-powercontext-scope-mode: current @@ -6986,11 +6987,13 @@ components: type: string minLength: 1 maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true entry_version_id: type: string minLength: 1 maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true content_digest: type: string @@ -8556,8 +8559,9 @@ components: maxLength: 64 pattern: '^[\x21-\x7E]+$' description: >- - Optional exact receipt for the last successfully injected bootstrap package. Only identical Memory entry - versions from an injected same-Scope receipt are removed from this query result. + Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Memory + entry versions from an injected same-Scope receipt are removed before candidate limiting and sufficiency + assessment; entries whose bootstrap bodies were truncated remain eligible. BootstrapContextRequest: type: object additionalProperties: false @@ -8587,7 +8591,9 @@ components: minLength: 1 maxLength: 512 pattern: '^[\x20-\x7E]+$' - description: Optional stable host event identity; persisted only as a digest. + description: >- + Optional stable host event identity, persisted only as a digest. Within the same Scope, integration, and + lifecycle, retries must keep the profile, max_bytes, and exact Handoff unchanged. max_bytes: type: integer minimum: 512 diff --git a/src/powercontext/builtin/persistence/bootstrap_receipts.py b/src/powercontext/builtin/persistence/bootstrap_receipts.py index f2e787201a..4434a7355c 100644 --- a/src/powercontext/builtin/persistence/bootstrap_receipts.py +++ b/src/powercontext/builtin/persistence/bootstrap_receipts.py @@ -17,10 +17,11 @@ from __future__ import annotations from dataclasses import dataclass, replace +from datetime import datetime from typing import cast from pydantic import TypeAdapter -from sqlalchemy import insert, select, update +from sqlalchemy import delete, insert, select, update from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert @@ -43,6 +44,7 @@ class StoredBootstrapReceipt: receipt_id: str scope_id: str event_key: str | None + request_digest: str integration: str lifecycle: BootstrapContextLifecycle profile: BootstrapContextProfile @@ -53,6 +55,7 @@ class StoredBootstrapReceipt: content_bytes: int truncated: bool items: tuple[BootstrapContextItem, ...] + created_at: datetime class BootstrapReceiptRepository: @@ -108,6 +111,15 @@ async def create(self, receipt: StoredBootstrapReceipt) -> tuple[StoredBootstrap ) return _stored_receipt(row), False + async def delete_before(self, cutoff: datetime) -> int: + """Delete receipts outside the bounded retry/deduplication window.""" + + async with self._database.transaction() as connection: + result = await connection.execute( + delete(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.created_at < cutoff) + ) + return result.rowcount + async def record_delivery( self, scope_id: str, @@ -139,6 +151,8 @@ async def record_delivery( return (replace(stored, state=outcome) if claimed else stored), claimed async def injected_memory_keys(self, scope_id: str, receipt_id: str) -> frozenset[tuple[object, ...]]: + """Return fully delivered entry-version identities from one injected package.""" + async with self._database.transaction() as connection: row = ( ( @@ -162,12 +176,11 @@ async def injected_memory_keys(self, scope_id: str, receipt_id: str) -> frozense ( item.scope_id, item.artifact.artifact_id, - item.artifact.revision, item.entry_id, item.entry_version_id, ) for item in items - if item.kind == "memory_entry" + if item.kind == "memory_entry" and not item.truncated ) @@ -176,6 +189,7 @@ def _values(receipt: StoredBootstrapReceipt) -> dict[str, object]: "receipt_id": receipt.receipt_id, "scope_id": receipt.scope_id, "event_key": receipt.event_key, + "request_digest": receipt.request_digest, "integration": receipt.integration, "lifecycle": receipt.lifecycle, "profile": receipt.profile, @@ -186,6 +200,7 @@ def _values(receipt: StoredBootstrapReceipt) -> dict[str, object]: "content_bytes": receipt.content_bytes, "truncated": receipt.truncated, "items": _ITEMS.dump_json(receipt.items), + "created_at": receipt.created_at, } @@ -200,6 +215,7 @@ def _stored_receipt(row: object) -> StoredBootstrapReceipt: receipt_id=receipt_id, scope_id=cast(str, values["scope_id"]), event_key=cast(str | None, values["event_key"]), + request_digest=cast(str, values["request_digest"]), integration=cast(str, values["integration"]), lifecycle=cast(BootstrapContextLifecycle, values["lifecycle"]), profile=cast(BootstrapContextProfile, values["profile"]), @@ -210,4 +226,5 @@ def _stored_receipt(row: object) -> StoredBootstrapReceipt: content_bytes=cast(int, values["content_bytes"]), truncated=cast(bool, values["truncated"]), items=_load_items(values["items"], receipt_id), + created_at=cast(datetime, values["created_at"]), ) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 5aaf007836..1ca4482873 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -1096,6 +1096,7 @@ def _entry_text_type(): Column("receipt_id", identity_string(64), primary_key=True), Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), Column("event_key", identity_string(64), nullable=True), + Column("request_digest", identity_string(71), nullable=False), Column("integration", identity_string(64), nullable=False), Column("lifecycle", identity_string(16), nullable=False), Column("profile", identity_string(64), nullable=False), @@ -1106,8 +1107,10 @@ def _entry_text_type(): Column("content_bytes", Integer, nullable=False), Column("truncated", Boolean, nullable=False), Column("items", _canonical_payload_type(), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), UniqueConstraint("event_key", name="uq_pc_context_bootstrap_receipts_event"), + Index("ix_pc_context_bootstrap_receipts_created_at", "created_at"), CheckConstraint( "state IN ('pending', 'injected', 'skipped', 'failed')", name="ck_pc_context_bootstrap_receipts_state", diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index aae2bebeac..7ed09f109c 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -22,7 +22,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractContextManager, asynccontextmanager, nullcontext from dataclasses import dataclass, replace -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from hashlib import sha256 from pathlib import Path from time import perf_counter @@ -931,6 +931,15 @@ async def _prepare_build( # Expansion rounds read it so a repeat search does not re-embed; round 0 fills it. reuse: dict[str, MemoryQueryEmbedding] = {} topic_reuse: dict[str, MemoryQueryEmbedding] = {} + injected_memory_keys: frozenset[tuple[object, ...]] = frozenset() + if request.bootstrap_receipt_id is not None and self._runtime._bootstrap_receipts is not None: + receipts = self._runtime._bootstrap_receipts + cutoff = self._runtime._clock().astimezone(UTC) - _BOOTSTRAP_RECEIPT_RETENTION + await receipts.delete_before(cutoff) + injected_memory_keys = await receipts.injected_memory_keys( + self.scope_id, + request.bootstrap_receipt_id, + ) round_zero = await self._recall_round( request, @@ -940,6 +949,7 @@ async def _prepare_build( admission=None, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=injected_memory_keys, ) memory_candidates = list(round_zero.memory) experience_candidates = list(round_zero.experience) @@ -974,22 +984,8 @@ async def _prepare_build( reuse=reuse, topic_reuse=topic_reuse, round_zero=round_zero, + excluded_memory=injected_memory_keys, ) - if request.bootstrap_receipt_id is not None and self._runtime._bootstrap_receipts is not None: - injected = await self._runtime._bootstrap_receipts.injected_memory_keys( - self.scope_id, - request.bootstrap_receipt_id, - ) - if injected: - memory_candidates = [ - replace( - candidates, - hits=tuple( - hit for hit in candidates.hits if _memory_identity(candidates.scope_id, hit) not in injected - ), - ) - for candidates in memory_candidates - ] with self._runtime._stage( "context.build", attributes={ @@ -1053,6 +1049,7 @@ async def _gated_recall_effort( # noqa: C901 - the bounded expansion loop is in reuse: dict[str, MemoryQueryEmbedding], topic_reuse: dict[str, MemoryQueryEmbedding], round_zero: _RecallRoundOutcome, + excluded_memory: frozenset[tuple[object, ...]], ) -> tuple[ list[PreparedMemoryCandidates], list[PreparedExperienceCandidates], @@ -1120,6 +1117,7 @@ async def _gated_recall_effort( # noqa: C901 - the bounded expansion loop is in admission=plan.admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) for group in issued.memory: _ensure_memory_head_stable( @@ -1269,6 +1267,7 @@ async def _recall_round( admission: AdmissionFloor | None, reuse: dict[str, MemoryQueryEmbedding], topic_reuse: dict[str, MemoryQueryEmbedding], + excluded_memory: frozenset[tuple[object, ...]], ) -> _RecallRoundOutcome: memory_candidates: list[PreparedMemoryCandidates] = [] experience_candidates: list[PreparedExperienceCandidates] = [] @@ -1284,7 +1283,18 @@ async def _recall_round( admission=admission, reuse=reuse.get(scope_id), ) - memory_candidates.append(outcome.memory) + memory_candidates.append( + replace( + outcome.memory, + hits=tuple( + hit + for hit in outcome.memory.hits + if _bootstrap_memory_identity(outcome.memory.scope_id, hit) not in excluded_memory + ), + ) + if excluded_memory + else outcome.memory + ) experience_candidates.append(outcome.experience) if outcome.memory_query_embedding is not None: reuse[scope_id] = outcome.memory_query_embedding @@ -1622,6 +1632,12 @@ def _memory_identity(scope_id: str, hit: MemoryHit) -> tuple[str, str, int, str, return (scope_id, hit.memory_ref.artifact_id, hit.memory_ref.revision, hit.entry_id, hit.entry_version_id) +def _bootstrap_memory_identity(scope_id: str, hit: MemoryHit) -> tuple[str, str, str, str]: + """Identify exact entry content without coupling it to unrelated Memory revisions.""" + + return (scope_id, hit.memory_ref.artifact_id, hit.entry_id, hit.entry_version_id) + + def _experience_identity(scope_id: str, hit: ExperienceSearchHit) -> tuple[str, str, int]: return (scope_id, hit.artifact_ref.artifact_id, hit.artifact_ref.revision) @@ -1650,17 +1666,15 @@ def for_scope(self, scope_id: str, /) -> ScopedContextApplication: _BOOTSTRAP_MEMORY_LIMIT = 6 _BOOTSTRAP_CONTEXT_REFERENCE_LIMIT = 8 _BOOTSTRAP_TAG_SCAN_LIMIT = 32 -_SECRET_MEMORY_KINDS = ( +_BOOTSTRAP_RECEIPT_RETENTION = timedelta(days=30) +_SECRET_MEMORY_KIND_MARKERS = ( "secret", "credential", "password", "token", - "api key", - "api-key", "apikey", - "access key", - "private key", - "private-key", + "accesskey", + "privatekey", ) @@ -1682,41 +1696,24 @@ async def prepare( async with self._runtime._scope_operation(self.scope_id) as scope: scope_ids = (self.scope_id, *scope.context_references[:_BOOTSTRAP_CONTEXT_REFERENCE_LIMIT]) event_key = _bootstrap_event_key(self.scope_id, request) + await self._delete_expired_receipts(request, event_key, repository) if not request.enabled: return await self._disabled_result(request, event_key, repository, authorize_scopes) - if event_key is not None: - existing = await repository.by_event(event_key) - if existing is not None: - if authorize_scopes is not None: - stored_scope_ids = tuple( - dict.fromkeys((*scope_ids, *(item.scope_id for item in existing.items))) - ) - await authorize_scopes(stored_scope_ids) - return await self._existing_result(existing) + existing = await self._existing_event_result( + request, + event_key, + scope_ids, + repository, + authorize_scopes, + ) + if existing is not None: + return existing if authorize_scopes is not None: await authorize_scopes(scope_ids) candidates = await self._select_candidates(request, scope_ids) build = build_bootstrap_context(candidates, request.max_bytes) if build.content is None: - stored, created = await repository.create( - self._stored_receipt( - request, - event_key=event_key, - state="skipped", - reason="no_eligible_context", - truncated=build.truncated, - ) - ) - if not created: - return await self._existing_result(stored) - return BootstrapContext( - status="empty", - reason="no_eligible_context", - content=None, - content_bytes=0, - truncated=build.truncated, - receipt=_delivery_receipt(stored), - ) + return await self._empty_result(request, event_key, build, repository) stored, created = await repository.create( self._stored_receipt( @@ -1729,8 +1726,68 @@ async def prepare( ) ) if not created: + self._require_matching_request(stored, request) return await self._existing_result(stored) - return _ready_bootstrap(stored, build.content, build) + return _ready_bootstrap(stored, build.content) + + async def _delete_expired_receipts( + self, + request: BootstrapContextRequest, + event_key: str | None, + repository: BootstrapReceiptRepository, + ) -> None: + if request.enabled or event_key is not None: + cutoff = self._runtime._clock().astimezone(UTC) - _BOOTSTRAP_RECEIPT_RETENTION + await repository.delete_before(cutoff) + + async def _existing_event_result( + self, + request: BootstrapContextRequest, + event_key: str | None, + scope_ids: tuple[str, ...], + repository: BootstrapReceiptRepository, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, + ) -> BootstrapContext | None: + if event_key is None: + return None + existing = await repository.by_event(event_key) + if existing is None: + return None + self._require_matching_request(existing, request) + if authorize_scopes is not None: + stored_scope_ids = tuple(dict.fromkeys((*scope_ids, *(item.scope_id for item in existing.items)))) + await authorize_scopes(stored_scope_ids) + return await self._existing_result(existing) + + async def _empty_result( + self, + request: BootstrapContextRequest, + event_key: str | None, + build: BootstrapBuild, + repository: BootstrapReceiptRepository, + ) -> BootstrapContext: + candidate = self._stored_receipt( + request, + event_key=event_key, + state="skipped", + reason="no_eligible_context", + truncated=build.truncated, + ) + if event_key is None: + stored = candidate + else: + stored, created = await repository.create(candidate) + if not created: + self._require_matching_request(stored, request) + return await self._existing_result(stored) + return BootstrapContext( + status="empty", + reason="no_eligible_context", + content=None, + content_bytes=0, + truncated=build.truncated, + receipt=_delivery_receipt(stored), + ) async def _disabled_result( self, @@ -1743,9 +1800,11 @@ async def _disabled_result( await authorize_scopes((self.scope_id,)) stored = await repository.by_event(event_key) if event_key is not None else None if stored is None: - stored, _ = await repository.create( - self._stored_receipt(request, event_key=event_key, state="skipped", reason="disabled") - ) + candidate = self._stored_receipt(request, event_key=event_key, state="skipped", reason="disabled") + if event_key is None: + stored = candidate + else: + stored, _ = await repository.create(candidate) if stored.state == "pending": stored, _ = await repository.record_delivery(self.scope_id, stored.receipt_id, "skipped") if stored is None: @@ -1847,8 +1906,12 @@ async def _existing_result(self, stored: StoredBootstrapReceipt) -> BootstrapCon extra={"event": "context.bootstrap.reconstruct_failed", "outcome": "failure", "unit": "context"}, ) failed, _ = await self._repository().record_delivery(self.scope_id, stored.receipt_id, "failed") - return self._terminal_result(failed or stored, reason="preparation_failed") - return _ready_bootstrap(stored, content, build) + terminal = failed or stored + if terminal.state == "pending": + raise InvalidRuntimeRequestError("bootstrap-receipt") from error + reason: BootstrapSkipReason = "preparation_failed" if terminal.state == "failed" else "already_delivered" + return self._terminal_result(terminal, reason=reason) + return _ready_bootstrap(stored, content) async def _candidate_from_item(self, item: BootstrapContextItem) -> BootstrapCandidate: records = self._runtime._records() @@ -1893,6 +1956,7 @@ def _stored_receipt( receipt_id=f"bcr_{uuid4().hex}", scope_id=self.scope_id, event_key=event_key, + request_digest=_bootstrap_request_digest(request), integration=request.integration, lifecycle=request.lifecycle, profile=request.profile, @@ -1903,8 +1967,14 @@ def _stored_receipt( content_bytes=len(encoded), truncated=truncated, items=items, + created_at=self._runtime._clock(), ) + @staticmethod + def _require_matching_request(stored: StoredBootstrapReceipt, request: BootstrapContextRequest) -> None: + if stored.request_digest != _bootstrap_request_digest(request): + raise InvalidRuntimeRequestError("bootstrap-event-request-mismatch") + def _terminal_result(self, stored: StoredBootstrapReceipt, *, reason: BootstrapSkipReason) -> BootstrapContext: return BootstrapContext( status="skipped", @@ -1940,8 +2010,20 @@ def _bootstrap_event_key(scope_id: str, request: BootstrapContextRequest) -> str def _secret_memory_kind(kind: str) -> bool: - normalized = " ".join(kind.casefold().replace("_", " ").split()) - return any(marker in normalized for marker in _SECRET_MEMORY_KINDS) + compact = "".join(character for character in kind.casefold() if character.isalnum()) + return any(marker in compact for marker in _SECRET_MEMORY_KIND_MARKERS) + + +def _bootstrap_request_digest(request: BootstrapContextRequest) -> str: + payload = { + "handoff": None if request.handoff is None else request.handoff.model_dump(mode="json", by_alias=True), + "integration": request.integration, + "lifecycle": request.lifecycle, + "max_bytes": request.max_bytes, + "profile": request.profile, + } + encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode() + return "sha256:" + sha256(encoded).hexdigest() def _package_digest(content: str) -> str: @@ -1958,7 +2040,6 @@ def _require_reconstructed_bootstrap( or build.items != stored.items or len(content.encode("utf-8")) != stored.content_bytes or _package_digest(content) != stored.package_digest - or build.truncated != stored.truncated ): raise ValueError("bootstrap receipt cannot be reconstructed") # noqa: TRY003 return content @@ -1968,15 +2049,15 @@ def _delivery_receipt(stored: StoredBootstrapReceipt) -> BootstrapDeliveryReceip return BootstrapDeliveryReceipt(receipt_id=stored.receipt_id, state=stored.state) -def _ready_bootstrap(stored: StoredBootstrapReceipt, content: str, build: BootstrapBuild) -> BootstrapContext: +def _ready_bootstrap(stored: StoredBootstrapReceipt, content: str) -> BootstrapContext: return BootstrapContext( status="ready", profile=stored.profile, content=content, content_bytes=stored.content_bytes, package_digest=stored.package_digest, - items=build.items, - truncated=build.truncated, + items=stored.items, + truncated=stored.truncated, receipt=_delivery_receipt(stored), ) diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 989b916f0a..0a559cc152 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -260,8 +260,8 @@ def validate_query(cls, value: str) -> str: @field_validator("bootstrap_receipt_id") @classmethod def validate_bootstrap_receipt_id(cls, value: str | None) -> str | None: - if value is not None and (value != value.strip() or not value.isascii() or not value.isprintable()): - raise ValueError("bootstrap_receipt_id must be a printable exact identifier") # noqa: TRY003 + if value is not None and not all("\x21" <= character <= "\x7e" for character in value): + raise ValueError("bootstrap_receipt_id must contain only visible ASCII") # noqa: TRY003 return value @@ -297,11 +297,18 @@ class BootstrapContextRequest(_PreparedContextModel): max_bytes: Annotated[int, Field(ge=512, le=8192)] = 4096 handoff: ArtifactRef | None = None - @field_validator("integration", "event_id") + @field_validator("integration") @classmethod - def validate_host_identity(cls, value: str | None) -> str | None: - if value is not None and (value != value.strip() or not value.isascii() or not value.isprintable()): - raise ValueError("host identity must be printable ASCII without surrounding whitespace") # noqa: TRY003 + def validate_integration(cls, value: str) -> str: + if not all("\x21" <= character <= "\x7e" for character in value): + raise ValueError("integration must contain only visible ASCII") # noqa: TRY003 + return value + + @field_validator("event_id") + @classmethod + def validate_event_id(cls, value: str | None) -> str | None: + if value is not None and not all("\x20" <= character <= "\x7e" for character in value): + raise ValueError("event_id must contain only printable ASCII") # noqa: TRY003 return value @field_validator("handoff") @@ -318,8 +325,14 @@ class BootstrapContextItem(_PreparedContextModel): kind: Literal["memory_entry", "handoff"] scope_id: Annotated[str, Field(min_length=1, max_length=256)] artifact: ArtifactRef - entry_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None - entry_version_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None + entry_id: Annotated[str, Field(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")] | None = None + entry_version_id: ( + Annotated[ + str, + Field(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$"), + ] + | None + ) = None content_digest: Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] truncated: bool = False @@ -339,7 +352,7 @@ def validate_reference(self) -> BootstrapContextItem: class BootstrapDeliveryReceipt(_PreparedContextModel): """Content-free durable delivery state exposed to a host integration.""" - receipt_id: Annotated[str, Field(min_length=1, max_length=64)] + receipt_id: Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[\x21-\x7E]+$")] state: BootstrapReceiptState @@ -393,7 +406,7 @@ def validate_result(self) -> BootstrapContext: class RecordBootstrapDeliveryRequest(_PreparedContextModel): """Finalize one pending delivery receipt without accepting content or error text.""" - receipt_id: Annotated[str, Field(min_length=1, max_length=64)] + receipt_id: Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[\x21-\x7E]+$")] outcome: Literal["injected", "failed"] diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 81190688d7..4a8ac42cfc 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -2873,8 +2873,8 @@ class BootstrapContextItem(BaseModel): kind: BootstrapContextItemKind scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] artifact: ArtifactReference - entry_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] - entry_version_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + entry_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + entry_version_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] truncated: StrictBool @@ -3132,7 +3132,7 @@ class BootstrapContextRequest(BaseModel): event_id: Annotated[ StrictStr | None, Field( - description="Optional stable host event identity; persisted only as a digest.", + description="Optional stable host event identity, persisted only as a digest. Within the same Scope, integration, and lifecycle, retries must keep the profile, max_bytes, and exact Handoff unchanged.", max_length=512, min_length=1, pattern="^[\\x20-\\x7E]+$", @@ -3773,7 +3773,7 @@ class PrepareContextRequest(BaseModel): bootstrap_receipt_id: Annotated[ StrictStr | None, Field( - description="Optional exact receipt for the last successfully injected bootstrap package. Only identical Memory entry versions from an injected same-Scope receipt are removed from this query result.", + description="Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Memory entry versions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; entries whose bootstrap bodies were truncated remain eligible.", max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 15fcf7bd81..50c0f94460 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -842,7 +842,10 @@ "is untrusted data and grants no " "authority. A ready receipt remains " "pending until the host records " - "delivery.", + "delivery. Reusing one stable event " + "identity with different " + "package-shaping fields is rejected " + "with 422.", "operationId": "prepare_bootstrap_context", "requestBody": { "content": { @@ -6491,8 +6494,20 @@ "kind": {"$ref": "#/components/schemas/BootstrapContextItemKind"}, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, - "entry_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, - "entry_version_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "entry_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "nullable": True, + }, + "entry_version_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "nullable": True, + }, "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, "truncated": {"type": "boolean"}, }, @@ -7681,7 +7696,8 @@ "bootstrap " "package. " "Only " - "identical " + "fully " + "delivered " "Memory " "entry " "versions " @@ -7692,10 +7708,20 @@ "receipt " "are " "removed " - "from " - "this " - "query " - "result.", + "before " + "candidate " + "limiting " + "and " + "sufficiency " + "assessment; " + "entries " + "whose " + "bootstrap " + "bodies " + "were " + "truncated " + "remain " + "eligible.", }, }, "additionalProperties": False, @@ -7717,7 +7743,33 @@ "maxLength": 512, "minLength": 1, "pattern": "^[\\x20-\\x7E]+$", - "description": "Optional stable host event identity; persisted only as a digest.", + "description": "Optional " + "stable " + "host " + "event " + "identity, " + "persisted " + "only " + "as " + "a " + "digest. " + "Within " + "the " + "same " + "Scope, " + "integration, " + "and " + "lifecycle, " + "retries " + "must " + "keep " + "the " + "profile, " + "max_bytes, " + "and " + "exact " + "Handoff " + "unchanged.", }, "max_bytes": {"type": "integer", "maximum": 8192.0, "minimum": 512.0, "default": 4096}, "handoff": { diff --git a/tests/claude_code_plugin/test_bootstrap.py b/tests/claude_code_plugin/test_bootstrap.py index 21636d6471..edd4c15d82 100644 --- a/tests/claude_code_plugin/test_bootstrap.py +++ b/tests/claude_code_plugin/test_bootstrap.py @@ -20,6 +20,7 @@ from hashlib import sha256 from pathlib import Path from types import ModuleType +from typing import cast import pytest @@ -70,6 +71,12 @@ def _empty() -> dict[str, object]: return value +def _already_delivered() -> dict[str, object]: + value = _skipped("already_delivered") + value["receipt"] = {"receipt_id": "bcr_test", "state": "injected"} + return value + + def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( session_start_module: ModuleType, hook_module: ModuleType, @@ -159,6 +166,95 @@ def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): assert hook_module.main(hook_module.ClaudeCodePluginSettings(capture_prompts=False)) == 0 assert prepared_receipts == ["bcr_test"] + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies next?", + }) + ), + ) + assert hook_module.main(hook_module.ClaudeCodePluginSettings(capture_prompts=False)) == 0 + assert prepared_receipts == ["bcr_test", None] + + +def test_repeated_injected_event_preserves_receipt_for_first_query( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_start_module.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( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + assert json.loads(state_files[0].read_text())["receipt_id"] == "bcr_test" + + +def test_invalid_settings_clear_a_stale_receipt( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") + + def invalid_settings(): + raise ValueError + + monkeypatch.setattr( + session_start_module.ClaudeCodePluginSettings, + "from_environment", + staticmethod(invalid_settings), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "compact", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_start_module.main() == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +@pytest.mark.parametrize("invalid_field", ["receipt", "artifact"]) +def test_bootstrap_response_rejects_identifiers_containing_spaces( + invalid_field: str, + session_start_module: ModuleType, +) -> None: + response = _ready() + if invalid_field == "receipt": + response["receipt"] = {"receipt_id": "bcr test", "state": "pending"} + else: + items = cast(list[dict[str, object]], response["items"]) + 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) @pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "fork"]) @@ -219,6 +315,42 @@ def post(path, _payload, **_kwargs): assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" +def test_session_start_output_failure_clears_the_deduplication_receipt( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class BrokenOutput: + def write(self, _value): + raise OSError + + 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_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", BrokenOutput()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + 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( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + def test_session_start_rejects_malformed_bootstrap_without_blocking( session_start_module: ModuleType, monkeypatch: pytest.MonkeyPatch, @@ -245,6 +377,7 @@ def test_session_start_empty_or_timeout_fails_open( result: dict[str, object] | TimeoutError, session_start_module: ModuleType, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: def post(*_args, **_kwargs): if isinstance(result, BaseException): @@ -253,6 +386,7 @@ def post(*_args, **_kwargs): monkeypatch.setattr(session_start_module, "_post_json", post) monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") monkeypatch.setattr( sys, "stdin", @@ -269,6 +403,7 @@ def post(*_args, **_kwargs): assert diagnostic["outcome"] == ("server_unavailable" if isinstance(result, BaseException) else "empty") assert diagnostic["profile"] == "powercontext.scope-bootstrap.v1" assert "content" not in diagnostic + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) @pytest.mark.parametrize( diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py index bfe5af3bec..fc436ad72f 100644 --- a/tests/codex_plugin/test_bootstrap.py +++ b/tests/codex_plugin/test_bootstrap.py @@ -20,6 +20,7 @@ from hashlib import sha256 from pathlib import Path from types import ModuleType +from typing import cast import pytest @@ -70,6 +71,12 @@ def _empty() -> dict[str, object]: return value +def _already_delivered() -> dict[str, object]: + value = _skipped("already_delivered") + value["receipt"] = {"receipt_id": "bcr_test", "state": "injected"} + return value + + def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( session_binding_module: ModuleType, recall_module: ModuleType, @@ -157,6 +164,91 @@ def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): assert recall_module.main(recall_module.CodexPluginSettings(capture_prompts=False)) == 0 assert prepared_payloads == ["bcr_test"] + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies next?", + }) + ), + ) + assert recall_module.main(recall_module.CodexPluginSettings(capture_prompts=False)) == 0 + assert prepared_payloads == ["bcr_test", None] + + +def test_repeated_injected_event_preserves_receipt_for_first_query( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_binding_module.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( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + assert json.loads(state_files[0].read_text())["receipt_id"] == "bcr_test" + + +def test_invalid_settings_clear_a_stale_receipt( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") + + def invalid_settings(): + raise ValueError + + monkeypatch.setattr(session_binding_module, "CodexPluginSettings", invalid_settings) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "compact", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_binding_module.main() == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +@pytest.mark.parametrize("invalid_field", ["receipt", "artifact"]) +def test_bootstrap_response_rejects_identifiers_containing_spaces( + invalid_field: str, + session_binding_module: ModuleType, +) -> None: + response = _ready() + if invalid_field == "receipt": + response["receipt"] = {"receipt_id": "bcr test", "state": "pending"} + else: + items = cast(list[dict[str, object]], response["items"]) + 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) @pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "restore", "fork"]) @@ -217,6 +309,42 @@ def post(path, _payload, **_kwargs): assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" +def test_session_start_output_failure_clears_the_deduplication_receipt( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class BrokenOutput: + def write(self, _value): + raise OSError + + 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_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", BrokenOutput()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + 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( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + def test_session_start_rejects_malformed_bootstrap_without_blocking( session_binding_module: ModuleType, monkeypatch: pytest.MonkeyPatch, @@ -243,6 +371,7 @@ def test_session_start_empty_or_timeout_fails_open( result: dict[str, object] | TimeoutError, session_binding_module: ModuleType, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: def post(*_args, **_kwargs): if isinstance(result, BaseException): @@ -251,6 +380,7 @@ def post(*_args, **_kwargs): monkeypatch.setattr(session_binding_module, "_post_json", post) monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") monkeypatch.setattr( sys, "stdin", @@ -267,6 +397,7 @@ def post(*_args, **_kwargs): assert diagnostic["outcome"] == ("server_unavailable" if isinstance(result, BaseException) else "empty") assert diagnostic["profile"] == "powercontext.scope-bootstrap.v1" assert "content" not in diagnostic + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) @pytest.mark.parametrize( diff --git a/tests/e2e/test_bootstrap_context.py b/tests/e2e/test_bootstrap_context.py index cec9b799aa..4cb76333de 100644 --- a/tests/e2e/test_bootstrap_context.py +++ b/tests/e2e/test_bootstrap_context.py @@ -17,12 +17,16 @@ from __future__ import annotations import asyncio +from datetime import UTC, datetime, timedelta import httpx -from sqlalchemy import select +from sqlalchemy import select, update from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.persistence.tables import CONTEXT_BOOTSTRAP_RECEIPTS_TABLE +from powercontext.builtin.runtime.application import ScopedBootstrapApplication +from powercontext.builtin.runtime.config import RuntimeConfig +from powercontext.builtin.runtime.recall_sufficiency import RecallSufficiencyGate from powercontext.client import PowerContextClient from powercontext.http import ( BootstrapContextRequest, @@ -41,7 +45,24 @@ from powercontext.server.settings import McpConfig, MetricsConfig, ServerSettings -def test_bootstrap_is_opt_in_exact_bounded_idempotent_and_deduplicates_first_query(tmp_path) -> None: +async def _tag_for_bootstrap( + client: PowerContextClient, + scope_id: str, + memory_artifact_id: str, + entry_id: str, +) -> None: + tags = await client.get_memory_entry_tags(scope_id, memory_artifact_id, entry_id) + assert tags is not None + await client.replace_memory_entry_tags( + scope_id, + memory_artifact_id, + entry_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["bootstrap-context"]}), + expected_etag=tags.etag, + ) + + +def test_bootstrap_is_opt_in_exact_bounded_idempotent_and_keeps_partial_memory_recallable(tmp_path) -> None: app = create_server_app( settings=ServerSettings( database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'bootstrap.db'}"), @@ -64,7 +85,8 @@ async def scenario() -> None: scope_id=scope.scope_id, kind="decision", text="BOOTSTRAP-ONLY: regenerate the OpenAPI bindings before contract tests. " - + "中文上下文。" * 120, + + "中文上下文。" * 60 + + " OMITTED-TAIL: restore the lunar archive after validation.", ) ) assert remembered.entry is not None @@ -83,7 +105,7 @@ async def scenario() -> None: expected_etag=tags.etag, ) secret = await client.remember_memory( - RememberMemoryRequest(scope_id=scope.scope_id, kind="credential", text="do-not-inject-secret") + RememberMemoryRequest(scope_id=scope.scope_id, kind="access-key", text="do-not-inject-secret") ) assert secret.entry is not None secret_tags = await client.get_memory_entry_tags( @@ -105,12 +127,14 @@ async def scenario() -> None: "scope_id": scope.scope_id, "lifecycle": "startup", "integration": "acceptance", - "event_id": "disabled-event", }) ) assert disabled.status == "skipped" assert disabled.reason == "disabled" assert disabled.receipt.state == "skipped" + database = app.state.application._bootstrap_receipts._database + async with database.transaction() as connection: + assert (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE))).all() == [] request = BootstrapContextRequest.model_validate({ "scope_id": scope.scope_id, @@ -124,6 +148,7 @@ async def scenario() -> None: assert prepared.status == "ready" assert prepared.content is not None assert "BOOTSTRAP-ONLY" in prepared.content + assert "OMITTED-TAIL" not in prepared.content assert "do-not-inject-secret" not in prepared.content assert prepared.content_bytes == len(prepared.content.encode("utf-8")) <= 1024 assert prepared.truncated is True @@ -134,6 +159,18 @@ async def scenario() -> None: retried = await client.prepare_bootstrap_context(request) assert retried.content == prepared.content assert retried.receipt == prepared.receipt + mismatched_retry = await transport.post( + "/v1/context/bootstrap", + json={ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "stable-event-1", + "max_bytes": 512, + }, + ) + assert mismatched_retry.status_code == 422 recorded = await client.record_bootstrap_delivery( RecordBootstrapDeliveryRequest.model_validate({ "scope_id": scope.scope_id, @@ -161,17 +198,19 @@ async def scenario() -> None: assert terminal_retry.receipt.state == "injected" ordinary = await client.prepare_context( - PrepareContextRequest(scope_id=scope.scope_id, query="regenerate OpenAPI bindings contract tests") + PrepareContextRequest(scope_id=scope.scope_id, query="restore lunar archive validation") ) assert ordinary.status == "ready" - deduplicated = await client.prepare_context( + assert ordinary.content is not None and "OMITTED-TAIL" in ordinary.content + with_receipt = await client.prepare_context( PrepareContextRequest( scope_id=scope.scope_id, - query="regenerate OpenAPI bindings contract tests", + query="restore lunar archive validation", bootstrap_receipt_id=prepared.receipt.receipt_id, ) ) - assert deduplicated.status == "empty" + assert with_receipt.status == "ready" + assert with_receipt.content is not None and "OMITTED-TAIL" in with_receipt.content current_entries = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id=scope.scope_id)) current_citation = next( @@ -235,7 +274,6 @@ async def scenario() -> None: assert reenabled_same_event.status == "skipped" assert reenabled_same_event.reason == "already_delivered" - database = app.state.application._bootstrap_receipts._database async with database.transaction() as connection: row = ( ( @@ -256,6 +294,173 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_bootstrap_deduplicates_complete_entry_before_recall_sufficiency_gate( + tmp_path, + monkeypatch, +) -> None: + assessed_memory_counts: list[int] = [] + original_assess = RecallSufficiencyGate.assess + + def observe_assess(self, candidates, *args, **kwargs): + assessed_memory_counts.append(sum(candidate.family == "memory" for candidate in candidates)) + return original_assess(self, candidates, *args, **kwargs) + + monkeypatch.setattr(RecallSufficiencyGate, "assess", observe_assess) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'deduplicate-entry-version.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + runtime=RuntimeConfig( + recall_gate_enabled=True, + recall_gate_min_top_score=0.0, + recall_gate_min_top_gap=0.0, + recall_gate_min_lexical_overlap=0.0, + ), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest( + title="Entry version deduplication", + summary="Ignore unrelated Memory revisions", + idempotency_key="entry-version-deduplication", + ) + ) + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="decision", + text="Use the orange moonlight protocol for release validation.", + ) + ) + assert remembered.entry is not None + entry = remembered.entry + await _tag_for_bootstrap( + client, + scope.scope_id, + entry.citation.memory_ref.artifact_id, + entry.citation.entry_id, + ) + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "entry-version-event", + "max_bytes": 4096, + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + assert len(prepared.items) == 1 + assert prepared.items[0].truncated is False + await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "injected", + }) + ) + + unrelated = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="fact", + text="The cafeteria closes at six.", + ) + ) + assert unrelated.entry is not None + assert unrelated.entry.citation.memory_ref.revision > entry.citation.memory_ref.revision + + ordinary = await client.prepare_context( + PrepareContextRequest(scope_id=scope.scope_id, query="orange moonlight release protocol") + ) + assert ordinary.status == "ready" + assert ordinary.content is not None and entry.citation.entry_version_id in ordinary.content + deduplicated = await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="orange moonlight release protocol", + bootstrap_receipt_id=prepared.receipt.receipt_id, + ) + ) + assert deduplicated.status == "empty" + assert assessed_memory_counts[-1] == 0 + + asyncio.run(scenario()) + + +def test_pending_bootstrap_retry_preserves_package_level_omission(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'package-omission-retry.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest( + title="Package omission retry", + summary="Preserve package-level truncation", + idempotency_key="package-omission-retry", + ) + ) + entries = [] + for marker in ("FIRST-ITEM", "SECOND-ITEM"): + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="decision", + text=f"{marker}: keep this complete body in deterministic order.", + ) + ) + assert remembered.entry is not None + entries.append(remembered.entry) + for entry in entries: + await _tag_for_bootstrap( + client, + scope.scope_id, + entry.citation.memory_ref.artifact_id, + entry.citation.entry_id, + ) + + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "package-omission-event", + "max_bytes": 900, + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + assert prepared.truncated is True + assert len(prepared.items) == 1 + assert prepared.items[0].truncated is False + + retried = await client.prepare_bootstrap_context(request) + assert retried.status == "ready" + assert retried.receipt == prepared.receipt + assert retried.content == prepared.content + assert retried.items == prepared.items + assert retried.truncated is True + + asyncio.run(scenario()) + + def test_bootstrap_includes_only_the_explicit_exact_handoff(tmp_path) -> None: app = create_server_app( settings=ServerSettings( @@ -309,6 +514,9 @@ async def scenario() -> None: }) ) assert without_selection.status == "empty" + database = app.state.application._bootstrap_receipts._database + async with database.transaction() as connection: + assert (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE))).all() == [] selected = await client.prepare_bootstrap_context( BootstrapContextRequest.model_validate({ "scope_id": scope.scope_id, @@ -325,6 +533,127 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_bootstrap_receipts_expire_after_the_retry_window(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'receipt-retention.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Receipt retention", summary="Bound receipt rows", idempotency_key="expiry") + ) + old = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "expired-event", + }) + ) + database = app.state.application._bootstrap_receipts._database + async with database.transaction() as connection: + await connection.execute( + update(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE) + .where(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id == old.receipt.receipt_id) + .values(created_at=datetime.now(UTC) - timedelta(days=31)) + ) + + await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="Check whether retained context exists.", + bootstrap_receipt_id=old.receipt.receipt_id, + ) + ) + async with database.transaction() as connection: + assert (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE))).all() == [] + + await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "current-event", + }) + ) + async with database.transaction() as connection: + receipt_ids = set( + (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id))).scalars() + ) + assert old.receipt.receipt_id not in receipt_ids + assert len(receipt_ids) == 1 + + asyncio.run(scenario()) + + +def test_reconstruction_failure_uses_a_concurrent_terminal_receipt_state(tmp_path, monkeypatch) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'reconstruction-race.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Receipt race", summary="Concurrent terminal state", idempotency_key="race") + ) + remembered = await client.remember_memory( + RememberMemoryRequest(scope_id=scope.scope_id, kind="decision", text="Keep the blue release flag.") + ) + assert remembered.entry is not None + await _tag_for_bootstrap( + client, + scope.scope_id, + remembered.entry.citation.memory_ref.artifact_id, + remembered.entry.citation.entry_id, + ) + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "reconstruction-race", + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + + async def fail_reconstruction(_application, _item): + raise ValueError + + repository = app.state.application._bootstrap_receipts + original_record_delivery = repository.record_delivery + + async def concurrent_delivery(scope_id, receipt_id, outcome): + assert outcome == "failed" + await original_record_delivery(scope_id, receipt_id, "injected") + return await original_record_delivery(scope_id, receipt_id, outcome) + + monkeypatch.setattr(ScopedBootstrapApplication, "_candidate_from_item", fail_reconstruction) + monkeypatch.setattr(repository, "record_delivery", concurrent_delivery) + retried = await client.prepare_bootstrap_context(request) + assert retried.status == "skipped" + assert retried.reason == "already_delivered" + assert retried.receipt.state == "injected" + + asyncio.run(scenario()) + + def test_bootstrap_considers_only_the_first_eight_direct_context_references(tmp_path) -> None: app = create_server_app( settings=ServerSettings( diff --git a/tests/e2e/test_recall_sufficiency_gate.py b/tests/e2e/test_recall_sufficiency_gate.py index 9a958c72d0..1767d2df2e 100644 --- a/tests/e2e/test_recall_sufficiency_gate.py +++ b/tests/e2e/test_recall_sufficiency_gate.py @@ -94,6 +94,7 @@ async def counted( admission: Any, reuse: Any, topic_reuse: Any, + excluded_memory: Any, ) -> Any: log.calls.append({"families": set(families), "admission": admission}) if log.empty_from is not None and len(log.calls) >= log.empty_from: @@ -107,6 +108,7 @@ async def counted( admission=admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) if log.force_recoverable_family is None: return result @@ -451,6 +453,7 @@ async def changed_head( admission: Any, reuse: Any, topic_reuse: Any, + excluded_memory: Any, ) -> Any: nonlocal calls calls += 1 @@ -464,6 +467,7 @@ async def changed_head( admission=admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) return _RecallRoundOutcome( memory=( @@ -547,6 +551,7 @@ async def fails_on_second_expansion( admission: Any, reuse: Any, topic_reuse: Any, + excluded_memory: Any, ) -> Any: nonlocal calls calls += 1 @@ -561,6 +566,7 @@ async def fails_on_second_expansion( admission=admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) if calls == 2: return _RecallRoundOutcome( From 95864f2c9e42dbcd081c823960982596e007a07b Mon Sep 17 00:00:00 2001 From: chenxu wang <150030641+wangchenxuya@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:47:52 -0400 Subject: [PATCH 3/7] fix(codex): preserve session scope without lifecycle --- .../powercontext/hooks/session_binding.py | 8 +++++ tests/codex_plugin/test_bootstrap.py | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 6167b29723..707d6e1353 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -95,6 +95,14 @@ def main(settings: CodexPluginSettings | None = None) -> int: return 0 source = payload.get("source") if not isinstance(source, str) or source not in _LIFECYCLES: + settings = CodexPluginSettings() if settings is None else settings + resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=monotonic() + settings.http_budget_seconds, + persist_session=True, + ) return 0 try: settings = CodexPluginSettings() if settings is None else settings diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py index fc436ad72f..95cee89fce 100644 --- a/tests/codex_plugin/test_bootstrap.py +++ b/tests/codex_plugin/test_bootstrap.py @@ -77,6 +77,37 @@ def _already_delivered() -> dict[str, object]: return value +def test_session_start_without_lifecycle_still_persists_session_binding( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolutions: list[tuple[str, str | None, bool]] = [] + requests: list[str] = [] + + def resolve(cwd, *, session_id, persist_session=False, **_kwargs): + resolutions.append((cwd, session_id, persist_session)) + return "scope:test" + + monkeypatch.setattr(session_binding_module, "resolve_scope_id", resolve) + monkeypatch.setattr( + session_binding_module, + "_post_json", + lambda path, *_args, **_kwargs: requests.append(path) or _skipped(), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_binding_module.main() == 0 + assert resolutions == [("/workspace", "session-1", True)] + assert requests == [] + assert output.getvalue() == "" + + def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( session_binding_module: ModuleType, recall_module: ModuleType, From fa8cd0412dc366ff1f1735697e0397d07f7f4325 Mon Sep 17 00:00:00 2001 From: chenxu wang <150030641+wangchenxuya@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:01:33 -0400 Subject: [PATCH 4/7] fix(desktop): refresh generated API contract --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 160 ++++++++++++++++++ desktop/ui/src/generated/operations.ts | 2 +- 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 7599102bbf..3c886aa9f8 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "16eff718a6607ab6ff3d03c2a279ee92c26d8895ac804e7259fc11173ac8e3c8", + "contractSha256": "153e9f0ca42d9a9381b75bde3654afc2ff4c2854f09b8798c8e3fbf15b3ffd91", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 1ed9b6b95a..f45758b88a 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -558,6 +558,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/context/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Prepare bounded context for an Agent lifecycle boundary + * @description Prepare an opt-in, deterministic bootstrap package without manufacturing a query. Only explicitly curated active Atomic Memory Artifacts and one caller-selected exact committed Handoff are eligible. Returned historical text is untrusted data and grants no authority. A ready receipt remains pending until the host records delivery. Reusing one stable event identity with different package-shaping fields is rejected with 422. + */ + post: operations["prepare_bootstrap_context"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/context/bootstrap/receipts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Record a bootstrap delivery outcome + * @description Atomically claim one pending content-free receipt as injected or failed. A second injected claim is rejected with 422, so concurrent lifecycle hooks cannot both inject the same package. A terminal outcome never changes. + */ + post: operations["record_bootstrap_delivery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/work/contracts/create": { parameters: { query?: never; @@ -3277,6 +3317,29 @@ export interface components { content: string | null; content_bytes: number; }; + BootstrapContextItem: { + kind: components["schemas"]["BootstrapContextItemKind"]; + scope_id: string; + artifact: components["schemas"]["ArtifactReference"]; + content_digest: string; + truncated: boolean; + }; + BootstrapDeliveryReceipt: { + receipt_id: string; + state: components["schemas"]["BootstrapReceiptState"]; + }; + BootstrapContext: { + schema: components["schemas"]["BootstrapContextSchema"]; + status: components["schemas"]["BootstrapContextStatus"]; + reason: components["schemas"]["BootstrapSkipReason"] | null; + profile: components["schemas"]["BootstrapContextProfile"]; + content: string | null; + content_bytes: number; + package_digest: string | null; + items: components["schemas"]["BootstrapContextItem"][]; + truncated: boolean; + receipt: components["schemas"]["BootstrapDeliveryReceipt"]; + }; EntryChange: { op: components["schemas"]["EntryChangeOperation"]; entry_id: string; @@ -3954,6 +4017,29 @@ export interface components { */ include_code: boolean; assembly?: components["schemas"]["ContextAssembly"]; + /** @description Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; Artifacts whose bootstrap bodies were truncated remain eligible. */ + bootstrap_receipt_id?: string; + }; + BootstrapContextRequest: { + scope_id: string; + /** @default false */ + enabled: boolean; + /** @default powercontext.scope-bootstrap.v1 */ + profile: components["schemas"]["BootstrapContextProfile"]; + lifecycle: components["schemas"]["BootstrapContextLifecycle"]; + integration: string; + /** @description Optional stable host event identity, persisted only as a digest. Within the same Scope, integration, and lifecycle, retries must keep the profile, max_bytes, and exact Handoff unchanged. */ + event_id?: string; + /** @default 4096 */ + max_bytes: number; + /** @description Optional exact committed Handoff Revision in the current Scope. */ + handoff?: components["schemas"]["ArtifactReference"]; + }; + RecordBootstrapDeliveryRequest: { + scope_id: string; + receipt_id: string; + /** @enum {string} */ + outcome: "injected" | "failed"; }; ContextAssemblySection: { family: components["schemas"]["ContextAssemblyFamily"]; @@ -4506,6 +4592,20 @@ export interface components { /** @enum {string} */ PreparedContextStatus: "ready" | "empty"; /** @enum {string} */ + BootstrapContextSchema: "powercontext.bootstrap-context.v1"; + /** @enum {string} */ + BootstrapContextProfile: "powercontext.scope-bootstrap.v1"; + /** @enum {string} */ + BootstrapContextLifecycle: "startup" | "resume" | "clear" | "compact" | "restore" | "fork"; + /** @enum {string} */ + BootstrapContextStatus: "ready" | "empty" | "skipped"; + /** @enum {string} */ + BootstrapContextItemKind: "atomic-memory" | "handoff"; + /** @enum {string} */ + BootstrapReceiptState: "pending" | "injected" | "skipped" | "failed"; + /** @enum {string} */ + BootstrapSkipReason: "disabled" | "no_eligible_context" | "already_delivered" | "preparation_failed"; + /** @enum {string} */ EntryChangeOperation: "add" | "revise" | "deactivate" | "reactivate" | "compact"; /** @enum {string} */ FlushStatus: "idle" | "processed"; @@ -5921,6 +6021,66 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + prepare_bootstrap_context: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BootstrapContextRequest"]; + }; + }; + responses: { + /** @description A bounded package ready for host injection, or a normal empty/skipped result. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapContext"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + record_bootstrap_delivery: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordBootstrapDeliveryRequest"]; + }; + }; + responses: { + /** @description Current durable delivery state. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapDeliveryReceipt"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; create_work_contract: { parameters: { query?: never; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 38ab224bdb..6e7f9ea5b4 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "16eff718a6607ab6ff3d03c2a279ee92c26d8895ac804e7259fc11173ac8e3c8"; +export const contractSha256 = "153e9f0ca42d9a9381b75bde3654afc2ff4c2854f09b8798c8e3fbf15b3ffd91"; export const operations = { "get_liveness": { "method": "GET", From 7e0b16c14fee1a038508345818aec9a3fa564f37 Mon Sep 17 00:00:00 2001 From: chenxu wang <150030641+wangchenxuya@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:59:08 -0400 Subject: [PATCH 5/7] fix(context): harden bootstrap delivery races --- .../plugins/powercontext/bootstrap_state.py | 112 +++++++++++++---- .../powercontext/hooks/session_start.py | 45 ++++++- .../plugins/powercontext/bootstrap_state.py | 112 +++++++++++++---- .../powercontext/hooks/session_binding.py | 45 ++++++- .../builtin/runtime/application.py | 35 +++++- tests/claude_code_plugin/test_bootstrap.py | 39 +++++- tests/codex_plugin/test_bootstrap.py | 39 +++++- tests/e2e/test_access_control_regressions.py | 117 ++++++++++++++++++ tests/e2e/test_bootstrap_context.py | 96 ++++++++++++++ 9 files changed, 563 insertions(+), 77 deletions(-) diff --git a/integrations/claude-code/plugins/powercontext/bootstrap_state.py b/integrations/claude-code/plugins/powercontext/bootstrap_state.py index 69d615491e..00f4d1308d 100644 --- a/integrations/claude-code/plugins/powercontext/bootstrap_state.py +++ b/integrations/claude-code/plugins/powercontext/bootstrap_state.py @@ -19,26 +19,39 @@ import json import os import stat -from contextlib import suppress +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 value.get("scope_id") != scope_id + 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) @@ -46,7 +59,7 @@ def load_receipt(session_id: str | None, scope_id: str) -> str | None: return None except (OSError, ValueError, TypeError, json.JSONDecodeError): return None - return receipt_id + return scope_id, receipt_id def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None: @@ -63,38 +76,87 @@ def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, separators=(",", ":"), ).encode() - 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: - with os.fdopen(descriptor, "wb", closefd=False) as output: - output.write(payload) - output.flush() - os.fsync(output.fileno()) - os.replace(temporary, path) - if os.name != "nt": - path.chmod(0o600) - finally: - os.close(descriptor) - with suppress(FileNotFoundError): - temporary.unlink() + 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: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + os.close(descriptor) + with suppress(FileNotFoundError): + temporary.unlink() def clear_receipt(session_id: str | None) -> None: path = _state_path(session_id) - if path is None: + if path is None or not path.parent.exists(): return try: - if not path.is_symlink(): - path.unlink(missing_ok=True) + 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 diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py index 071633b6d8..09cfbdd515 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/session_start.py +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -18,6 +18,7 @@ from __future__ import annotations import json +import os import sys from collections.abc import Mapping from contextlib import suppress @@ -32,7 +33,13 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import clear_receipt, load_receipt, save_receipt # noqa: E402 +from bootstrap_state import ( # noqa: E402 + clear_receipt, + clear_receipt_if_matches, + load_receipt, + load_receipt_state, + save_receipt, +) from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from hooks.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, @@ -128,8 +135,8 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: return 0 receipt = cast(dict[str, str], response["receipt"]) receipt_id = receipt["receipt_id"] + previous_receipt = load_receipt_state(session_id) try: - save_receipt(session_id, scope_id, receipt_id) delivered = validate_delivery_receipt( _post_json( "/v1/context/bootstrap/receipts", @@ -141,7 +148,7 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: ) _require_injected(delivered) except Exception: - clear_receipt(session_id) + _clear_stale_receipt_after_failed_claim(session_id, scope_id, receipt_id, previous_receipt) _emit_bootstrap_event("delivery_failed") with suppress(Exception): _post_json( @@ -152,6 +159,7 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: ) return 0 try: + save_receipt(session_id, scope_id, receipt_id) json.dump( { "hookSpecificOutput": { @@ -163,9 +171,9 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: separators=(",", ":"), ) sys.stdout.write("\n") - except Exception: - clear_receipt(session_id) - _emit_bootstrap_event("delivery_failed") + sys.stdout.flush() + except Exception as error: + _handle_output_failure(session_id, scope_id, receipt_id, error) return 0 except Exception: return 0 @@ -280,6 +288,31 @@ def _require_injected(receipt: Mapping[str, str]) -> None: 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 _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): + try: + stdout = sys.stdout.fileno() + descriptor = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(descriptor, stdout) + finally: + os.close(descriptor) + except (AttributeError, OSError, ValueError): + pass + _emit_bootstrap_event("delivery_failed") + + def _synchronize_non_ready_receipt( session_id: str, scope_id: str, diff --git a/integrations/codex/plugins/powercontext/bootstrap_state.py b/integrations/codex/plugins/powercontext/bootstrap_state.py index 3beb18c5cb..eff55ff347 100644 --- a/integrations/codex/plugins/powercontext/bootstrap_state.py +++ b/integrations/codex/plugins/powercontext/bootstrap_state.py @@ -19,26 +19,39 @@ import json import os import stat -from contextlib import suppress +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 value.get("scope_id") != scope_id + 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) @@ -46,7 +59,7 @@ def load_receipt(session_id: str | None, scope_id: str) -> str | None: return None except (OSError, ValueError, TypeError, json.JSONDecodeError): return None - return receipt_id + return scope_id, receipt_id def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None: @@ -63,38 +76,87 @@ def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, separators=(",", ":"), ).encode() - 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: - with os.fdopen(descriptor, "wb", closefd=False) as output: - output.write(payload) - output.flush() - os.fsync(output.fileno()) - os.replace(temporary, path) - if os.name != "nt": - path.chmod(0o600) - finally: - os.close(descriptor) - with suppress(FileNotFoundError): - temporary.unlink() + 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: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + os.close(descriptor) + with suppress(FileNotFoundError): + temporary.unlink() def clear_receipt(session_id: str | None) -> None: path = _state_path(session_id) - if path is None: + if path is None or not path.parent.exists(): return try: - if not path.is_symlink(): - path.unlink(missing_ok=True) + 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 diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 1ec2519d72..70ae31fbde 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -18,6 +18,7 @@ from __future__ import annotations import json +import os import sys from collections.abc import Mapping from contextlib import suppress @@ -32,7 +33,13 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from bootstrap_state import clear_receipt, load_receipt, save_receipt # noqa: E402 +from bootstrap_state import ( # noqa: E402 + clear_receipt, + clear_receipt_if_matches, + load_receipt, + load_receipt_state, + save_receipt, +) from hooks.bootstrap_context import ( # noqa: E402 InvalidBootstrapResponse, validate_bootstrap_context, @@ -130,8 +137,8 @@ def main(settings: CodexPluginSettings | None = None) -> int: return 0 receipt = cast(dict[str, str], response["receipt"]) receipt_id = receipt["receipt_id"] + previous_receipt = load_receipt_state(session_id) try: - save_receipt(session_id, scope_id, receipt_id) delivered = validate_delivery_receipt( _post_json( "/v1/context/bootstrap/receipts", @@ -143,7 +150,7 @@ def main(settings: CodexPluginSettings | None = None) -> int: ) _require_injected(delivered) except Exception: - clear_receipt(session_id) + _clear_stale_receipt_after_failed_claim(session_id, scope_id, receipt_id, previous_receipt) _emit_bootstrap_event("delivery_failed") with suppress(Exception): _post_json( @@ -154,6 +161,7 @@ def main(settings: CodexPluginSettings | None = None) -> int: ) return 0 try: + save_receipt(session_id, scope_id, receipt_id) json.dump( { "hookSpecificOutput": { @@ -165,9 +173,9 @@ def main(settings: CodexPluginSettings | None = None) -> int: separators=(",", ":"), ) sys.stdout.write("\n") - except Exception: - clear_receipt(session_id) - _emit_bootstrap_event("delivery_failed") + sys.stdout.flush() + except Exception as error: + _handle_output_failure(session_id, scope_id, receipt_id, error) return 0 except Exception: return 0 @@ -283,6 +291,31 @@ def _require_injected(receipt: Mapping[str, str]) -> None: 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 _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): + try: + stdout = sys.stdout.fileno() + descriptor = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(descriptor, stdout) + finally: + os.close(descriptor) + except (AttributeError, OSError, ValueError): + pass + _emit_bootstrap_event("delivery_failed") + + def _synchronize_non_ready_receipt( session_id: str, scope_id: str, diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 9b69f156e6..7c8b617626 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -1376,10 +1376,15 @@ async def _recall_round( generation_calls = 0 memory_recoverable = False for scope_id in scope_ids: + memory_limit = ( + builder.memory_candidate_limit + sum(identity[0] == scope_id for identity in excluded_memory) + if MEMORY_FAMILY in families + else 0 + ) outcome = await self._recall_scope( scope_id, request, - memory_limit=builder.memory_candidate_limit if MEMORY_FAMILY in families else 0, + memory_limit=memory_limit, experience_limit=builder.experience_candidate_limit if EXPERIENCE_FAMILY in families else 0, admission=admission, reuse=reuse.get(scope_id), @@ -1835,7 +1840,14 @@ async def prepare( candidates = await self._select_candidates(request, scope_ids) build = build_bootstrap_context(candidates, request.max_bytes) if build.content is None: - return await self._empty_result(request, event_key, build, repository) + return await self._empty_result( + request, + event_key, + build, + scope_ids, + repository, + authorize_scopes, + ) stored, created = await repository.create( self._stored_receipt( @@ -1849,6 +1861,7 @@ async def prepare( ) if not created: self._require_matching_request(stored, request) + await self._authorize_stored_result(stored, scope_ids, authorize_scopes) return await self._existing_result(stored) return _ready_bootstrap(stored, build.content) @@ -1876,17 +1889,28 @@ async def _existing_event_result( if existing is None: return None self._require_matching_request(existing, request) - if authorize_scopes is not None: - stored_scope_ids = tuple(dict.fromkeys((*scope_ids, *(item.scope_id for item in existing.items)))) - await authorize_scopes(stored_scope_ids) + await self._authorize_stored_result(existing, scope_ids, authorize_scopes) return await self._existing_result(existing) + async def _authorize_stored_result( + self, + stored: StoredBootstrapReceipt, + scope_ids: tuple[str, ...], + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, + ) -> None: + if authorize_scopes is None: + return + stored_scope_ids = tuple(dict.fromkeys((*scope_ids, *(item.scope_id for item in stored.items)))) + await authorize_scopes(stored_scope_ids) + async def _empty_result( self, request: BootstrapContextRequest, event_key: str | None, build: BootstrapBuild, + scope_ids: tuple[str, ...], repository: BootstrapReceiptRepository, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, ) -> BootstrapContext: candidate = self._stored_receipt( request, @@ -1901,6 +1925,7 @@ async def _empty_result( stored, created = await repository.create(candidate) if not created: self._require_matching_request(stored, request) + await self._authorize_stored_result(stored, scope_ids, authorize_scopes) return await self._existing_result(stored) return BootstrapContext( status="empty", diff --git a/tests/claude_code_plugin/test_bootstrap.py b/tests/claude_code_plugin/test_bootstrap.py index d5417495c5..3f03370a7a 100644 --- a/tests/claude_code_plugin/test_bootstrap.py +++ b/tests/claude_code_plugin/test_bootstrap.py @@ -16,6 +16,7 @@ import io import json +import os import sys from hashlib import sha256 from pathlib import Path @@ -313,15 +314,39 @@ def post(path, _payload, **_kwargs): assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" -def test_session_start_output_failure_clears_the_deduplication_receipt( +def test_concurrent_delivery_loser_preserves_the_winner_receipt( session_start_module: ModuleType, monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, ) -> None: - class BrokenOutput: - def write(self, _value): + session_start_module.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") raise OSError + return {"receipt_id": "bcr_test", "state": "injected"} + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + 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("session-1", "scope:test") == "bcr_test" + +def test_session_start_output_failure_clears_the_deduplication_receipt( + session_start_module: 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() @@ -332,10 +357,14 @@ def post(path, _payload, **_kwargs): "stdin", io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), ) - monkeypatch.setattr(sys, "stdout", BrokenOutput()) + read_descriptor, write_descriptor = os.pipe() + os.close(read_descriptor) + output = os.fdopen(write_descriptor, "w") + monkeypatch.setattr(sys, "stdout", output) monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + 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()) diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py index 943a205004..bc3730c3ed 100644 --- a/tests/codex_plugin/test_bootstrap.py +++ b/tests/codex_plugin/test_bootstrap.py @@ -16,6 +16,7 @@ import io import json +import os import sys from hashlib import sha256 from pathlib import Path @@ -338,15 +339,39 @@ def post(path, _payload, **_kwargs): assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" -def test_session_start_output_failure_clears_the_deduplication_receipt( +def test_concurrent_delivery_loser_preserves_the_winner_receipt( session_binding_module: ModuleType, monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, ) -> None: - class BrokenOutput: - def write(self, _value): + session_binding_module.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") raise OSError + return {"receipt_id": "bcr_test", "state": "injected"} + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + 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("session-1", "scope:test") == "bcr_test" + +def test_session_start_output_failure_clears_the_deduplication_receipt( + session_binding_module: 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() @@ -357,10 +382,14 @@ def post(path, _payload, **_kwargs): "stdin", io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), ) - monkeypatch.setattr(sys, "stdout", BrokenOutput()) + read_descriptor, write_descriptor = os.pipe() + os.close(read_descriptor) + output = os.fdopen(write_descriptor, "w") + monkeypatch.setattr(sys, "stdout", output) monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + 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()) diff --git a/tests/e2e/test_access_control_regressions.py b/tests/e2e/test_access_control_regressions.py index f18d891b33..10462c035b 100644 --- a/tests/e2e/test_access_control_regressions.py +++ b/tests/e2e/test_access_control_regressions.py @@ -359,6 +359,123 @@ async def scenario(): asyncio.run(scenario()) +@pytest.mark.parametrize("reader_has_candidate", [False, True]) +def test_bootstrap_insert_conflict_reauthorizes_the_winning_receipt(tmp_path, monkeypatch, reader_has_candidate): + async def scenario(): + async with _server(tmp_path) as (app, client, _): + private = await _scope(client) + remembered = await client.post( + "/v1/memory/remember", + json={ + "scope_id": private, + "kind": "constraint", + "text": "PRIVATE concurrent bootstrap constraint.", + }, + ) + assert remembered.status_code == 200, remembered.text + memory_id = remembered.json()["records"][0]["artifact"]["artifact_id"] + tags_path = f"/v1/scopes/{private}/artifacts/atomic-memory/{memory_id}/tags" + tags = await client.get(tags_path) + assert tags.status_code == 200, tags.text + tagged = await client.put( + tags_path, + headers={"If-Match": tags.headers["ETag"]}, + json={"tags": ["bootstrap-context"]}, + ) + assert tagged.status_code == 200, tagged.text + + created = await client.post( + "/v1/scopes", + json={ + "title": "Concurrent bootstrap scope", + "summary": "Starts without a Context Reference", + "idempotency_key": "bootstrap-insert-conflict", + }, + ) + assert created.status_code == 201, created.text + current = created.json()["scope_id"] + await _grant(client, current, "bob", "scope.viewer") + if reader_has_candidate: + public = await client.post( + "/v1/memory/remember", + json={ + "scope_id": current, + "kind": "fact", + "text": "Public concurrent bootstrap context.", + }, + ) + assert public.status_code == 200, public.text + public_id = public.json()["records"][0]["artifact"]["artifact_id"] + public_tags_path = f"/v1/scopes/{current}/artifacts/atomic-memory/{public_id}/tags" + public_tags = await client.get(public_tags_path) + assert public_tags.status_code == 200, public_tags.text + public_tagged = await client.put( + public_tags_path, + headers={"If-Match": public_tags.headers["ETag"]}, + json={"tags": ["bootstrap-context"]}, + ) + assert public_tagged.status_code == 200, public_tagged.text + headers = {"Authorization": "Bearer bob"} + payload = { + "scope_id": current, + "enabled": True, + "lifecycle": "resume", + "integration": "access-regression", + "event_id": "concurrent-bootstrap-event", + } + + repository = app.state.application._bootstrap_receipts + original_create = repository.create + reader_waiting = asyncio.Event() + release_reader = asyncio.Event() + first_create = True + + async def pause_first_receipt(receipt): + nonlocal first_create + if first_create: + first_create = False + assert bool(receipt.items) is reader_has_candidate + reader_waiting.set() + await release_reader.wait() + return await original_create(receipt) + + monkeypatch.setattr(repository, "create", pause_first_receipt) + reader_task = asyncio.create_task(client.post("/v1/context/bootstrap", headers=headers, json=payload)) + try: + await asyncio.wait_for(reader_waiting.wait(), timeout=5) + updated = await client.put( + f"/v1/scopes/{current}", + json={ + "expected_version": created.json()["version"], + "title": created.json()["title"], + "summary": created.json()["summary"], + "context_references": [private], + }, + ) + assert updated.status_code == 200, updated.text + winner = await client.post("/v1/context/bootstrap", json=payload) + assert winner.status_code == 200, winner.text + assert winner.json()["status"] == "ready" + assert "PRIVATE concurrent bootstrap constraint" in winner.json()["content"] + finally: + release_reader.set() + + denied = await reader_task + assert denied.status_code == 403, denied.text + assert "PRIVATE concurrent bootstrap constraint" not in denied.text + assert ( + await client.get( + f"/v1/scopes/{private}/artifacts/atomic-memory/{memory_id}", + headers=headers, + ) + ).status_code == 403 + retry = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert retry.status_code == 403 + assert "PRIVATE concurrent bootstrap constraint" not in retry.text + + asyncio.run(scenario()) + + async def _grant(client, scope_id, principal, role, resource=None): response = await client.post( "/v1/access/bindings/create", diff --git a/tests/e2e/test_bootstrap_context.py b/tests/e2e/test_bootstrap_context.py index d9614e24e8..e73bd32927 100644 --- a/tests/e2e/test_bootstrap_context.py +++ b/tests/e2e/test_bootstrap_context.py @@ -378,6 +378,102 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_bootstrap_deduplication_replenishes_candidates_before_the_limit(tmp_path, monkeypatch) -> None: + assessed_memory_counts: list[int] = [] + original_assess = RecallSufficiencyGate.assess + + def observe_assess(self, candidates, *args, **kwargs): + assessed_memory_counts.append(sum(candidate.family == "memory" for candidate in candidates)) + return original_assess(self, candidates, *args, **kwargs) + + monkeypatch.setattr(RecallSufficiencyGate, "assess", observe_assess) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'deduplicate-before-limit.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + runtime=RuntimeConfig( + recall_gate_enabled=True, + recall_gate_min_top_score=0.0, + recall_gate_min_top_gap=0.0, + recall_gate_min_lexical_overlap=0.0, + ), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest( + title="Candidate replenishment", + summary="Exclude bootstrap revisions before limiting recall", + idempotency_key="candidate-replenishment", + ) + ) + memories = [] + for index in range(22): + marker = f"REPLENISH-{index:02d}" + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="fact", + text=f"Candidate replenishment evidence {marker} remains independently recallable.", + ) + ) + assert len(remembered.records) == 1 + memories.append((marker, remembered.records[0])) + + recall_request = PrepareContextRequest.model_validate({ + "scope_id": scope.scope_id, + "query": "candidate replenishment evidence", + "max_bytes": 32768, + "assembly": {"sections": [{"family": "memory", "limit": 8}]}, + }) + ordinary = await client.prepare_context(recall_request) + assert ordinary.content is not None + assert ordinary.content.count("REPLENISH-") == 8 + assert assessed_memory_counts[-1] == 16 + delivered = [(marker, record) for marker, record in memories if marker in ordinary.content][:6] + assert len(delivered) == 6 + for _marker, record in delivered: + await _tag_for_bootstrap(client, scope.scope_id, record.artifact.artifact_id) + + prepared = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "candidate-replenishment-event", + "max_bytes": 4096, + }) + ) + assert prepared.status == "ready" + assert len(prepared.items) == 6 + assert all(not item.truncated for item in prepared.items) + await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "injected", + }) + ) + + deduplicated = await client.prepare_context( + recall_request.model_copy(update={"bootstrap_receipt_id": prepared.receipt.receipt_id}) + ) + assert deduplicated.content is not None + assert deduplicated.content.count("REPLENISH-") == 8 + assert all(marker not in deduplicated.content for marker, _record in delivered) + assert assessed_memory_counts[-1] == 16 + + asyncio.run(scenario()) + + def test_pending_bootstrap_retry_preserves_package_level_omission(tmp_path) -> None: app = create_server_app( settings=ServerSettings( From f2f205ac93a878a1f8939eb56185260f66660786 Mon Sep 17 00:00:00 2001 From: chenxu wang <150030641+wangchenxuya@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:32:12 -0400 Subject: [PATCH 6/7] fix(context): bound bootstrap scan races --- .../powercontext/hooks/session_start.py | 20 ++-- .../powercontext/hooks/session_binding.py | 20 ++-- src/powercontext/builtin/persistence/tags.py | 97 ++++++++----------- tests/builtin/persistence/test_records.py | 70 ++++++++++++- tests/claude_code_plugin/test_bootstrap.py | 57 ++++++++++- tests/codex_plugin/test_bootstrap.py | 57 ++++++++++- 6 files changed, 247 insertions(+), 74 deletions(-) diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py index 09cfbdd515..1df3a08984 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/session_start.py +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -34,9 +34,7 @@ sys.path.insert(0, str(_SCRIPTS_ROOT)) from bootstrap_state import ( # noqa: E402 - clear_receipt, clear_receipt_if_matches, - load_receipt, load_receipt_state, save_receipt, ) @@ -110,19 +108,20 @@ 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_receipt(session_id) + _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_receipt(session_id) + _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) + receipt = _synchronize_non_ready_receipt(session_id, scope_id, response, previous_receipt) if response["status"] == "empty": _emit_bootstrap_event( "empty", @@ -135,7 +134,6 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: return 0 receipt = cast(dict[str, str], response["receipt"]) receipt_id = receipt["receipt_id"] - previous_receipt = load_receipt_state(session_id) try: delivered = validate_delivery_receipt( _post_json( @@ -298,6 +296,11 @@ def _clear_stale_receipt_after_failed_claim( 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): @@ -317,6 +320,7 @@ 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.""" @@ -325,10 +329,10 @@ def _synchronize_non_ready_receipt( response.get("status") == "skipped" and response.get("reason") == "already_delivered" and receipt["state"] == "injected" - and load_receipt(session_id, scope_id) == receipt["receipt_id"] + and previous_receipt == (scope_id, receipt["receipt_id"]) ): return receipt - clear_receipt(session_id) + _clear_observed_receipt(session_id, previous_receipt) return receipt diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 70ae31fbde..110f1f60bf 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -34,9 +34,7 @@ sys.path.insert(0, str(_SCRIPTS_ROOT)) from bootstrap_state import ( # noqa: E402 - clear_receipt, clear_receipt_if_matches, - load_receipt, load_receipt_state, save_receipt, ) @@ -112,19 +110,20 @@ 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_receipt(session_id) + _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_receipt(session_id) + _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) + receipt = _synchronize_non_ready_receipt(session_id, scope_id, response, previous_receipt) if response["status"] == "empty": _emit_bootstrap_event( "empty", @@ -137,7 +136,6 @@ def main(settings: CodexPluginSettings | None = None) -> int: return 0 receipt = cast(dict[str, str], response["receipt"]) receipt_id = receipt["receipt_id"] - previous_receipt = load_receipt_state(session_id) try: delivered = validate_delivery_receipt( _post_json( @@ -301,6 +299,11 @@ def _clear_stale_receipt_after_failed_claim( 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): @@ -320,6 +323,7 @@ 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.""" @@ -328,10 +332,10 @@ def _synchronize_non_ready_receipt( response.get("status") == "skipped" and response.get("reason") == "already_delivered" and receipt["state"] == "injected" - and load_receipt(session_id, scope_id) == receipt["receipt_id"] + and previous_receipt == (scope_id, receipt["receipt_id"]) ): return receipt - clear_receipt(session_id) + _clear_observed_receipt(session_id, previous_receipt) return receipt diff --git a/src/powercontext/builtin/persistence/tags.py b/src/powercontext/builtin/persistence/tags.py index ee44b55be0..9353b900d0 100644 --- a/src/powercontext/builtin/persistence/tags.py +++ b/src/powercontext/builtin/persistence/tags.py @@ -204,68 +204,55 @@ async def query(self, scope_id: str, query: TagQuery, *, caller: str = "runtime" ).hexdigest() after = self._decode_cursor(query.cursor, binding) table = ARTIFACT_TAGS_TABLE + heads = ARTIFACT_HEADS_TABLE order = (table.c.family, table.c.target_type, table.c.artifact_id, table.c.target_id) items: list[TaggedTarget] = [] keys: list[tuple[str, ...]] = [] async with self._database.transaction() as connection: await _begin_read_snapshot(connection) - heads: dict[tuple[str, str], tuple[Artifact[Any], str]] = {} - while len(items) <= query.limit: - statement = ( - select(*order) - .where( - table.c.scope_id == scope_id, - table.c.tag_key_hash.in_(_key_hashes(query)), - table.c.tag_key.in_(query.keys), - table.c.family.in_(query.families), - table.c.target_type.in_(query.target_types), - tuple_(*order) > tuple_(*(literal(value) for value in after)), + statement = ( + select(*order, heads.c.revision) + .select_from( + table.join( + heads, + and_( + heads.c.scope_id == table.c.scope_id, + heads.c.family == table.c.family, + heads.c.artifact_id == table.c.artifact_id, + ), ) - .group_by(*order) - .having(func.count() == len(query.keys) if query.match == "all" else func.count() > 0) - .order_by(*order) - .limit(100) ) - rows = (await connection.execute(statement)).all() - for row in rows: - key = tuple(str(value) for value in row) - family, _target_type, artifact_id, _target_id = key - target = ArtifactTagTarget(family=cast(Any, family), artifact_id=artifact_id) - head_key = (family, artifact_id) - if head_key not in heads: - head_row = ( - await connection.execute( - select( - ARTIFACT_HEADS_TABLE.c.revision, - ARTIFACT_HEADS_TABLE.c.lifecycle_state, - ).where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ) - ) - ).one_or_none() - if head_row is None: - continue - ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=head_row.revision) - heads[head_key] = ( - await self._artifacts.get(connection, scope_id, ref), - str(head_row.lifecycle_state), - ) - artifact, lifecycle = heads[head_key] - if not query.include_inactive and lifecycle != "active": - continue - reference = artifact.as_ref() - labels = await self._read(connection, scope_id, target) - items.append(TaggedTarget(**labels.model_dump(), reference=reference)) - keys.append(key) - if len(items) > query.limit: - break - if not rows or len(rows) < 100: - break - after = tuple(str(value) for value in rows[-1]) - cursor = self._encode_cursor(keys[query.limit - 1], binding) if len(items) > query.limit else None - return TagQueryPage(items=tuple(items[: query.limit]), next_cursor=cursor) + .where( + table.c.scope_id == scope_id, + table.c.tag_key_hash.in_(_key_hashes(query)), + table.c.tag_key.in_(query.keys), + table.c.family.in_(query.families), + table.c.target_type.in_(query.target_types), + tuple_(*order) > tuple_(*(literal(value) for value in after)), + ) + .group_by(*order, heads.c.revision) + .having(func.count() == len(query.keys) if query.match == "all" else func.count() > 0) + .order_by(*order) + .limit(query.limit + 1) + ) + if not query.include_inactive: + statement = statement.where(heads.c.lifecycle_state == "active") + rows = (await connection.execute(statement)).all() + artifacts: dict[tuple[str, str], Artifact[Any]] = {} + for row in rows[: query.limit]: + key = tuple(str(value) for value in row[:4]) + family, _target_type, artifact_id, _target_id = key + target = ArtifactTagTarget(family=cast(Any, family), artifact_id=artifact_id) + head_key = (family, artifact_id) + if head_key not in artifacts: + ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=int(row[4])) + artifacts[head_key] = await self._artifacts.get(connection, scope_id, ref) + reference = artifacts[head_key].as_ref() + labels = await self._read(connection, scope_id, target) + items.append(TaggedTarget(**labels.model_dump(), reference=reference)) + keys.append(key) + cursor = self._encode_cursor(keys[-1], binding) if len(rows) > query.limit else None + return TagQueryPage(items=tuple(items), next_cursor=cursor) async def _read( self, connection: AsyncConnection, scope_id: str, target: TagTarget, *, current=False diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index 350b9e20cb..c39f5e2ed6 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -21,7 +21,7 @@ import pytest from pydantic import JsonValue, ValidationError -from sqlalchemy import event, func, select +from sqlalchemy import event, func, select, update from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef @@ -196,6 +196,74 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_tag_query_filters_inactive_heads_before_bounded_materialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + async with _atomic_record_services() as (contexts, records, _, _): + inactive_ids: list[str] = [] + active_ids: list[str] = [] + for index in range(73): + created = await records.create_artifact( + "scope", + "atomic-memory", + ArtifactWrite(content={"kind": "fact", "text": f"Bounded memory {index}."}), + ) + target = ArtifactTagTarget(family="atomic-memory", artifact_id=created.artifact_id) + empty = await records.get_tags("scope", target) + await records.replace_tags("scope", target, ("bootstrap-context",), expected_etag=empty.etag) + (inactive_ids if index < 40 else active_ids).append(created.artifact_id) + + async with contexts.database.transaction() as connection: + await connection.execute( + update(ARTIFACT_HEADS_TABLE) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == "scope", + ARTIFACT_HEADS_TABLE.c.family == "atomic-memory", + ARTIFACT_HEADS_TABLE.c.artifact_id.in_(inactive_ids), + ) + .values(lifecycle_state="deprecated") + ) + + loaded: list[ArtifactRef] = [] + original_get = ArtifactRepository.get + + async def track_get(repository, connection, scope_id, ref, /, *, for_update=False): + loaded.append(ref) + return await original_get( + repository, + connection, + scope_id, + ref, + for_update=for_update, + ) + + monkeypatch.setattr(ArtifactRepository, "get", track_get) + first = await records.query_tags( + "scope", + TagQuery(tags=("bootstrap-context",), limit=32), + caller="bootstrap-context", + ) + + assert len(first.items) == 32 + assert first.next_cursor is not None + assert len(loaded) == 32 + assert {ref.artifact_id for ref in loaded}.isdisjoint(inactive_ids) + + loaded.clear() + second = await records.query_tags( + "scope", + TagQuery(tags=("bootstrap-context",), limit=32, cursor=first.next_cursor), + caller="bootstrap-context", + ) + assert len(second.items) == 1 + assert second.next_cursor is None + assert len(loaded) == 1 + assert {item.reference.artifact_id for item in (*first.items, *second.items)} == set(active_ids) + + asyncio.run(scenario()) + + def test_in_memory_sqlite_supports_concurrent_tag_reads() -> None: async def scenario() -> None: async with _atomic_record_services() as (_, records, _, _): diff --git a/tests/claude_code_plugin/test_bootstrap.py b/tests/claude_code_plugin/test_bootstrap.py index 3f03370a7a..06ab515234 100644 --- a/tests/claude_code_plugin/test_bootstrap.py +++ b/tests/claude_code_plugin/test_bootstrap.py @@ -20,6 +20,7 @@ import sys from hashlib import sha256 from pathlib import Path +from threading import Event, Thread from types import ModuleType from typing import cast @@ -213,6 +214,60 @@ def test_repeated_injected_event_preserves_receipt_for_first_query( assert json.loads(state_files[0].read_text())["receipt_id"] == "bcr_test" +def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bootstrap_state = sys.modules[session_start_module.load_receipt_state.__module__] + original_read = bootstrap_state._read_receipt_state + snapshot_read = Event() + winner_saved = Event() + winner_started = False + winner: Thread | None = None + + def synchronized_read(path): + state = original_read(path) + snapshot_read.set() + if winner_started: + assert winner_saved.wait(2) + return state + + def save_winner() -> None: + if snapshot_read.wait(2): + session_start_module.save_receipt("session-1", "scope:test", "bcr_test") + winner_saved.set() + + def post(*_args, **_kwargs): + nonlocal winner, winner_started + winner_started = True + winner = Thread(target=save_winner) + winner.start() + return _already_delivered() + + 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( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + 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") + + def test_invalid_settings_clear_a_stale_receipt( session_start_module: ModuleType, monkeypatch: pytest.MonkeyPatch, @@ -339,7 +394,7 @@ def post(path, payload, **_kwargs): monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 - assert session_start_module.load_receipt("session-1", "scope:test") == "bcr_test" + assert session_start_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_session_start_output_failure_clears_the_deduplication_receipt( diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py index bc3730c3ed..d148cc8322 100644 --- a/tests/codex_plugin/test_bootstrap.py +++ b/tests/codex_plugin/test_bootstrap.py @@ -20,6 +20,7 @@ import sys from hashlib import sha256 from pathlib import Path +from threading import Event, Thread from types import ModuleType from typing import cast @@ -242,6 +243,60 @@ def test_repeated_injected_event_preserves_receipt_for_first_query( assert json.loads(state_files[0].read_text())["receipt_id"] == "bcr_test" +def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bootstrap_state = sys.modules[session_binding_module.load_receipt_state.__module__] + original_read = bootstrap_state._read_receipt_state + snapshot_read = Event() + winner_saved = Event() + winner_started = False + winner: Thread | None = None + + def synchronized_read(path): + state = original_read(path) + snapshot_read.set() + if winner_started: + assert winner_saved.wait(2) + return state + + def save_winner() -> None: + if snapshot_read.wait(2): + session_binding_module.save_receipt("session-1", "scope:test", "bcr_test") + winner_saved.set() + + def post(*_args, **_kwargs): + nonlocal winner, winner_started + winner_started = True + winner = Thread(target=save_winner) + winner.start() + return _already_delivered() + + 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( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + 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") + + def test_invalid_settings_clear_a_stale_receipt( session_binding_module: ModuleType, monkeypatch: pytest.MonkeyPatch, @@ -364,7 +419,7 @@ def post(path, payload, **_kwargs): monkeypatch.setattr(sys, "stderr", io.StringIO()) assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 - assert session_binding_module.load_receipt("session-1", "scope:test") == "bcr_test" + assert session_binding_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") def test_session_start_output_failure_clears_the_deduplication_receipt( From 669113df12fb7af0d754b244dbd9939de0d0331e Mon Sep 17 00:00:00 2001 From: chenxu wang <150030641+wangchenxuya@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:04:53 -0400 Subject: [PATCH 7/7] fix(dify): hide bootstrap delivery receipts --- integrations/dify/ACCEPTANCE.md | 2 +- integrations/dify/plugin/README.md | 2 +- .../dify/plugin/powercontext_dify/contract.json | 7 +++++++ integrations/dify/plugin/powercontext_dify/policy.py | 12 +++++++++++- integrations/dify/scope-mapping.md | 2 +- integrations/dify/tests/test_plugin_contract.py | 2 ++ integrations/dify/tool-coverage.md | 2 +- 7 files changed, 24 insertions(+), 5 deletions(-) diff --git a/integrations/dify/ACCEPTANCE.md b/integrations/dify/ACCEPTANCE.md index 9233002ca9..a225a10b07 100644 --- a/integrations/dify/ACCEPTANCE.md +++ b/integrations/dify/ACCEPTANCE.md @@ -22,7 +22,7 @@ The results and package checksum below describe the 19-tool baseline validated o ## Passed local acceptance -- 66 focused regressions cover registered SDK entries, official daemon serialization and host models/helpers: exact catalog, hidden credential/Scope fields, JSON-text inputs, input validation, UTF-8 limits, missing binding, credential read validation, malformed/oversized receipts, partial error receipts, transport failure, secret-bearing capture, sanitization and no automatic write retry. All six server-controlled fields are rejected at the SDK boundary. The packaged requirements check accepts the locked dependency export and rejects a stale SDK constraint without changing the lock. Named outputs are checked against their declared Draft 7 JSON schemas, and `result` must equal the complete successful response or `{}` on error/unknown. Ready and empty context responses retain their original text/null values; no-op candidate receipts retain `candidate=null`. Rendering metadata does not weaken nullable object or integer revision validation. +- 67 focused regressions cover registered SDK entries, official daemon serialization and host models/helpers: exact catalog, hidden credential/Scope fields, JSON-text inputs, input validation, UTF-8 limits, missing binding, credential read validation, malformed/oversized receipts, partial error receipts, transport failure, secret-bearing capture, sanitization and no automatic write retry. All seven server-controlled fields are rejected at the SDK boundary. The packaged requirements check accepts the locked dependency export and rejects a stale SDK constraint without changing the lock. Named outputs are checked against their declared Draft 7 JSON schemas, and `result` must equal the complete successful response or `{}` on error/unknown. Ready and empty context responses retain their original text/null values; no-op candidate receipts retain `candidate=null`. Rendering metadata does not weaken nullable object or integer revision validation. - The daemon probe imports the pinned official `plugin_entities.ToolDeclaration`/`ToolParameter` types and round-trips SDK registrations with Go `encoding/json`. Dify's real Pydantic parameter models and official model-schema builder then produce valid Draft 7 schemas for all 19 tools. All 26 decoded structured/nullable schemas remain in model-visible descriptions; defaults match the string transport. The compose fixture confirms that the tested daemon corresponds to the host's default version. This is executable source-boundary evidence, not installation or live daemon dispatch. - The HTTP/SQLite tests invoke all 19 loaded tools. Memory write/list/search/prepare/get/revise/retire remains usable through exact citations; Source capture retains structured metadata and a Unicode Source ID. - Complete Handoff JSON passes prepare/finalize/temporary continue/commit/exact/latest readback. Full objects and references survive the SDK messages. diff --git a/integrations/dify/plugin/README.md b/integrations/dify/plugin/README.md index 483941ae48..b465a88eeb 100644 --- a/integrations/dify/plugin/README.md +++ b/integrations/dify/plugin/README.md @@ -26,7 +26,7 @@ Configure exactly one of `scope_id` and `binding_external_id`. Every call resolv All users and conversations using one credential share its Scope. This is a fixed application/team boundary, not automatic personal or conversation isolation. Use distinct credential configurations and Server policies, or distinct deployments, when different access boundaries are needed. Paths, usernames and hashes are not Scope IDs. Provision bindings before use; the plugin never creates a Scope or falls back to Default. -The model cannot supply the top-level Scope, URL, token, binding, context assembly, or byte budget. Nested exact references and Handoff objects keep their original identities, and the Server validates their access relationships. +The model cannot supply the top-level Scope, URL, token, binding, context assembly, byte budget, or a host-owned bootstrap receipt. Nested exact references and Handoff objects keep their original identities, and the Server validates their access relationships. ## Tools diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 093ea09a80..823e2218c2 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -2879,6 +2879,13 @@ }, "assembly": { "$ref": "#/components/schemas/ContextAssembly" + }, + "bootstrap_receipt_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[\\x21-\\x7E]+$", + "description": "Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; Artifacts whose bootstrap bodies were truncated remain eligible." } } }, diff --git a/integrations/dify/plugin/powercontext_dify/policy.py b/integrations/dify/plugin/powercontext_dify/policy.py index 0e19eb2c94..1cfec9087e 100644 --- a/integrations/dify/plugin/powercontext_dify/policy.py +++ b/integrations/dify/plugin/powercontext_dify/policy.py @@ -14,5 +14,15 @@ """Model-facing parameter policy shared by declarations and SDK validation.""" -HIDDEN_PARAMETERS = frozenset({"scope_id", "tag_filter", "expected_revision", "max_bytes", "include_code", "assembly"}) +HIDDEN_PARAMETERS = frozenset( + { + "scope_id", + "tag_filter", + "expected_revision", + "max_bytes", + "include_code", + "assembly", + "bootstrap_receipt_id", + } +) MEMORY_KINDS = ("decision", "constraint", "current-state", "task-outcome", "next-step", "agent-note") diff --git a/integrations/dify/scope-mapping.md b/integrations/dify/scope-mapping.md index dde609c237..8a76b7711c 100644 --- a/integrations/dify/scope-mapping.md +++ b/integrations/dify/scope-mapping.md @@ -12,7 +12,7 @@ An administrator provisions that key through PowerContext's supported Scope bind Credential validation uses the same resolution followed by protected `get_scope`. It does not write data or use an unauthenticated health endpoint as evidence of access. Save-time read validation cannot promise write permission; handle actual Server 403 responses. -The resolved Scope is injected into every selected operation. Top-level `scope_id`, URL, token, binding, expected Memory revision, context assembly and byte budget are not model parameters. Nested citations and prepared Handoff objects keep their original identities. The Server owns reference and cross-Scope access checks; a matching top-level Scope is not an authorization proof. +The resolved Scope is injected into every selected operation. Top-level `scope_id`, URL, token, binding, expected Memory revision, context assembly, byte budget and host-owned bootstrap receipt are not model parameters. Nested citations and prepared Handoff objects keep their original identities. The Server owns reference and cross-Scope access checks; a matching top-level Scope is not an authorization proof. Dify user/session identifiers are not used for automatic routing. One credential shares its Scope among its authorized applications/users. A shared administrator token represents one trust domain, even when Scope A/B searches are separate. Production resource isolation requires appropriately restricted Server credentials/policies or separate instances. Dynamic trusted identity routing needs a separate design and host/Server acceptance. diff --git a/integrations/dify/tests/test_plugin_contract.py b/integrations/dify/tests/test_plugin_contract.py index 1720621d14..c716d4c325 100644 --- a/integrations/dify/tests/test_plugin_contract.py +++ b/integrations/dify/tests/test_plugin_contract.py @@ -190,6 +190,7 @@ def test_sdk_registers_exact_catalog_and_json_text_inputs(registry): "max_bytes", "include_code", "assembly", + "bootstrap_receipt_id", "api_token", "server_url", } & {parameter.name for parameter in declaration.parameters} @@ -207,6 +208,7 @@ def test_sdk_registers_exact_catalog_and_json_text_inputs(registry): ("pc_prepare_context", {"query": "scope"}, "max_bytes", 1), ("pc_prepare_context", {"query": "scope"}, "include_code", True), ("pc_prepare_context", {"query": "scope"}, "assembly", {}), + ("pc_prepare_context", {"query": "scope"}, "bootstrap_receipt_id", "receipt-1"), ("pc_search", {"query": "scope"}, "tag_filter", {"tags": ["private"]}), ("pc_remember", {"kind": "decision", "text": "Retain."}, "expected_revision", 1), ], diff --git a/integrations/dify/tool-coverage.md b/integrations/dify/tool-coverage.md index 5266ede431..64b71fe3eb 100644 --- a/integrations/dify/tool-coverage.md +++ b/integrations/dify/tool-coverage.md @@ -29,7 +29,7 @@ The provider declares exactly the following 20 tools. `generate_contract.py --ch Memory tools retain their logical operation IDs. The adapter routes `pc_memory_list` to `list_atomic_memories`, Atomic reads to `get_artifact` or `get_artifact_revision`, revisions to `replace_artifact`, and forgetting to `change_atomic_memory_lifecycle`. -The administrator supplies Scope/binding, Server URL/token, context assembly and byte budget. The model supplies only operation-specific fields. Search limit is 8 rather than the HTTP default of 10 and maximum of 50. Memory kind is restricted to the six documented kinds; normalized Memory text is bounded by 8192 UTF-8 bytes. Search query keeps the HTTP character limit. Code indexing and tag filters are outside this provider surface. +The administrator supplies Scope/binding, Server URL/token, context assembly and byte budget. Host integrations, rather than the model, own bootstrap delivery receipts. The model supplies only operation-specific fields. Search limit is 8 rather than the HTTP default of 10 and maximum of 50. Memory kind is restricted to the six documented kinds; normalized Memory text is bounded by 8192 UTF-8 bytes. Search query keeps the HTTP character limit. Code indexing and tag filters are outside this provider surface. Objects, arrays and nullable inputs use strings containing one JSON value. The model-facing parameter description retains the decoded schema through the default daemon's discovery path; the plugin decodes once and validates the public HTTP contract. Nullable strings need JSON quotes, explicit null is the text `null`, and optional unused parameters may be omitted. Unknown fields, malformed/non-finite JSON, empty reference objects and malformed references fail before the operation request. Generation permits a combined 1–32 Source/Artifact references; empty array defaults are encoded as `[]`. Review's explicit family uses the text `"experience"`/`"skill"`; an omitted or JSON-null filter is unfiltered. See [Workflow serialization examples](plugin/README.md).