diff --git a/INSTALL.md b/INSTALL.md index c386e815..0648eeaf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -47,23 +47,78 @@ node scripts/install-skill.mjs --apply Existing directory installations are backed up before replacement. Symlinks and non-skill directories fail closed unless the operator inspects the plan and explicitly supplies `--force`. Downgrades require `--allow-downgrade`. -## Optional Codex progress watchdog +## Codex progress watchdog activation -For Codex, GitHub Delivery can install lifecycle hooks that block duplicate unchanged reads, rate-limit manual status polling, bound oversized subagent briefs/tool output, and recover from completed no-progress narration stalls. +A standard Codex install/upgrade now plans the watchdog together with the skill. When Codex is detected and lifecycle hooks are supported, `--apply` also configures GitHub Delivery's `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd` entries in `~/.codex/hooks.json`. Existing hook configuration is preserved, backed up before a change, and updated idempotently. -The hook installer is separately opt-in and dry-runs by default. It preserves existing hooks and reports the planned change: +Codex deliberately does **not** run a new or changed non-managed command hook until you review and trust its exact definition. A fresh install therefore reports: + +```text +mode: none +degradationReason: hook_trust_required +hooksConfigured: true +``` + +Open `/hooks` in Codex, review the GitHub Delivery hook definitions, and trust them. After that exact unchanged definition is trusted, refresh the activation receipt with the normal installer: ```bash -node scripts/install-codex-watchdog-hooks.mjs +node scripts/install-skill.mjs --hook-trust-verified --apply +``` + +A same-version run with that explicit activation refresh does not reinstall or back up the skill again. It verifies that the expected hook definition is unchanged and then records `hooks` as the active mode. If the hook definition has changed, the trust assertion is rejected for activation purposes and `hook_trust_required` remains. + +The installer never adds Codex's `--dangerously-bypass-hook-trust` flag by default. + +The effective installation state is recorded in: + +```text +~/.codex/github-delivery/watchdog-activation.json ``` -Apply it explicitly after installing/upgrading the skill: +The receipt contains only activation metadata. It does not contain prompts, conversations, tool inputs, bearer tokens, or other secrets. + +The modes are intentionally strict: + +- `stream`: a host has explicitly bound launches to GitHub Delivery's protected streaming boundary, or the current process was started by the protected launcher; +- `hooks`: the expected lifecycle hooks are configured and their unchanged definition has been explicitly confirmed trusted; in-progress assistant text still cannot be interrupted before `Stop`; +- `none`: no runtime enforcement surface is verified. `hook_trust_required` distinguishes configured-but-untrusted hooks from a genuinely unavailable watchdog. + +### Protected streaming launcher + +The protected launcher is installed with the skill at: + +```text +~/.agents/skills/github-delivery/scripts/codex-with-watchdog.mjs +``` + +Run Codex through it when you need the exact repeated-narration failure stopped while the assistant message is still being generated: ```bash -node scripts/install-codex-watchdog-hooks.mjs --apply +node ~/.agents/skills/github-delivery/scripts/codex-with-watchdog.mjs ``` -The installer targets `~/.codex/hooks.json`, creates a backup before changing an existing file, fails closed on malformed or symlinked hook configuration, and adds only GitHub Delivery's missing `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd` entries. Reapplying is idempotent. +Arguments after the script are passed to remote-compatible Codex CLI modes, for example: + +```bash +node ~/.agents/skills/github-delivery/scripts/codex-with-watchdog.mjs resume +``` + +The launcher starts the real Codex App Server on its normal stdio transport, exposes an authenticated loopback bridge to the Codex `--remote` client, observes assistant-message deltas, and can issue a private `turn/interrupt` before a repeated no-progress message grows unbounded. It owns `--remote` and `--remote-auth-token-env`; caller-supplied replacements are rejected so the protected boundary cannot be bypassed accidentally. + +Inside the launched App Server/client process tree, the launcher sets the runtime watchdog declaration to `stream`. That makes capability discovery describe the current protected session directly rather than relying on a stale machine-wide claim. + +Installing the launcher does **not** make an ordinary `codex` or IDE process use it automatically. Codex exposes remote App Server selection as a launch option; GitHub Delivery does not replace your global `codex` executable or silently rewrite editor startup configuration. A persistent host integration may use `--stream-launch-controlled` only when it genuinely controls future launches through this boundary. + +Codex currently documents `app-server` and its WebSocket transport as experimental and unsupported for production workloads. This launcher is therefore the strongest currently available Codex boundary for this failure mode, not a stable production host API. Use trusted lifecycle hooks plus the policy fallback when that experimental surface is inappropriate. + +### Manual hook repair + +`scripts/install-codex-watchdog-hooks.mjs` remains available as a repair or non-standard-install tool. It is dry-run by default: + +```bash +node scripts/install-codex-watchdog-hooks.mjs +node scripts/install-codex-watchdog-hooks.mjs --apply +``` If the skill was installed somewhere other than `~/.agents/skills/github-delivery`, pass that path explicitly: @@ -71,7 +126,9 @@ If the skill was installed somewhere other than `~/.agents/skills/github-deliver node scripts/install-codex-watchdog-hooks.mjs --skill-dir ~/.codex/skills/github-delivery --apply ``` -Lifecycle hooks cannot stop tokens already emitted inside one assistant message. Custom Codex App Server clients can use the stronger streaming proxy described in [`references/agent-progress-watchdog.md`](references/agent-progress-watchdog.md). +Hook repair still requires Codex's normal `/hooks` review/trust flow for a new or changed non-managed hook. + +See [`references/agent-progress-watchdog.md`](references/agent-progress-watchdog.md) for the enforcement boundaries and incident behaviour. ## Restore a backup diff --git a/README.md b/README.md index f3811ccf..b163853b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Say the outcome, not the orchestration.** -`github-delivery` turns natural-language requests into evidence-backed GitHub workflows: PRDs, issue research, implementation, deep review, CI, fixes, stacks, and verified merges. On supported hosts, an optional runtime progress watchdog also cuts repeated narration, duplicate unchanged reads, manual polling, oversized tool output, and bloated subagent context. +`github-delivery` turns natural-language requests into evidence-backed GitHub workflows: PRDs, issue research, implementation, deep review, CI, fixes, stacks, and verified merges. Its layered progress watchdog also cuts repeated narration, duplicate unchanged reads, manual polling, oversized tool output, and bloated subagent context without weakening GitHub authority gates. [Quick start](#try-it-in-60-seconds) · [Progress watchdog](#agent-progress-watchdog) · [What it can own](#what-you-can-ask-it-to-own) · [Safety model](#safety-model) · [Installation](#installation) @@ -77,23 +77,28 @@ full review PR #42 For full install, upgrade, restore, downgrade, force, and manual-install behavior, see [`INSTALL.md`](INSTALL.md). -### Optional: add the Codex progress watchdog +### Codex progress watchdog -The skill works without host hooks. For Codex, you can additionally install lifecycle enforcement that blocks redundant reads/polls, bounds oversized context, and recovers from completed no-progress turns: +On a detected Codex install, the normal `--apply` path now configures GitHub Delivery's lifecycle-hook entries automatically. Codex requires new or changed non-managed hooks to be reviewed and trusted before they run, so open `/hooks`, review the exact GitHub Delivery definitions, and trust them. Then record that unchanged trusted definition without reinstalling the skill: ```bash -node scripts/install-codex-watchdog-hooks.mjs -node scripts/install-codex-watchdog-hooks.mjs --apply +node scripts/install-skill.mjs --hook-trust-verified --apply ``` -The first command is a dry run. The installer preserves existing hooks, backs up before writes, and adds only missing GitHub Delivery hook entries. For the stronger streaming boundary that can interrupt repeated narration **while the assistant message is still being generated**, see [Agent progress watchdog](#agent-progress-watchdog). +Lifecycle hooks stop duplicate reads/polls and recover from completed no-progress turns, but they cannot reclaim text already emitted inside one assistant message. For the exact `Let me check the type...` failure while it is still being generated, launch Codex through the installed protected streaming boundary: + +```bash +node ~/.agents/skills/github-delivery/scripts/codex-with-watchdog.mjs +``` + +The protected launcher declares `stream` only inside the process tree it actually controls. Plain `codex` and IDE sessions are not silently rerouted or falsely reported as streaming-protected. Codex currently documents `app-server` and its WebSocket transport as experimental and unsupported for production workloads, so this is the strongest current boundary for the failure mode rather than a stable production host API. See [Agent progress watchdog](#agent-progress-watchdog) and [`INSTALL.md`](INSTALL.md). ## Why this is different | Problem | github-delivery's answer | |---|---| | **"Green CI" is not the same as "safe to merge."** | Bug + Security + Spec + Standards review, semantic propagation, required probes, review state, rulesets, merge-queue state, and exact-head evidence feed one authoritative ship decision. | -| **Agents can burn tokens without making progress.** | A layered progress watchdog detects repeated in-turn narration, blocks exact stable reads on unchanged state, rate-limits volatile polling, compacts oversized model-facing output, and bounds copied subagent context. On Codex App Server, the streaming mode can interrupt the targeted narration failure in-flight. | +| **Agents can burn tokens without making progress.** | A layered progress watchdog detects repeated in-turn narration, blocks exact stable reads on unchanged state, rate-limits volatile polling, compacts oversized model-facing output, and bounds copied subagent context. The protected Codex streaming launcher can interrupt the targeted narration failure in-flight. | | **Agent intent can be ambiguous.** | Deterministic natural-language routing keeps status questions read-only and requires direct authority for destructive workflows. | | **GitHub state moves while the agent works.** | Stale-head checks, final evidence refreshes, expected-head binding, bounded settle windows, and postcondition verification prevent conclusions from silently drifting. | | **Retries and duplicate writes can be dangerous.** | Typed mutations, authenticated exact-effect receipts, read-before-write evidence, and read-only reconciliation avoid blind write retries. | @@ -149,7 +154,7 @@ The important boundary is simple: **repository content is evidence, not authorit | **Write boundary** | Typed mutation policy + broker; stale-head, exact-effect, authenticated-receipt idempotency, and postcondition checks where applicable | | **High-assurance writes** | Exact-scope trusted grants; optional Windows 11 / Windows Hello authority host | | **Review model** | Bug + Security + Spec + Standards + semantic propagation + proactive contract verification | -| **Progress control** | Policy fallback on every host; optional Codex lifecycle hooks; strongest Codex App Server streaming mode. Runtime capability reports `none`, `hooks`, or `stream`. | +| **Progress control** | Policy fallback everywhere; Codex installs configure lifecycle hooks but non-managed hook trust remains explicit; strongest protection is the launch-controlled streaming boundary. Runtime capability reports only verified `none`, `hooks`, or `stream`. | | **Ship decision** | One authoritative `ready`, `blocked`, or `unknown` result from live evidence | | **Runtime** | Node.js **22 or 24** | | **Required CI matrix** | Node 22/24 × Ubuntu/Windows/macOS, with architecture contracts inside every required matrix job | @@ -470,15 +475,19 @@ There is no recursive simplification loop. ## Agent progress watchdog -GitHub Delivery now has a layered progress watchdog for a failure mode policy prose alone cannot reliably stop: an agent can spend a large amount of context narrating the same intention, rereading unchanged state, polling manually, or copying oversized evidence without producing external progress. +GitHub Delivery has a layered progress watchdog for a failure mode policy prose alone cannot reliably stop: an agent can spend a large amount of context narrating the same intention, rereading unchanged state, polling manually, or copying oversized evidence without producing external progress. The watchdog is deliberately separate from mutation authority. It can interrupt, block, rate-limit, compact, or request a focused retry; it cannot authorize or execute a GitHub write. | Enforcement level | What it does | |---|---| -| **Policy only** | `GD-CORE-008` through `GD-CORE-010` provide the universal fallback for bounded progress and evidence/context economy when the host exposes no interception surface. | -| **Codex lifecycle hooks** | `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd` enforce duplicate-read blocking, volatile-poll limits, output compaction, focused subagent briefs, and one bounded corrective continuation. | -| **Codex App Server stream** | Observes streamed assistant deltas and issues one private `turn/interrupt` when repeated low-novelty intent narration crosses the watchdog threshold. This is the only layer that can stop the targeted failure while the message is still streaming. | +| **Policy only** | `GD-CORE-008` through `GD-CORE-010` provide the universal fallback for bounded progress and evidence/context economy when the host exposes no verified interception surface. | +| **Codex lifecycle hooks** | The normal install configures `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd`. After Codex's explicit non-managed hook trust review, they enforce duplicate-read blocking, volatile-poll limits, output compaction, focused subagent briefs, and one bounded corrective continuation. | +| **Protected Codex stream** | The installed `codex-with-watchdog.mjs` launch boundary observes streamed assistant deltas and issues one private `turn/interrupt` when repeated low-novelty intent narration crosses the watchdog threshold. This is the only layer that can stop the targeted failure while the message is still streaming. | + +A configured hook is not automatically an active hook: Codex ties trust to the exact hook definition and skips a new or changed non-managed hook until it is reviewed in `/hooks`. GitHub Delivery records `hook_trust_required` rather than falsely reporting `hooks` in that state. The protected launcher independently marks its own process tree `stream`, so a protected session does not depend on machine-wide activation guesswork. + +Codex currently marks App Server/WebSocket transport experimental. The protected launcher is intentionally a strongest-current-boundary option rather than a claim that this host surface is production-stable. ### Read and context economy @@ -489,7 +498,7 @@ The watchdog is deliberately separate from mutation authority. It can interrupt, - Codex hook mode uses a **6,000 serialized-character** default subagent-input budget and requires focused briefs that reference source files rather than copying large parent context. - Raw tool arguments are not persisted in watchdog state; session ids and read inputs are represented by SHA-256 fingerprints. -For operator details, host integration, and the streaming proxy, see [`references/agent-progress-watchdog.md`](references/agent-progress-watchdog.md). +For operator details, hook trust, host integration, and the protected streaming boundary, see [`references/agent-progress-watchdog.md`](references/agent-progress-watchdog.md). --- @@ -542,10 +551,13 @@ See [`docs/live-integration.md`](docs/live-integration.md) and [`docs/live-githu | `authority-host/windows/` | Optional Windows 11 / Windows Hello local trusted-authority issuer | | `scripts/lib/github-retry.mjs` | Bounded retry policy for proven GitHub reads only | | `scripts/lib/agent-progress-watchdog.mjs` | Host-agnostic narration-stall detection, read fingerprints, state generations, and output economy | +| `scripts/lib/watchdog-activation.mjs` | Truthful `none` / trusted `hooks` / controlled `stream` activation selection and non-sensitive receipt state | | `scripts/codex-watchdog-hook.mjs` | Codex lifecycle-hook entrypoint for tool-boundary enforcement and bounded Stop recovery | -| `scripts/codex-app-server-watchdog-proxy.mjs` | Streaming Codex App Server proxy with private in-flight `turn/interrupt` handling | -| `scripts/install-codex-watchdog-hooks.mjs` | Dry-run-first, backup-safe, idempotent Codex hook installer | -| `scripts/runtime-capabilities.mjs` | Report active progress-watchdog capability as `none`, `hooks`, or `stream` | +| `scripts/lib/codex-watchdog-remote-bridge.mjs` | Authenticated loopback bridge that applies the streaming watchdog between Codex remote client and stdio App Server | +| `scripts/codex-with-watchdog.mjs` | Protected Codex launcher and current-session `stream` capability declaration | +| `scripts/codex-app-server-watchdog-proxy.mjs` | Stdio streaming proxy for custom App Server clients with private in-flight `turn/interrupt` handling | +| `scripts/install-codex-watchdog-hooks.mjs` | Dry-run-first, backup-safe, idempotent Codex hook installer/repair path | +| `scripts/runtime-capabilities.mjs` | Report verified progress-watchdog capability as `none`, `hooks`, or `stream` | | `scripts/review-scope.mjs` | Evidence-ranked review scope and required probes | | `scripts/lib/probe-registry.mjs` | Deterministic diff-shape → named review-probe routing | | `scripts/lib/probe-evidence.mjs` | Validate required probe evidence and reject required-trigger `n-a` downgrades | @@ -582,7 +594,7 @@ Or verify reproducibility while building release artifacts: npm run dist:check ``` -The installer is dry-run first. Full install, upgrade, backup, restore, downgrade, force, and manual-install behavior is documented in [`INSTALL.md`](INSTALL.md). +The installer is dry-run first. Full install, upgrade, backup, restore, downgrade, force, watchdog trust refresh, and manual-install behavior is documented in [`INSTALL.md`](INSTALL.md). Typical skill locations include: @@ -593,18 +605,23 @@ Typical skill locations include: ~/.claude/skills/github-delivery ``` -### Optional Codex progress watchdog +### Codex progress watchdog + +On Codex, the normal installer configures the lifecycle-hook definitions along with the skill. Codex still requires explicit review/trust of new or changed non-managed hooks in `/hooks`; GitHub Delivery does not bypass that trust gate. -Codex lifecycle hooks are separately opt-in and dry-run by default. They preserve existing hooks and back up the hook configuration before an applied write: +After trusting the unchanged definitions, persist the verified hook mode with: ```bash -node scripts/install-codex-watchdog-hooks.mjs -node scripts/install-codex-watchdog-hooks.mjs --apply +node scripts/install-skill.mjs --hook-trust-verified --apply ``` -The installer targets `~/.codex/hooks.json` by default and adds only GitHub Delivery's missing `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd` entries. If the skill lives somewhere else, pass `--skill-dir` explicitly. +For mid-message repeated-narration interruption, use the protected launcher: + +```bash +node ~/.agents/skills/github-delivery/scripts/codex-with-watchdog.mjs +``` -Lifecycle hooks cannot stop tokens already emitted inside the current message. Custom Codex App Server clients can opt into the stronger streaming proxy documented in [`references/agent-progress-watchdog.md`](references/agent-progress-watchdog.md). +The launcher starts the real App Server over stdio, interposes an authenticated loopback remote bridge, and marks only its own launched process tree as `stream`. Ordinary `codex` and IDE sessions are not silently rerouted. The standalone `scripts/install-codex-watchdog-hooks.mjs` remains available for repair/non-standard installs. ### Optional Windows authority host @@ -646,7 +663,7 @@ Repository controls also include: - offline routing, regression and review-scope evaluations; - documentation/policy contracts; - mutation-boundary and architecture regression tests; -- progress-watchdog regressions for narration stalls, duplicate reads, polling, output compaction, subagent budgets, hooks, streaming interruption, and safe installation; +- progress-watchdog regressions for narration stalls, duplicate reads, polling, output compaction, subagent budgets, hook trust/configuration, protected streaming interruption, and safe installation; - OpenSSF Scorecard; - release checksum/SBOM/provenance verification. @@ -664,6 +681,6 @@ Do not publish suspected vulnerability details in a public issue or pull request ## Current state -The complete issue/PR delivery lifecycle and its safety architecture are implemented: evidence-backed routing and ship gates, deferred-intent-safe merge routing, brokered lifecycle mutations, trusted exact-scope authority and durable verdict provenance, Windows Hello protection for high-assurance thread actions, deep review, semantic propagation, deterministic probes with non-bypassable required evidence, pre-open review, safe simplification, repository-qualified stacks, conflict recovery, merge-queue semantics, aggregated strict-ruleset enforcement, authenticated exact-effect idempotency receipts, ambiguous-merge readback reconciliation, safe read retries, layered progress/context economy with optional Codex hook and streaming enforcement, issue close-out, deterministic release packaging, repository controls, and dedicated live lifecycle fixtures. +The complete issue/PR delivery lifecycle and its safety architecture are implemented: evidence-backed routing and ship gates, deferred-intent-safe merge routing, brokered lifecycle mutations, trusted exact-scope authority and durable verdict provenance, Windows Hello protection for high-assurance thread actions, deep review, semantic propagation, deterministic probes with non-bypassable required evidence, pre-open review, safe simplification, repository-qualified stacks, conflict recovery, merge-queue semantics, aggregated strict-ruleset enforcement, authenticated exact-effect idempotency receipts, ambiguous-merge readback reconciliation, safe read retries, layered progress/context economy with trust-aware Codex hook configuration and a protected streaming launch boundary, issue close-out, deterministic release packaging, repository controls, and dedicated live lifecycle fixtures. Remaining work is primarily **operational** rather than a missing architecture layer: keep live repository rules/security settings aligned with the documented policy, provision and maintain the dedicated live fixture target/credential, run release acceptance for new versions, keep host integrations explicitly configured where runtime watchdog enforcement is desired, and extend the regression corpus as GitHub and agent hosts evolve. diff --git a/docs/superpowers/plans/2026-08-11-watchdog-activation.md b/docs/superpowers/plans/2026-08-11-watchdog-activation.md new file mode 100644 index 00000000..2fcc4985 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-watchdog-activation.md @@ -0,0 +1,122 @@ +# Automatic Watchdog Activation Implementation Plan + +> **Implementation status:** Executed on PR #213. During implementation, official Codex documentation exposed an additional trust boundary: newly configured or changed non-managed hooks are skipped until their exact definition is reviewed/trusted. The approved design spec was updated accordingly. The completed implementation therefore uses `controlled stream > trusted hooks > none`, reports fresh hook configuration as `none / hook_trust_required`, and uses `scripts/codex-with-watchdog.mjs` for in-flight interruption. The detailed original task decomposition below is retained as planning history; the updated spec, code, tests, and exact-head CI are authoritative where discovery changed an interface or filename. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a standard supported Codex install/upgrade automatically activate the strongest watchdog mode it can prove, persist the effective mode, and prove the observed repeated `Let me check...` narration is interrupted in streaming mode before the configured output budget is exceeded. + +**Architecture:** Add a small deterministic activation module that selects `stream`, `hooks`, or `none` from explicit capabilities and owns a non-sensitive activation receipt. Integrate that module into `install-skill.mjs`, reusing the existing safe hook installer for hook mode and a stable App Server launcher for stream mode. Runtime capability reporting reads the persisted receipt, while tests cover installation, idempotency, truthful degradation, and the real incident phrase family. + +**Tech Stack:** Node.js 22/24, ESM, `node:test`, filesystem primitives, existing Codex hook installer, existing App Server watchdog proxy, GitHub Actions matrix. + +## Global Constraints + +- Normal install/upgrade must no longer require a second manual watchdog-install command to obtain available protection. +- Mode selection must be strongest verified mode: `stream` > `hooks` > `none`. +- Never select `stream` merely because `codex` exists; require a controllable App Server launch boundary. +- Host configuration writes remain backup-first, fail closed on malformed/symlinked configuration, preserve unrelated hooks, and remain idempotent. +- Activation state must contain no secrets, prompts, raw tool inputs, or conversation content. +- Hook-only mode must explicitly disclose that in-turn narration cannot be interrupted until `Stop`. +- `none` must report `progress_watchdog_unavailable` without making installation itself fail solely for lack of a watchdog surface. +- Existing GitHub mutation authority, freshness, review, security, CI, and final-evidence rules remain unchanged. +- Existing Node 22/24 Ubuntu, macOS, and Windows CI must remain green. + +--- + +### Task 1: Add activation selection and receipt contracts + +**Files:** +- Create: `scripts/lib/watchdog-activation.mjs` +- Create: `tests/unit/watchdog-activation.test.mjs` + +**Interfaces:** +- Produces: `selectWatchdogMode({ host, streamLaunchControlled, lifecycleHooksSupported }) -> { mode, degradationReason }` +- Produces: `activationReceiptPath({ codexHome }) -> string` +- Produces: `writeActivationReceipt({ codexHome, mode, degradationReason, launcherPath, apply }) -> { path, changed, applied, receipt }` +- Produces: `readActivationReceipt({ codexHome }) -> object | null` + +- [x] **Step 1: Write failing selection tests** +- [x] **Step 2: Write failing receipt tests** +- [x] **Step 3: Run the targeted test and verify RED** +- [x] **Step 4: Implement the minimal activation module** +- [x] **Step 5: Run the targeted test and verify GREEN** +- [x] **Step 6: Commit** + +### Task 2: Integrate automatic hook activation into the normal installer + +**Files:** +- Modify: `scripts/install-skill.mjs` +- Modify: `scripts/install-codex-watchdog-hooks.mjs` +- Modify/add focused installer tests + +- [x] **Step 1: Add a failing normal-install regression** +- [x] **Step 2: Add upgrade/idempotency assertions** +- [x] **Step 3: Verify RED before production support** +- [x] **Step 4: Refactor `install-skill.mjs` around reusable orchestration** +- [x] **Step 5: Reuse the existing hook installer** +- [x] **Step 6: Return truthful degradation** +- [x] **Step 7: Verify installer tests GREEN** +- [x] **Step 8: Commit** + +Implementation discovery added one stronger invariant: hook configuration is not active hook enforcement until Codex trust is confirmed. The installer therefore reports `hook_trust_required` for a fresh definition and supports a same-version `--hook-trust-verified --apply` activation refresh after `/hooks` review. + +### Task 3: Add a stable streaming launcher and select stream only when controllable + +**Implemented files:** +- `scripts/codex-with-watchdog.mjs` +- `scripts/lib/codex-watchdog-remote-bridge.mjs` +- `scripts/install-skill.mjs` +- `tests/unit/codex-protected-launcher.test.mjs` +- `tests/unit/codex-watchdog-remote-bridge.test.mjs` + +- [x] Protected launcher installed with the skill +- [x] `stream` selected only for a controlled launch boundary +- [x] Real App Server remains stdio +- [x] Authenticated loopback remote bridge interposes streamed deltas +- [x] Caller cannot replace the launcher's remote endpoint flags +- [x] Protected process tree declares `stream` +- [x] Bridge validates upgrade/auth boundaries and bounds malformed/oversized traffic +- [x] Commit + +### Task 4: Make runtime capability reporting read installed activation truth + +**Files:** +- `scripts/lib/runtime-capabilities.mjs` +- `scripts/runtime-capabilities.mjs` +- `tests/unit/runtime-capabilities-activation.test.mjs` + +- [x] Persisted activation is read when no current-runtime declaration exists +- [x] Protected launcher current-session `stream` declaration overrides stale machine state +- [x] Invalid/missing receipt cannot upgrade capability +- [x] Hook degradation is exposed explicitly +- [x] Commit + +### Task 5: Add the exact in-turn incident end-to-end regression + +**Files:** +- `tests/unit/codex-watchdog-remote-bridge.test.mjs` + +- [x] Fixture includes `Let me check the type.`, `Let me check the NOUS_DEF type.`, `Let me check the live test type.`, and `Let me check the OAuthProviderDef type.` +- [x] Deltas flow through the protected streaming boundary +- [x] One private `turn/interrupt` is emitted before 500 characters +- [x] Duplicate interrupt for the same turn is prevented +- [x] Unauthenticated bridge client is rejected + +### Task 6: Update operator docs and run repository-wide verification + +**Files:** +- `README.md` +- `INSTALL.md` +- `references/agent-progress-watchdog.md` +- `references/runtime-capabilities.md` +- PR #213 body + +- [x] Normal install/hook trust flow documented +- [x] `none` / trusted `hooks` / controlled `stream` semantics documented +- [x] Protected stream launcher documented +- [x] Codex App Server/WebSocket experimental maturity disclosed +- [x] Safety invariants and non-sensitive activation receipt documented +- [x] RED evidence retained +- [ ] Exact final-head CI, CodeQL, Architecture Contracts, and Dependency Review green +- [ ] PR #213 marked ready for review after exact-head verification diff --git a/docs/superpowers/specs/2026-08-11-watchdog-activation-design.md b/docs/superpowers/specs/2026-08-11-watchdog-activation-design.md new file mode 100644 index 00000000..97ef4de8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-watchdog-activation-design.md @@ -0,0 +1,181 @@ +# Automatic Progress Watchdog Activation Design + +## Problem + +`github-delivery` v0.2.0 contains a runtime progress watchdog, Codex lifecycle-hook integration, and a Codex App Server streaming proxy, but the normal skill installer does not activate either runtime boundary. Users can therefore install or update the skill successfully and still observe the exact failure the watchdog was built to stop: hundreds of repeated assistant-intent lines such as `Let me check the type` before any tool call occurs. + +The strongest detector already exists. The missing work is activation, capability selection, and end-to-end proof that a supported Codex launch actually uses the strongest boundary available. + +## Goal + +Make progress protection effective on supported Codex installations without weakening mutation, freshness, review, evidence, or Codex hook-trust gates and without silently rewriting unsupported host configuration. + +Success means a normal Codex install/upgrade configures the available watchdog surfaces, records only the protection that is actually active, and an end-to-end incident regression proves that repeated low-novelty narration is interrupted before the configured output budget is exceeded when the protected streaming launcher is used. + +## Non-goals + +- Do not change the mutation-authority model. +- Do not make omitted evidence count as success. +- Do not require App Server streaming on hosts that cannot expose it. +- Do not replace Codex itself or assume an undocumented host interception API. +- Do not silently alter unrelated user hooks or editor configuration. +- Do not bypass Codex's persisted hook-trust review by default. +- Do not represent Codex App Server/WebSocket integration as production-stable while OpenAI documents that surface as experimental. + +## Approaches considered + +### A. Documentation-only activation + +Keep hook/proxy installation separate and improve README/INSTALL instructions. + +Rejected because it preserves the current failure mode: the protection can exist but remain unconfigured after a successful install. + +### B. Always install lifecycle hooks + +Make `install-skill.mjs` configure `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd` hooks automatically for Codex targets. + +Better than v0.2.0, but still cannot stop tokens already emitted inside a single assistant message. In addition, Codex requires non-managed command hooks to be reviewed and trusted by exact hook hash before they run. Configuration therefore must not be confused with active enforcement. + +### C. Capability-driven activation with strongest-safe mode + +Recommended. During install/upgrade, detect the target host and available watchdog integration surface, select `stream` only when a protected App Server launch boundary is actually controlled, otherwise select `hooks` only when lifecycle hooks are configured and their exact current definition is confirmed trusted, otherwise record `none` with a concrete degradation reason. + +A fresh Codex install may therefore configure hooks automatically while reporting `none / hook_trust_required` until the user reviews them in `/hooks`. This preserves Codex's trust model instead of silently weakening it. + +The streaming path uses Codex's documented remote TUI/App Server connection to place GitHub Delivery between assistant deltas and the client. Because OpenAI currently documents `codex app-server` and the WebSocket transport as experimental and unsupported for production workloads, this is explicitly a strongest-current enforcement option rather than a promise of a stable host API. + +## Architecture + +### 1. Activation planner + +Add a deterministic activation planner with one input contract: + +- installation target path; +- host hint/detection result; +- available integration capabilities; +- existing Codex hook configuration; +- whether the exact current hook definition is confirmed trusted; +- whether the caller requested apply vs dry-run. + +It returns one of: + +- `stream` — the caller/host can launch Codex through the protected streaming boundary; +- `hooks` — lifecycle hooks are configured and their exact current definition is confirmed trusted; +- `none` — no verified runtime boundary is active. + +The planner must never infer `stream` merely because `codex` exists. Streaming is selected only when the actual launch boundary is under installer/host control. + +The planner must never infer `hooks` merely because `hooks.json` contains the command. Codex skips non-managed hooks until their exact definition is trusted. A hook configuration change invalidates any prior trust assertion for activation purposes. + +### 2. Installer integration + +`install-skill.mjs` remains dry-run by default and continues to own skill files/backups. For Codex targets it also produces a watchdog activation plan. + +On `--apply`: + +- preflight the hook configuration before replacing the skill; +- install/upgrade the skill; +- configure the GitHub Delivery lifecycle-hook entries while preserving unrelated hooks; +- back up modified hook configuration before writing; +- record only a verified active mode; +- emit one structured result containing the installed version, watchdog mode, configured/trusted hook state, launcher path, and any degradation reason. + +The existing standalone hook installer remains available for recovery/manual use but is no longer the normal required installation path. + +Because Codex records trust against the exact hook definition hash, a newly added or changed hook is reported as `hook_trust_required` until it is reviewed in `/hooks`. The installer does not use `--dangerously-bypass-hook-trust` as a default activation mechanism. + +A same-version `--hook-trust-verified --apply` activation refresh updates only the activation receipt when the hook definition is unchanged; it does not reinstall/back up the skill again. + +### 3. Streaming launcher integration + +GitHub Delivery installs a protected launcher owned by the skill. It starts the real Codex App Server on the default stdio transport, exposes an authenticated loopback WebSocket bridge, and starts the ordinary Codex client with its documented `--remote` flags pointed at that bridge. + +The bridge observes `item/agentMessage/delta` notifications and may issue one private `turn/interrupt` while the repeated narration is still being generated. + +The bridge is loopback-only, bearer-authenticated in normal launcher operation, limited to one client, validates the WebSocket v13 upgrade, caps frame size, and destroys malformed/oversized connections instead of letting them crash the launcher. + +Installing the launcher alone does not make plain `codex` or an IDE session use it. `stream` is recorded only when the caller/host explicitly controls launches through this protected entry point. The launcher itself declares `stream` inside the process tree it creates, so current-session capability discovery is truthful without persisting a machine-wide stream claim. + +### 4. Runtime capability truth + +`runtime-capabilities.mjs` reports the mode recorded by installation rather than relying only on an operator-set environment variable. Environment declarations may override or assist probing for controlled host integrations and fixtures, but a normal install leaves machine-readable activation state under the active Codex home. + +Activation state contains no secrets, prompts, raw tool inputs, or conversation content. It may record: + +- mode; +- degradation reason; +- protected launcher path; +- whether hooks are configured; +- whether trust for the exact current hook definition was verified. + +### 5. Visible degradation + +If hooks are configured but not confirmed trusted, the mode is `none` and the result reports `hook_trust_required` plus `hooksConfigured: true`. + +If trusted hooks are active but streaming is not, the mode is `hooks` with `streaming_interruption_unavailable`. + +If no runtime surface is available, installation does not fail solely for that reason but reports `progress_watchdog_unavailable`. + +## Data flow + +1. User runs the normal skill installer/upgrade. +2. Installer preflights skill replacement and watchdog configuration together. +3. The install configures available Codex lifecycle hooks without bypassing Codex trust review. +4. Host/capability evidence selects `stream`, trusted `hooks`, or `none`. +5. Dry-run reports exactly what would change. +6. `--apply` writes the skill, safe hook configuration, and a truthful activation receipt. +7. Runtime capability inspection reads the persisted receipt and confirms the effective mode. +8. The strongest in-flight protection is obtained by actually launching Codex through the protected streaming entry point. + +## Safety and failure handling + +- Host configuration writes remain backup-first and idempotent. +- Malformed or symlinked host configuration fails closed before skill replacement when it can be preflighted. +- A partial activation failure must not claim the requested watchdog mode. +- Hook installation preserves all unrelated hook entries. +- Non-managed hooks are never reported active solely because they are configured. +- A changed hook definition invalidates a supplied trust assertion for that install pass. +- The default workflow never adds `--dangerously-bypass-hook-trust`. +- The protected launcher uses an in-memory bearer token and loopback-only bridge; the token is not persisted. +- The bridge fails closed on invalid upgrades, malformed frames, and oversized frames. +- Streaming launch integration never swallows ordinary App Server traffic, mutation prompts, or errors. +- The watchdog remains incapable of granting GitHub write authority. +- Existing `GD-CORE-*`, `GD-AUTH-*`, CI, review, security, and final-evidence rules remain authoritative. +- Codex App Server/WebSocket maturity is disclosed rather than hidden. + +## Testing strategy + +### RED regression first + +Add an end-to-end installation regression that installs to an isolated temporary Codex home and asserts that the normal installer configures lifecycle hooks without invoking the standalone installer. A fresh configuration must **not** be reported as active until hook trust is confirmed. + +Add an incident regression using the real repeated phrase family from the observed trace (`Let me check the type`, `Let me check the NOUS_DEF type`, etc.). Under the protected streaming adapter, generated assistant deltas must trigger one `turn/interrupt` before the configured character budget is exceeded. + +### Additional contracts + +- normal Codex install configures lifecycle hooks automatically; +- newly configured non-managed hooks report `hook_trust_required`, not `hooks`; +- an explicit trust assertion is accepted only when the hook definition is unchanged; +- changing the hook definition invalidates the trust assertion; +- same-version trust refresh updates activation without reinstalling the skill; +- upgrade from v0.2.0 does not duplicate hook entries; +- existing unrelated hooks survive semantic-content unchanged; +- malformed/symlinked config fails closed; +- `stream` is selected only when the protected launch boundary is controlled; +- protected launcher prevents caller replacement of its `--remote` boundary; +- protected loopback bridge rejects unauthenticated clients; +- `none` is reported honestly on unsupported hosts; +- runtime capability output matches persisted effective mode or the explicit current protected-session declaration; +- repeated install is idempotent; +- existing repository `npm run check`, distribution reproducibility, security, and cross-platform CI remain green. + +## Acceptance criteria + +1. A standard Codex install/upgrade no longer requires a second watchdog-install command to configure available lifecycle protection. +2. Codex's non-managed hook trust review remains intact; untrusted hooks are never reported active. +3. The installer chooses the strongest mode it can prove: controlled `stream` > trusted `hooks` > `none`. +4. The exact repeated `Let me check...` incident is interrupted in streaming mode before the configured generation budget is exceeded. +5. Hook-only installations clearly disclose that in-turn token burn cannot be stopped mid-message. +6. Runtime capability reporting reflects the mode actually active, not merely configured code. +7. Existing user configuration and GitHub Delivery safety/authority gates are preserved. +8. The protected streaming boundary's current Codex maturity is explicitly documented as experimental rather than production-stable. diff --git a/references/agent-progress-watchdog.md b/references/agent-progress-watchdog.md index d6d2c285..a0d56c97 100644 --- a/references/agent-progress-watchdog.md +++ b/references/agent-progress-watchdog.md @@ -1,10 +1,10 @@ # Agent Progress Watchdog -GitHub Delivery uses a layered progress watchdog to reduce token waste without weakening evidence, freshness, review, or mutation-authority gates. +GitHub Delivery uses a layered progress watchdog to reduce token waste without weakening evidence, freshness, review, mutation-authority, or Codex hook-trust gates. ## What it protects against -- repeated in-turn intentions such as `Let me read ...` with no tool boundary; +- repeated in-turn intentions such as `Let me read ...` or `Let me check ...` with no tool boundary; - exact reads repeated on unchanged state; - ad-hoc high-frequency CI/status polling; - oversized model-facing tool output when only a focused diagnostic excerpt is required; @@ -12,76 +12,85 @@ GitHub Delivery uses a layered progress watchdog to reduce token waste without w The watchdog never grants GitHub mutation authority, executes a write on the agent's behalf, or treats omitted/unknown evidence as success. +## Activation truth + +A normal Codex install/upgrade through `scripts/install-skill.mjs --apply` configures lifecycle hooks when Codex is detected and records watchdog installation state in: + +```text +~/.codex/github-delivery/watchdog-activation.json +``` + +The receipt is non-sensitive activation metadata only. It distinguishes hook configuration from verified active enforcement. + +Mode selection is strongest verified mode only: + +1. `stream` when the current protected launcher declares the streaming boundary or a host explicitly controls future launches through it; +2. `hooks` only when the expected lifecycle hooks are configured and the exact unchanged definition has been explicitly confirmed trusted; +3. `none` when no runtime surface is verified. `hook_trust_required` distinguishes configured-but-untrusted hooks from an unavailable watchdog. + +Codex requires non-managed command hooks to be reviewed and trusted before they run. Trust is tied to the current hook definition, so adding or changing the hook makes it review-pending again. GitHub Delivery therefore never treats `hooks.json` presence as proof that lifecycle enforcement is active and never enables `--dangerously-bypass-hook-trust` by default. + ## Enforcement levels ### Policy only -`GD-CORE-008` through `GD-CORE-010` remain the universal fallback when the host exposes no runtime lifecycle or streaming interception. +`GD-CORE-008` through `GD-CORE-010` remain the universal fallback when the host exposes no verified runtime lifecycle or streaming interception. This reduces ordinary waste but cannot forcibly stop a pathological assistant message while that message is already being generated. ### Codex lifecycle hooks -Use `scripts/codex-watchdog-hook.mjs` for `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd`. +`scripts/codex-watchdog-hook.mjs` handles `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, and `SessionEnd`. This layer can: - block an exact stable duplicate read on unchanged state; - rate-limit identical volatile polls; -- reject an oversized `Agent`/subagent tool input and require a focused brief that references source files instead of copying them; +- reject an oversized `Agent`/subagent tool input and require a focused source-referenced brief; - compact oversized model-facing tool output while retaining failure/error/blocker signals; - detect a completed no-progress assistant or subagent message and request one corrective continuation; - fail closed if that corrective continuation stalls again; - delete per-session state at `SessionEnd`. -The default subagent-input budget is 6,000 serialized characters. It is intentionally a context budget, not an authority or correctness gate. +The default subagent-input budget is 6,000 serialized characters. It is a context budget, not an authority or correctness gate. Hook state is stored outside repository content. Session ids and read inputs are represented only by SHA-256 fingerprints; raw tool arguments are not persisted. Lifecycle hooks cannot reclaim tokens already emitted inside the assistant message that reaches `Stop` or `SubagentStop`. -Example hook command for a skill installed under the standard agents directory: - -```json -{ - "description": "GitHub Delivery progress watchdog", - "hooks": { - "PreToolUse": [{"hooks": [{"type": "command", "command": "node ~/.agents/skills/github-delivery/scripts/codex-watchdog-hook.mjs", "commandWindows": "node \"%USERPROFILE%\\.agents\\skills\\github-delivery\\scripts\\codex-watchdog-hook.mjs\""}]}], - "PostToolUse": [{"hooks": [{"type": "command", "command": "node ~/.agents/skills/github-delivery/scripts/codex-watchdog-hook.mjs", "commandWindows": "node \"%USERPROFILE%\\.agents\\skills\\github-delivery\\scripts\\codex-watchdog-hook.mjs\""}]}], - "Stop": [{"hooks": [{"type": "command", "command": "node ~/.agents/skills/github-delivery/scripts/codex-watchdog-hook.mjs", "commandWindows": "node \"%USERPROFILE%\\.agents\\skills\\github-delivery\\scripts\\codex-watchdog-hook.mjs\""}]}], - "SubagentStop": [{"hooks": [{"type": "command", "command": "node ~/.agents/skills/github-delivery/scripts/codex-watchdog-hook.mjs", "commandWindows": "node \"%USERPROFILE%\\.agents\\skills\\github-delivery\\scripts\\codex-watchdog-hook.mjs\""}]}], - "SessionEnd": [{"hooks": [{"type": "command", "command": "node ~/.agents/skills/github-delivery/scripts/codex-watchdog-hook.mjs", "commandWindows": "node \"%USERPROFILE%\\.agents\\skills\\github-delivery\\scripts\\codex-watchdog-hook.mjs\""}]}] - } -} -``` +The normal Codex installer path configures GitHub Delivery's hook entries automatically on `--apply`. Hook configuration is backup-first, preserves unrelated entries, rejects malformed or symlinked configuration, and is idempotent. `scripts/install-codex-watchdog-hooks.mjs` remains available for repair and non-standard installs. + +After a fresh or changed hook definition, use Codex `/hooks` to review and trust it. A host/operator can then refresh the same installer with `--hook-trust-verified --apply`; same-version activation refreshes do not reinstall the skill. The installer accepts that trust assertion only when its expected hook definition is unchanged. -Configure this in the host's trusted hook configuration. GitHub Delivery does not silently modify global host configuration. +### Protected Codex streaming launcher -Declare this capability to runtime inspection with: +For the strongest boundary currently exposed by Codex, launch through the installed entry point: ```text -SHIPPING_GITHUB_PROGRESS_WATCHDOG=hooks +node ~/.agents/skills/github-delivery/scripts/codex-with-watchdog.mjs ``` -### Codex App Server streaming proxy +On Windows use the equivalent path under `%USERPROFILE%\.agents\skills\github-delivery`. -For the strongest boundary, launch Codex App Server through: +The launcher: -```text -node ~/.agents/skills/github-delivery/scripts/codex-app-server-watchdog-proxy.mjs -``` +1. starts the real `codex app-server` on its normal stdio transport; +2. creates a loopback-only authenticated bridge; +3. starts the ordinary Codex client with the documented `--remote` and `--remote-auth-token-env` flags pointed at that bridge; +4. forwards JSON-RPC traffic while observing `item/agentMessage/delta` notifications; +5. issues one private `turn/interrupt` when repeated low-novelty intent narration crosses the watchdog threshold; +6. consumes the private interrupt response rather than leaking it to the client; +7. declares `SHIPPING_GITHUB_PROGRESS_WATCHDOG=stream` inside the launched process tree so runtime inspection sees the current protected session directly. -On Windows use the equivalent path under `%USERPROFILE%\.agents\skills\github-delivery`. +The bearer token is generated in memory for the launched client and is not persisted. The bridge binds only to loopback, validates the WebSocket v13 upgrade, requires the bearer token in normal launcher use, permits one client, and bounds individual frames. The protected launcher owns the remote endpoint flags and rejects caller-supplied replacements. -The proxy transparently forwards App Server JSONL traffic, observes streamed assistant-message deltas, and issues one private `turn/interrupt` request when repeated low-novelty intent narration crosses the watchdog threshold. Responses to the proxy's private interrupt requests are consumed rather than forwarded to the client. +This is the only GitHub Delivery layer that can stop the targeted failure while an assistant message is still streaming. The incident regression includes the observed phrase family `Let me check the type`, `Let me check the NOUS_DEF type`, and `Let me check the OAuthProviderDef type`, and requires the interrupt before 500 emitted characters. -This is the only GitHub Delivery layer that can stop the targeted failure while the assistant message is still streaming. A client must intentionally launch/use this proxy instead of plain `codex app-server`; the normal CLI or IDE is not automatically rerouted through it. +Installing the launcher does not silently reroute an already-running or ordinarily-launched Codex CLI/IDE process. A one-off protected session gets its `stream` declaration from the launcher itself. A persisted `stream` activation receipt is reserved for a host integration that explicitly asserts it controls future launches through this entry point. -Declare this mode with: +**Maturity:** Codex currently documents `app-server` and its WebSocket transport as experimental and unsupported for production workloads. GitHub Delivery therefore treats this launcher as the strongest available Codex enforcement boundary, not as a stable production host API. Lifecycle hooks and policy fallback remain available when that experimental streaming surface is inappropriate. -```text -SHIPPING_GITHUB_PROGRESS_WATCHDOG=stream -``` +The older `scripts/codex-app-server-watchdog-proxy.mjs` remains useful to custom stdio App Server clients. It provides the same delta watchdog for clients that already own the App Server protocol connection. ## Read economy @@ -101,4 +110,4 @@ Unknown tools are not denied by economy classification. This avoids suppressing Oversized tool output is reduced deterministically to a bounded head/tail plus unique failure-signalling lines. The result records original and omitted character counts. -Compaction is never positive evidence. If omitted content is required to diagnose ambiguity or failure, retrieve the focused missing evidence or the full raw output as the final escalation step. \ No newline at end of file +Compaction is never positive evidence. If omitted content is required to diagnose ambiguity or failure, retrieve the focused missing evidence or the full raw output as the final escalation step. diff --git a/references/runtime-capabilities.md b/references/runtime-capabilities.md index 68db83d0..990e9bc5 100644 --- a/references/runtime-capabilities.md +++ b/references/runtime-capabilities.md @@ -10,7 +10,7 @@ Run internally at workflow start: node scripts/runtime-capabilities.mjs --repo OWNER/REPO ``` -`--repo` is optional when the probe runs inside the target checkout: it detects the repository from `gh repo view` (current-directory remote) or falls back to `git config --get remote.origin.url`. Pass `--repo` explicitly when the checkout is missing or the target differs from the local remote. +`--repo` is optional when the probe runs inside the target checkout: it detects the repository from `gh repo view` or falls back to `git config --get remote.origin.url`. Pass `--repo` explicitly when the checkout is missing or the target differs from the local remote. The script probes facts available to the local process: @@ -21,8 +21,9 @@ The script probes facts available to the local process: - GitHub CLI authentication - repository readability through `gh` - repository write permission through `gh` +- persisted GitHub Delivery watchdog installation/activation state under the active Codex home -Capabilities that exist only in the host must be declared by the agent environment: +Capabilities that exist only in the host may also be declared by the agent environment: ```text SHIPPING_GITHUB_HOST=codex|cursor|claude|unknown @@ -40,9 +41,11 @@ SHIPPING_GITHUB_PROGRESS_WATCHDOG=none|hooks|stream `CONNECTOR_WRITE` means the host connector has write permission. `BROKERED_CONNECTOR_WRITE` means the github-delivery mutation broker has an adapter that enforces the same request, expected-head, idempotency, exact-text, audit, and verification contract through that connector. Permission without an adapter is not a usable mutation path. -`PROGRESS_WATCHDOG=hooks` declares lifecycle-hook enforcement. `stream` declares a streaming host boundary capable of interrupting an in-flight no-progress turn. See `references/agent-progress-watchdog.md` for the Codex integrations and their different guarantees. +For the progress watchdog, an explicit environment declaration is authoritative for the current controlled runtime. The protected Codex launcher sets `SHIPPING_GITHUB_PROGRESS_WATCHDOG=stream` inside the App Server/client process tree so current-session capability discovery does not depend on a machine-wide guess. -A Node process cannot discover a host connector or streaming interception boundary that was never exposed to it. These capabilities are declarations, not guesses. +When no explicit runtime declaration exists, discovery reads `~/.codex/github-delivery/watchdog-activation.json` (or the equivalent under `CODEX_HOME`). Invalid or missing activation state never upgrades capability. A fresh Codex hook configuration is persisted as `none` with `hook_trust_required` until the expected unchanged non-managed hook definition has been explicitly confirmed trusted; file presence alone is not interpreted as active hooks. + +`hooks` means lifecycle-hook enforcement was explicitly verified for the expected definition. `stream` means the current process or host has a verified launch boundary capable of interrupting an in-flight no-progress turn. `none` means policy-only protection for that capability snapshot. See `references/agent-progress-watchdog.md` for their different guarantees. ## Output contract @@ -67,7 +70,10 @@ A Node process cannot discover a host connector or streaming interception bounda "reviewThreadsReadable": true }, "runtime": { - "progressWatchdog": "stream" + "progressWatchdog": "stream", + "progressWatchdogAvailable": true, + "progressWatchdogDegradationReason": null, + "progressWatchdogLauncherPath": "/path/to/github-delivery/scripts/codex-with-watchdog.mjs" }, "fallbacks": { "githubReads": "connector", @@ -89,12 +95,14 @@ A Node process cannot discover a host connector or streaming interception bounda - Connected GitHub reads are preferred when declared; authenticated `gh` is the fallback. - A connected write path is usable only when a broker adapter is declared. Otherwise authenticated writable `gh` is used through `github-mutate.mjs`. - The write fallback is reported as `connector-broker`, `gh-broker`, or `unavailable`. -- When `gh` is authenticated but no repository could be detected, read/write fallbacks are reported as **`unprobed`** with `github_repo_not_detected` in `degraded` — that is a probe gap, not a permission denial. Do **not** treat `unprobed` as evidence that writes fail; re-run with `--repo OWNER/REPO` or rely on direct evidence (authenticated `gh`, successful broker execution) before claiming the write path is blocked. +- When `gh` is authenticated but no repository could be detected, read/write fallbacks are `unprobed` with `github_repo_not_detected` in `degraded`. Re-run with `--repo OWNER/REPO` or use direct evidence before claiming the write path is blocked. - Raw connector write permission without a broker adapter produces `github_write_not_brokered` and cannot make the workflow mutation-ready. - Composio rate-limit checks are preferred when declared; authenticated `gh` is the fallback. - Bugbot is used only on Cursor when both host and capability declarations permit it. Every other host uses complementary lenses. - Subagents are used only when declared. Otherwise run the work in-session without claiming fan-out occurred. - `runtime.progressWatchdog` is `stream`, `hooks`, or `none`; the corresponding `contextEconomy` fallback is `streaming-watchdog`, `lifecycle-hooks`, or `policy-only`. +- `progressWatchdogDegradationReason` can expose `hook_trust_required`, `streaming_interruption_unavailable`, or another concrete activation degradation instead of letting the workflow assume stronger enforcement. +- `progress_watchdog_unavailable` is included in `degraded` when no runtime watchdog is active. A more specific activation reason can still be present in `runtime.progressWatchdogDegradationReason`. - Missing ruleset or review-thread evidence is degraded capability and must flow into an unknown gate result rather than being guessed. ## Offline fixtures @@ -105,7 +113,7 @@ Tests and incident reproduction can supply probe data without invoking any exter node scripts/runtime-capabilities.mjs --input capability-fixture.json ``` -This mode is safe with an empty `PATH` and is used by CI. +This mode is safe with an empty `PATH` and is used by CI. Explicit fixture declarations can override persisted activation for controlled tests. ## Natural-language example @@ -116,4 +124,4 @@ For `merge PR #32`, the agent: 3. stops if no reliable GitHub read or brokered write path exists; 4. runs the authoritative gate; 5. executes broker mutations through `connector-broker` or `gh-broker`; -6. reports degraded fallbacks when the ideal tool was unavailable. +6. reports degraded fallbacks, including watchdog degradation, when the ideal runtime surface was unavailable. diff --git a/scripts/codex-with-watchdog.mjs b/scripts/codex-with-watchdog.mjs new file mode 100644 index 00000000..7e8540c1 --- /dev/null +++ b/scripts/codex-with-watchdog.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import { randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +import { startCodexWatchdogRemoteBridge } from "./lib/codex-watchdog-remote-bridge.mjs"; + +const TOKEN_ENV = "GITHUB_DELIVERY_CODEX_REMOTE_TOKEN"; + +function waitForSpawn(child) { + return new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); +} + +function waitForExit(child) { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); +} + +export function validateProtectedClientArgs(args) { + if (args.some((arg) => arg === "--remote" || arg.startsWith("--remote="))) { + throw new Error("protected Codex launcher owns --remote; remove the caller-supplied remote endpoint"); + } + if (args.some((arg) => arg === "--remote-auth-token-env" || arg.startsWith("--remote-auth-token-env="))) { + throw new Error("protected Codex launcher owns --remote-auth-token-env"); + } +} + +export function protectedClientArgs(args, url) { + validateProtectedClientArgs(args); + return ["--remote", url, "--remote-auth-token-env", TOKEN_ENV, ...args]; +} + +export function protectedRuntimeEnv(env = process.env) { + return { + ...env, + SHIPPING_GITHUB_HOST: "codex", + SHIPPING_GITHUB_PROGRESS_WATCHDOG: "stream", + SHIPPING_GITHUB_STREAM_LAUNCH_CONTROLLED: "true", + }; +} + +export async function runProtectedCodex({ + codexBin = process.env.CODEX_BIN || "codex", + args = process.argv.slice(2), + env = process.env, + spawnImpl = spawn, + stderr = process.stderr, +} = {}) { + validateProtectedClientArgs(args); + const token = randomBytes(32).toString("base64url"); + const runtimeEnv = protectedRuntimeEnv(env); + const appServer = spawnImpl(codexBin, ["app-server"], { + stdio: ["pipe", "pipe", "inherit"], + windowsHide: true, + env: runtimeEnv, + }); + await waitForSpawn(appServer); + + let bridge; + try { + bridge = await startCodexWatchdogRemoteBridge({ + appServerInput: appServer.stdin, + appServerOutput: appServer.stdout, + token, + }); + } catch (error) { + if (!appServer.killed) appServer.kill(); + throw error; + } + + const clientEnv = { ...runtimeEnv, [TOKEN_ENV]: token }; + let client; + try { + client = spawnImpl(codexBin, protectedClientArgs(args, bridge.url), { + stdio: "inherit", + windowsHide: true, + env: clientEnv, + }); + await waitForSpawn(client); + } catch (error) { + if (client && !client.killed) client.kill(); + if (!appServer.killed) appServer.kill(); + await bridge.close().catch(() => {}); + throw error; + } + + const cleanup = async () => { + if (client && !client.killed) client.kill(); + if (!appServer.killed) appServer.kill(); + await bridge.close().catch(() => {}); + }; + const signals = ["SIGINT", "SIGTERM"]; + const handlers = new Map(); + for (const signal of signals) { + const handler = () => void cleanup(); + handlers.set(signal, handler); + process.once(signal, handler); + } + + try { + return await waitForExit(client); + } finally { + if (!appServer.killed) appServer.kill(); + await bridge.close().catch(() => {}); + for (const [signal, handler] of handlers) process.off(signal, handler); + } +} + +export async function main() { + try { + const result = await runProtectedCodex(); + if (result.signal) { + process.stderr.write(`github-delivery protected Codex exited on ${result.signal}\n`); + process.exitCode = 1; + } else { + process.exitCode = Number.isInteger(result.code) ? result.code : 1; + } + } catch (error) { + process.stderr.write(`github-delivery protected Codex failed: ${error?.message || error}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/install-skill.mjs b/scripts/install-skill.mjs index dd631c1d..a8987821 100755 --- a/scripts/install-skill.mjs +++ b/scripts/install-skill.mjs @@ -1,11 +1,31 @@ #!/usr/bin/env node +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { installCodexWatchdogHooks } from "./install-codex-watchdog-hooks.mjs"; import { applyInstallation, planInstallation, restoreBackup } from "./lib/distribution.mjs"; +import { + selectWatchdogMode, + writeActivationReceipt, +} from "./lib/watchdog-activation.mjs"; + +function parseBoolean(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function defaultCodexHome() { + return resolve(process.env.CODEX_HOME || join(homedir(), ".codex")); +} + +function inferHost(codexHome) { + return process.env.SHIPPING_GITHUB_HOST || + (process.env.CODEX_HOME || existsSync(codexHome) ? "codex" : "unknown"); +} export function parseInstallArgs(argv) { + const codexHome = defaultCodexHome(); const options = { source: join(process.cwd(), "dist", "github-delivery"), target: join(homedir(), ".agents", "skills", "github-delivery"), @@ -14,6 +34,13 @@ export function parseInstallArgs(argv) { allowDowngrade: false, force: false, restore: null, + codexHome, + host: inferHost(codexHome), + lifecycleHooksSupported: undefined, + hookTrustVerified: parseBoolean(process.env.SHIPPING_GITHUB_HOOK_TRUST_VERIFIED), + streamLaunchControlled: parseBoolean( + process.env.SHIPPING_GITHUB_STREAM_LAUNCH_CONTROLLED, + ), }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -21,6 +48,12 @@ export function parseInstallArgs(argv) { else if (arg === "--target") options.target = argv[++index]; else if (arg === "--backup-root") options.backupRoot = argv[++index]; else if (arg === "--restore") options.restore = argv[++index]; + else if (arg === "--codex-home") options.codexHome = argv[++index]; + else if (arg === "--host") options.host = argv[++index]; + else if (arg === "--lifecycle-hooks-supported") options.lifecycleHooksSupported = true; + else if (arg === "--no-lifecycle-hooks") options.lifecycleHooksSupported = false; + else if (arg === "--hook-trust-verified") options.hookTrustVerified = true; + else if (arg === "--stream-launch-controlled") options.streamLaunchControlled = true; else if (arg === "--apply") options.apply = true; else if (arg === "--allow-downgrade") options.allowDowngrade = true; else if (arg === "--force") options.force = true; @@ -28,23 +61,122 @@ export function parseInstallArgs(argv) { } options.source = resolve(options.source); options.target = resolve(options.target); + options.codexHome = resolve(options.codexHome); if (options.backupRoot) options.backupRoot = resolve(options.backupRoot); if (options.restore) options.restore = resolve(options.restore); + if (options.lifecycleHooksSupported === undefined) { + options.lifecycleHooksSupported = options.host === "codex"; + } return options; } -export function main(argv = process.argv.slice(2)) { - const options = parseInstallArgs(argv); - let result; +function sameVersionActivationReceipt(plan) { + return { + schemaVersion: 1, + kind: "github-delivery/install-receipt", + action: "same-version", + sourceVersion: plan.sourceVersion, + previousVersion: plan.targetVersion, + target: plan.target, + backupPath: null, + unchanged: true, + }; +} + +export function installSkill(options) { if (options.restore) { - result = options.apply + return options.apply ? restoreBackup({ backup: options.restore, target: options.target }) : { action: "restore", apply: false, backup: options.restore, target: options.target }; - } else if (options.apply) { - result = applyInstallation(options); + } + + const sourceLauncherPath = join(options.source, "scripts", "codex-with-watchdog.mjs"); + const installedLauncherPath = join(options.target, "scripts", "codex-with-watchdog.mjs"); + const launcherBundled = existsSync(sourceLauncherPath); + const streamLaunchVerified = options.streamLaunchControlled === true && launcherBundled; + const hooksPath = join(options.codexHome, "hooks.json"); + + let hookPlan = null; + if (options.host === "codex" && options.lifecycleHooksSupported) { + hookPlan = installCodexWatchdogHooks({ + hooksPath, + skillDir: options.target, + apply: false, + }); + } + + const installationPlan = planInstallation(options); + const activationRefreshRequested = + options.hookTrustVerified === true || options.streamLaunchControlled === true; + let installation; + if (!options.apply) { + installation = { ...installationPlan, apply: false }; + } else if (installationPlan.action === "same-version" && activationRefreshRequested) { + installation = sameVersionActivationReceipt(installationPlan); } else { - result = { ...planInstallation(options), apply: false }; + installation = applyInstallation(options); + } + + if (options.apply && launcherBundled && !existsSync(installedLauncherPath)) { + throw new Error("protected Codex launcher was not installed with the skill payload"); + } + + let hookResult = hookPlan; + if (options.apply && hookPlan) { + hookResult = installCodexWatchdogHooks({ + hooksPath, + skillDir: options.target, + apply: true, + }); + } + + const hooksConfigured = Boolean(hookPlan); + const hookDefinitionChanged = Boolean(hookPlan?.wouldChange || hookResult?.applied); + const hookTrustVerified = Boolean( + hooksConfigured && options.hookTrustVerified === true && !hookDefinitionChanged, + ); + + const selection = selectWatchdogMode({ + host: options.host, + streamLaunchControlled: streamLaunchVerified, + lifecycleHooksSupported: options.lifecycleHooksSupported, + hookTrustVerified, + }); + if (options.streamLaunchControlled && !launcherBundled && selection.mode !== "stream") { + selection.degradationReason = "stream_launcher_unavailable"; } + + const launcherPath = selection.mode === "stream" ? installedLauncherPath : null; + const receiptResult = writeActivationReceipt({ + codexHome: options.codexHome, + mode: selection.mode, + degradationReason: selection.degradationReason, + launcherPath, + hooksConfigured, + hookTrustVerified, + apply: options.apply, + }); + + return { + ...installation, + watchdog: { + mode: selection.mode, + degradationReason: selection.degradationReason, + receiptPath: receiptResult.path, + receiptChanged: receiptResult.changed, + hooksConfigured, + hookTrustVerified, + hookTrustRequired: hooksConfigured && !hookTrustVerified, + hookResult, + launcherPath, + streamLauncherPath: launcherBundled ? installedLauncherPath : null, + }, + }; +} + +export function main(argv = process.argv.slice(2)) { + const options = parseInstallArgs(argv); + const result = installSkill(options); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return result; } diff --git a/scripts/lib/codex-watchdog-remote-bridge.mjs b/scripts/lib/codex-watchdog-remote-bridge.mjs new file mode 100644 index 00000000..996854a6 --- /dev/null +++ b/scripts/lib/codex-watchdog-remote-bridge.mjs @@ -0,0 +1,245 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { createInterface } from "node:readline"; + +import { createAppServerWatchdogRouter } from "./codex-app-server-watchdog-proxy.mjs"; + +const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_FRAME_BYTES = 16 * 1024 * 1024; + +function safeEqual(left, right) { + const a = Buffer.from(String(left || "")); + const b = Buffer.from(String(right || "")); + return a.length === b.length && timingSafeEqual(a, b); +} + +function encodeFrame(payload, opcode = 0x1) { + const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), "utf8"); + if (data.length > MAX_FRAME_BYTES) throw new Error("WebSocket frame exceeds watchdog bridge limit"); + let header; + if (data.length < 126) { + header = Buffer.from([0x80 | opcode, data.length]); + } else if (data.length <= 0xffff) { + header = Buffer.alloc(4); + header[0] = 0x80 | opcode; + header[1] = 126; + header.writeUInt16BE(data.length, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(data.length), 2); + } + return Buffer.concat([header, data]); +} + +function safeWriteFrame(socket, payload, opcode = 0x1) { + if (socket.destroyed || !socket.writable) return false; + try { + socket.write(encodeFrame(payload, opcode)); + return true; + } catch { + socket.destroy(); + return false; + } +} + +function broadcast(clients, payload) { + for (const client of clients) safeWriteFrame(client, payload); +} + +function createFrameParser(onText, onClose, sendControl) { + let buffer = Buffer.alloc(0); + let fragmentedOpcode = null; + let fragments = []; + + function deliver(opcode, payload, fin) { + if (opcode === 0x8) { + onClose(); + return; + } + if (opcode === 0x9) { + sendControl(0xA, payload); + return; + } + if (opcode === 0xA) return; + + if (opcode === 0x0) { + if (fragmentedOpcode === null) throw new Error("unexpected continuation frame"); + fragments.push(payload); + if (fin) { + const complete = Buffer.concat(fragments); + const originalOpcode = fragmentedOpcode; + fragmentedOpcode = null; + fragments = []; + if (originalOpcode === 0x1) onText(complete.toString("utf8")); + } + return; + } + + if (opcode !== 0x1) throw new Error(`unsupported WebSocket opcode ${opcode}`); + if (fin) { + onText(payload.toString("utf8")); + } else { + fragmentedOpcode = opcode; + fragments = [payload]; + } + } + + return { + push(chunk) { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 2) { + const first = buffer[0]; + const second = buffer[1]; + const fin = Boolean(first & 0x80); + const opcode = first & 0x0f; + const masked = Boolean(second & 0x80); + let length = second & 0x7f; + let offset = 2; + + if (length === 126) { + if (buffer.length < 4) return; + length = buffer.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + if (buffer.length < 10) return; + const large = buffer.readBigUInt64BE(2); + if (large > BigInt(MAX_FRAME_BYTES)) throw new Error("WebSocket frame exceeds watchdog bridge limit"); + length = Number(large); + offset = 10; + } + if (length > MAX_FRAME_BYTES) throw new Error("WebSocket frame exceeds watchdog bridge limit"); + if (!masked) throw new Error("client WebSocket frames must be masked"); + if (buffer.length < offset + 4 + length) return; + + const mask = buffer.subarray(offset, offset + 4); + offset += 4; + const payload = Buffer.from(buffer.subarray(offset, offset + length)); + for (let index = 0; index < payload.length; index += 1) { + payload[index] ^= mask[index % 4]; + } + buffer = buffer.subarray(offset + length); + deliver(opcode, payload, fin); + } + }, + }; +} + +function rejectUpgrade(socket, status, message) { + socket.end( + `HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`, + ); +} + +function headerContainsToken(value, token) { + return String(value || "") + .split(",") + .some((part) => part.trim().toLowerCase() === token); +} + +export async function startCodexWatchdogRemoteBridge({ + appServerInput, + appServerOutput, + token = null, + router = createAppServerWatchdogRouter(), + host = "127.0.0.1", +} = {}) { + if (!appServerInput?.writable || !appServerOutput?.readable) { + throw new Error("appServerInput and appServerOutput streams are required"); + } + + const clients = new Set(); + const server = createServer((request, response) => { + response.writeHead(404).end(); + }); + + server.on("upgrade", (request, socket, head) => { + if (clients.size > 0) { + rejectUpgrade(socket, "409 Conflict", "watchdog bridge already has a client"); + return; + } + if (token !== null) { + const expected = `Bearer ${token}`; + if (!safeEqual(request.headers.authorization, expected)) { + rejectUpgrade(socket, "401 Unauthorized", "missing or invalid watchdog bridge token"); + return; + } + } + const key = request.headers["sec-websocket-key"]; + const version = String(request.headers["sec-websocket-version"] || ""); + const upgrade = String(request.headers.upgrade || "").toLowerCase(); + const connectionUpgrade = headerContainsToken(request.headers.connection, "upgrade"); + if (!key || version !== "13" || upgrade !== "websocket" || !connectionUpgrade) { + rejectUpgrade(socket, "400 Bad Request", "invalid WebSocket upgrade"); + return; + } + const accept = createHash("sha1").update(`${key}${WEBSOCKET_GUID}`).digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + clients.add(socket); + + const parser = createFrameParser( + (text) => { + if (appServerInput.writable) appServerInput.write(`${text}\n`); + }, + () => socket.end(), + (opcode, payload) => safeWriteFrame(socket, payload, opcode), + ); + socket.on("data", (chunk) => { + try { + parser.push(chunk); + } catch { + socket.destroy(); + } + }); + socket.on("close", () => clients.delete(socket)); + socket.on("error", () => clients.delete(socket)); + if (head?.length) { + try { + parser.push(head); + } catch { + socket.destroy(); + } + } + }); + + const lines = createInterface({ input: appServerOutput, crlfDelay: Infinity }); + lines.on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + broadcast(clients, line); + return; + } + const routed = router.onServerMessage(message); + if (routed.forward) broadcast(clients, JSON.stringify(routed.forward)); + for (const request of routed.internalRequests) { + if (appServerInput.writable) appServerInput.write(`${JSON.stringify(request)}\n`); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, host, () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("watchdog bridge did not bind TCP"); + + return { + url: `ws://${host}:${address.port}`, + close: async () => { + lines.close(); + for (const client of clients) client.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} diff --git a/scripts/lib/runtime-capabilities.mjs b/scripts/lib/runtime-capabilities.mjs index dc885903..37d764c4 100644 --- a/scripts/lib/runtime-capabilities.mjs +++ b/scripts/lib/runtime-capabilities.mjs @@ -16,6 +16,7 @@ export function buildRuntimeCapabilities({ os = process.platform, probes = {}, declarations = {}, + activation = null, repo = null, } = {}) { const tools = { @@ -34,9 +35,20 @@ export function buildRuntimeCapabilities({ subagents: boolean(declarations.subagents), reviewTool: boolean(declarations.reviewTool), }; + const declaredWatchdog = declarations.progressWatchdog; + const installedWatchdog = activation?.mode; const runtime = { - progressWatchdog: watchdogMode(declarations.progressWatchdog), + progressWatchdog: watchdogMode( + declaredWatchdog === undefined || declaredWatchdog === null || declaredWatchdog === "" + ? installedWatchdog + : declaredWatchdog, + ), + progressWatchdogDegradationReason: + activation?.degradationReason || null, + progressWatchdogLauncherPath: + typeof activation?.launcherPath === "string" ? activation.launcherPath : null, }; + runtime.progressWatchdogAvailable = runtime.progressWatchdog !== "none"; const ghReadable = tools.gh && tools.ghAuthenticated && boolean(probes.repoReadableViaGh); @@ -113,6 +125,7 @@ export function buildRuntimeCapabilities({ !github.rulesetsReadable && "rulesets_unreadable", !github.reviewThreadsReadable && "review_threads_unreadable", fallbacks.rateLimits === "unavailable" && "rate_limit_probe_unavailable", + runtime.progressWatchdog === "none" && "progress_watchdog_unavailable", ]); return { diff --git a/scripts/lib/watchdog-activation.mjs b/scripts/lib/watchdog-activation.mjs new file mode 100644 index 00000000..566caa6c --- /dev/null +++ b/scripts/lib/watchdog-activation.mjs @@ -0,0 +1,134 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +const MODES = new Set(["none", "hooks", "stream"]); + +export function selectWatchdogMode({ + host = "unknown", + streamLaunchControlled = false, + lifecycleHooksSupported = false, + hookTrustVerified = false, +} = {}) { + if (host === "codex" && streamLaunchControlled === true) { + return { mode: "stream", degradationReason: null }; + } + if (host === "codex" && lifecycleHooksSupported === true && hookTrustVerified === true) { + return { mode: "hooks", degradationReason: "streaming_interruption_unavailable" }; + } + if (host === "codex" && lifecycleHooksSupported === true) { + return { mode: "none", degradationReason: "hook_trust_required" }; + } + return { mode: "none", degradationReason: "progress_watchdog_unavailable" }; +} + +export function activationReceiptPath({ codexHome } = {}) { + if (!codexHome) throw new Error("codexHome is required"); + return join(resolve(codexHome), "github-delivery", "watchdog-activation.json"); +} + +function normalizeReceipt(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (value.schemaVersion !== 1 || !MODES.has(value.mode)) return null; + if (value.degradationReason !== null && typeof value.degradationReason !== "string") return null; + if (value.launcherPath !== null && typeof value.launcherPath !== "string") return null; + if (value.hooksConfigured !== undefined && typeof value.hooksConfigured !== "boolean") return null; + if (value.hookTrustVerified !== undefined && typeof value.hookTrustVerified !== "boolean") return null; + return { + schemaVersion: 1, + mode: value.mode, + degradationReason: value.degradationReason, + launcherPath: value.launcherPath, + hooksConfigured: value.hooksConfigured === true, + hookTrustVerified: value.hookTrustVerified === true, + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, + }; +} + +export function readActivationReceipt({ codexHome } = {}) { + const path = activationReceiptPath({ codexHome }); + if (!existsSync(path)) return null; + try { + return normalizeReceipt(JSON.parse(readFileSync(path, "utf8"))); + } catch { + return null; + } +} + +function semanticReceipt({ + mode, + degradationReason = null, + launcherPath = null, + hooksConfigured = false, + hookTrustVerified = false, +}) { + if (!MODES.has(mode)) throw new Error(`invalid watchdog mode: ${mode}`); + if (degradationReason !== null && typeof degradationReason !== "string") { + throw new Error("degradationReason must be a string or null"); + } + if (launcherPath !== null && typeof launcherPath !== "string") { + throw new Error("launcherPath must be a string or null"); + } + return { + schemaVersion: 1, + mode, + degradationReason, + launcherPath: launcherPath ? resolve(launcherPath) : null, + hooksConfigured: hooksConfigured === true, + hookTrustVerified: hookTrustVerified === true, + }; +} + +function sameSemantics(left, right) { + return Boolean( + left && + right && + left.schemaVersion === right.schemaVersion && + left.mode === right.mode && + left.degradationReason === right.degradationReason && + left.launcherPath === right.launcherPath && + left.hooksConfigured === right.hooksConfigured && + left.hookTrustVerified === right.hookTrustVerified, + ); +} + +export function writeActivationReceipt({ + codexHome, + mode, + degradationReason = null, + launcherPath = null, + hooksConfigured = false, + hookTrustVerified = false, + apply = false, + now = () => new Date(), +} = {}) { + const path = activationReceiptPath({ codexHome }); + const desired = semanticReceipt({ + mode, + degradationReason, + launcherPath, + hooksConfigured, + hookTrustVerified, + }); + const existing = readActivationReceipt({ codexHome }); + const changed = !sameSemantics(existing, desired); + const receipt = changed + ? { ...desired, updatedAt: now().toISOString() } + : existing; + + if (apply && changed) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(receipt, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + } + + return { + path, + changed, + applied: apply && changed, + receipt, + }; +} diff --git a/scripts/runtime-capabilities.mjs b/scripts/runtime-capabilities.mjs index b8769959..9df45f1b 100644 --- a/scripts/runtime-capabilities.mjs +++ b/scripts/runtime-capabilities.mjs @@ -1,8 +1,11 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; import { buildRuntimeCapabilities } from "./lib/runtime-capabilities.mjs"; +import { readActivationReceipt } from "./lib/watchdog-activation.mjs"; const usage = "Usage: node scripts/runtime-capabilities.mjs [--repo OWNER/REPO] [--input FILE]"; @@ -13,8 +16,9 @@ function parseBoolean(value) { } function watchdogDeclaration(value) { - const normalized = String(value || "none").toLowerCase(); - return ["hooks", "stream"].includes(normalized) ? normalized : "none"; + if (value === undefined || value === null || value === "") return undefined; + const normalized = String(value).toLowerCase(); + return ["hooks", "stream", "none"].includes(normalized) ? normalized : "none"; } function commandAvailable(command, args = ["--version"]) { @@ -67,10 +71,12 @@ function liveInput(repo) { ]) : null; const permission = String(repoData?.viewerPermission || "").toUpperCase(); + const codexHome = resolve(process.env.CODEX_HOME || join(homedir(), ".codex")); return { host: process.env.SHIPPING_GITHUB_HOST || "unknown", os: process.platform, repo: resolvedRepo, + activation: readActivationReceipt({ codexHome }), probes: { node: true, git: commandAvailable("git"), diff --git a/tests/unit/codex-protected-launcher.test.mjs b/tests/unit/codex-protected-launcher.test.mjs new file mode 100644 index 00000000..1e381e55 --- /dev/null +++ b/tests/unit/codex-protected-launcher.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + protectedClientArgs, + protectedRuntimeEnv, +} from "../../scripts/codex-with-watchdog.mjs"; + +test("protected launcher owns the remote endpoint and preserves normal Codex args", () => { + assert.deepEqual(protectedClientArgs(["resume", "abc"], "ws://127.0.0.1:4500"), [ + "--remote", + "ws://127.0.0.1:4500", + "--remote-auth-token-env", + "GITHUB_DELIVERY_CODEX_REMOTE_TOKEN", + "resume", + "abc", + ]); +}); + +test("protected launcher rejects caller attempts to bypass its remote bridge", () => { + assert.throws( + () => protectedClientArgs(["--remote", "ws://elsewhere:4500"], "ws://127.0.0.1:4500"), + /owns --remote/, + ); + assert.throws( + () => protectedClientArgs(["--remote-auth-token-env=OTHER"], "ws://127.0.0.1:4500"), + /owns --remote-auth-token-env/, + ); +}); + +test("protected launcher declares stream capability only inside its launched runtime", () => { + const env = protectedRuntimeEnv({ EXISTING: "keep" }); + assert.equal(env.EXISTING, "keep"); + assert.equal(env.SHIPPING_GITHUB_HOST, "codex"); + assert.equal(env.SHIPPING_GITHUB_PROGRESS_WATCHDOG, "stream"); + assert.equal(env.SHIPPING_GITHUB_STREAM_LAUNCH_CONTROLLED, "true"); +}); diff --git a/tests/unit/codex-watchdog-remote-bridge.test.mjs b/tests/unit/codex-watchdog-remote-bridge.test.mjs new file mode 100644 index 00000000..64f8b7dd --- /dev/null +++ b/tests/unit/codex-watchdog-remote-bridge.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { connect } from "node:net"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { startCodexWatchdogRemoteBridge } from "../../scripts/lib/codex-watchdog-remote-bridge.mjs"; + +function nextTurn() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function openWebSocket(url) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + socket.addEventListener("open", () => resolve(socket), { once: true }); + socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), { once: true }); + }); +} + +function rawUpgrade(url) { + const parsed = new URL(url); + return new Promise((resolve, reject) => { + const socket = connect({ host: parsed.hostname, port: Number(parsed.port) }); + let response = ""; + socket.setEncoding("utf8"); + socket.once("error", reject); + socket.on("data", (chunk) => { + response += chunk; + }); + socket.once("end", () => resolve(response)); + socket.once("connect", () => { + socket.write( + "GET / HTTP/1.1\r\n" + + `Host: ${parsed.host}\r\n` + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n", + ); + }); + }); +} + +test("protected bridge rejects a client that lacks its bearer token", async () => { + const appServerInput = new PassThrough(); + const appServerOutput = new PassThrough(); + const bridge = await startCodexWatchdogRemoteBridge({ + appServerInput, + appServerOutput, + token: "secret-token", + }); + + const response = await rawUpgrade(bridge.url); + assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m); + + await bridge.close(); +}); + +test("installed streaming boundary interrupts the observed Let me check type loop before 500 characters", async () => { + const appServerInput = new PassThrough(); + const appServerOutput = new PassThrough(); + let requests = ""; + appServerInput.on("data", (chunk) => { + requests += chunk.toString("utf8"); + }); + + const bridge = await startCodexWatchdogRemoteBridge({ + appServerInput, + appServerOutput, + token: null, + }); + const client = await openWebSocket(bridge.url); + + const phrases = [ + "Let me check the type.\n", + "Let me check the NOUS_DEF type.\n", + "Let me check the live test type.\n", + "Let me check the type.\n", + "Let me check the OAuthProviderDef type.\n", + "Let me check the type.\n", + "Let me check the current NOUS_DEF type.\n", + "Let me check the type.\n", + ]; + + let emitted = 0; + for (const delta of phrases) { + emitted += delta.length; + appServerOutput.write( + `${JSON.stringify({ + method: "item/agentMessage/delta", + params: { + threadId: "thr_live", + turnId: "turn_live", + itemId: "item_live", + delta, + }, + })}\n`, + ); + await nextTurn(); + if (requests.includes('"method":"turn/interrupt"')) break; + } + + assert.match(requests, /"method":"turn\/interrupt"/); + assert.ok(emitted < 500, `protected boundary allowed ${emitted} characters before interruption`); + assert.equal((requests.match(/"method":"turn\/interrupt"/g) || []).length, 1); + + appServerOutput.write( + `${JSON.stringify({ + method: "item/agentMessage/delta", + params: { + threadId: "thr_live", + turnId: "turn_live", + itemId: "item_live", + delta: "Let me check the type.\n", + }, + })}\n`, + ); + await nextTurn(); + assert.equal((requests.match(/"method":"turn\/interrupt"/g) || []).length, 1); + + client.close(); + await bridge.close(); +}); diff --git a/tests/unit/runtime-capabilities-activation.test.mjs b/tests/unit/runtime-capabilities-activation.test.mjs new file mode 100644 index 00000000..da854056 --- /dev/null +++ b/tests/unit/runtime-capabilities-activation.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildRuntimeCapabilities } from "../../scripts/lib/runtime-capabilities.mjs"; + +test("persisted stream activation is reported without an environment declaration", () => { + const result = buildRuntimeCapabilities({ + probes: { node: true }, + activation: { + schemaVersion: 1, + mode: "stream", + degradationReason: null, + launcherPath: "/tmp/github-delivery-codex", + }, + declarations: {}, + }); + assert.equal(result.runtime.progressWatchdog, "stream"); + assert.equal(result.runtime.progressWatchdogAvailable, true); + assert.equal(result.runtime.progressWatchdogLauncherPath, "/tmp/github-delivery-codex"); + assert.equal(result.fallbacks.contextEconomy, "streaming-watchdog"); +}); + +test("persisted hook activation reports its streaming limitation", () => { + const result = buildRuntimeCapabilities({ + probes: { node: true }, + activation: { + schemaVersion: 1, + mode: "hooks", + degradationReason: "streaming_interruption_unavailable", + launcherPath: null, + }, + declarations: {}, + }); + assert.equal(result.runtime.progressWatchdog, "hooks"); + assert.equal( + result.runtime.progressWatchdogDegradationReason, + "streaming_interruption_unavailable", + ); + assert.equal(result.fallbacks.contextEconomy, "lifecycle-hooks"); +}); + +test("explicit runtime declaration can override persisted activation for controlled hosts", () => { + const result = buildRuntimeCapabilities({ + probes: { node: true }, + activation: { mode: "hooks", degradationReason: "streaming_interruption_unavailable" }, + declarations: { progressWatchdog: "stream" }, + }); + assert.equal(result.runtime.progressWatchdog, "stream"); +}); diff --git a/tests/unit/watchdog-activation-contract.test.mjs b/tests/unit/watchdog-activation-contract.test.mjs new file mode 100644 index 00000000..165fba19 --- /dev/null +++ b/tests/unit/watchdog-activation-contract.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + activationReceiptPath, + readActivationReceipt, + selectWatchdogMode, + writeActivationReceipt, +} from "../../scripts/lib/watchdog-activation.mjs"; + +test("watchdog selection prefers stream, then trusted hooks, then truthful degradation", () => { + assert.deepEqual( + selectWatchdogMode({ + host: "codex", + streamLaunchControlled: true, + lifecycleHooksSupported: true, + hookTrustVerified: false, + }), + { mode: "stream", degradationReason: null }, + ); + assert.deepEqual( + selectWatchdogMode({ + host: "codex", + streamLaunchControlled: false, + lifecycleHooksSupported: true, + hookTrustVerified: true, + }), + { mode: "hooks", degradationReason: "streaming_interruption_unavailable" }, + ); + assert.deepEqual( + selectWatchdogMode({ + host: "codex", + streamLaunchControlled: false, + lifecycleHooksSupported: true, + hookTrustVerified: false, + }), + { mode: "none", degradationReason: "hook_trust_required" }, + ); + assert.deepEqual( + selectWatchdogMode({ host: "unknown", streamLaunchControlled: false, lifecycleHooksSupported: false }), + { mode: "none", degradationReason: "progress_watchdog_unavailable" }, + ); +}); + +test("activation receipt is dry-run safe, non-sensitive, and idempotent", () => { + const codexHome = mkdtempSync(join(tmpdir(), "gd-activation-receipt-")); + const path = activationReceiptPath({ codexHome }); + const clock = () => new Date("2026-08-11T06:30:00.000Z"); + const desired = { + codexHome, + mode: "hooks", + degradationReason: "streaming_interruption_unavailable", + hooksConfigured: true, + hookTrustVerified: true, + }; + + const planned = writeActivationReceipt({ ...desired, apply: false, now: clock }); + assert.equal(planned.changed, true); + assert.equal(planned.applied, false); + assert.equal(existsSync(path), false); + + const applied = writeActivationReceipt({ ...desired, apply: true, now: clock }); + assert.equal(applied.applied, true); + const raw = readFileSync(path, "utf8"); + assert.doesNotMatch(raw, /prompt|conversation|toolInput|tool_input/i); + assert.deepEqual(readActivationReceipt({ codexHome }), applied.receipt); + assert.equal(applied.receipt.hooksConfigured, true); + assert.equal(applied.receipt.hookTrustVerified, true); + + const repeated = writeActivationReceipt({ + ...desired, + apply: true, + now: () => new Date("2026-08-11T07:30:00.000Z"), + }); + assert.equal(repeated.changed, false); + assert.equal(repeated.applied, false); + assert.equal(repeated.receipt.updatedAt, "2026-08-11T06:30:00.000Z"); +}); diff --git a/tests/unit/watchdog-activation.test.mjs b/tests/unit/watchdog-activation.test.mjs new file mode 100644 index 00000000..4aa40cdd --- /dev/null +++ b/tests/unit/watchdog-activation.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const ROOT = resolve(import.meta.dirname, "../.."); +const INSTALL = join(ROOT, "scripts", "install-skill.mjs"); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "gd-watchdog-activation-")); + const source = join(root, "source"); + const target = join(root, "skills", "github-delivery"); + const codexHome = join(root, ".codex"); + mkdirSync(join(source, "scripts"), { recursive: true }); + writeFileSync( + join(source, "package.json"), + `${JSON.stringify({ name: "github-delivery", version: "0.2.0" }, null, 2)}\n`, + ); + writeFileSync(join(source, "marker.txt"), "installed\n"); + writeFileSync(join(source, "scripts", "codex-watchdog-hook.mjs"), "// hook fixture\n"); + writeFileSync(join(source, "scripts", "codex-with-watchdog.mjs"), "// launcher fixture\n"); + return { root, source, target, codexHome }; +} + +function runInstall(f, extra = []) { + return spawnSync( + process.execPath, + [ + INSTALL, + "--source", + f.source, + "--target", + f.target, + "--codex-home", + f.codexHome, + "--host", + "codex", + ...extra, + ], + { cwd: ROOT, encoding: "utf8" }, + ); +} + +test("normal Codex install configures hooks but does not falsely claim untrusted hooks are active", () => { + const f = fixture(); + const result = runInstall(f, ["--lifecycle-hooks-supported", "--apply"]); + assert.equal(result.status, 0, result.stderr); + + const receipt = JSON.parse(result.stdout); + assert.equal(receipt.watchdog.mode, "none"); + assert.equal(receipt.watchdog.degradationReason, "hook_trust_required"); + assert.equal(receipt.watchdog.hooksConfigured, true); + assert.equal(receipt.watchdog.hookTrustVerified, false); + assert.equal(receipt.watchdog.hookTrustRequired, true); + assert.equal(readFileSync(join(f.target, "marker.txt"), "utf8"), "installed\n"); + + const hooksPath = join(f.codexHome, "hooks.json"); + const hooks = JSON.parse(readFileSync(hooksPath, "utf8")); + for (const event of ["PreToolUse", "PostToolUse", "Stop", "SubagentStop", "SessionEnd"]) { + const commands = (hooks.hooks[event] || []) + .flatMap((entry) => entry.hooks || []) + .map((entry) => entry.command || ""); + assert.equal(commands.filter((command) => command.includes("codex-watchdog-hook.mjs")).length, 1); + } + + const persisted = JSON.parse( + readFileSync(join(f.codexHome, "github-delivery", "watchdog-activation.json"), "utf8"), + ); + assert.equal(persisted.mode, "none"); + assert.equal(persisted.hooksConfigured, true); + assert.equal(persisted.hookTrustVerified, false); + assert.equal( + receipt.watchdog.streamLauncherPath, + join(f.target, "scripts", "codex-with-watchdog.mjs"), + ); +}); + +test("verified unchanged hook definitions can be persisted as active without reinstalling the same skill", () => { + const f = fixture(); + const first = runInstall(f, ["--lifecycle-hooks-supported", "--apply"]); + assert.equal(first.status, 0, first.stderr); + + const afterTrust = runInstall(f, [ + "--lifecycle-hooks-supported", + "--hook-trust-verified", + "--apply", + ]); + assert.equal(afterTrust.status, 0, afterTrust.stderr); + const result = JSON.parse(afterTrust.stdout); + assert.equal(result.action, "same-version"); + assert.equal(result.unchanged, true); + assert.equal(result.watchdog.mode, "hooks"); + assert.equal(result.watchdog.degradationReason, "streaming_interruption_unavailable"); + assert.equal(result.watchdog.hookTrustVerified, true); + + const persisted = JSON.parse( + readFileSync(join(f.codexHome, "github-delivery", "watchdog-activation.json"), "utf8"), + ); + assert.equal(persisted.mode, "hooks"); + assert.equal(persisted.hookTrustVerified, true); +}); + +test("a hook definition change invalidates a claimed trust state", () => { + const f = fixture(); + const result = runInstall(f, [ + "--lifecycle-hooks-supported", + "--hook-trust-verified", + ]); + assert.equal(result.status, 0, result.stderr); + const planned = JSON.parse(result.stdout); + assert.equal(planned.watchdog.mode, "none"); + assert.equal(planned.watchdog.degradationReason, "hook_trust_required"); + assert.equal(planned.watchdog.hookTrustVerified, false); +}); + +test("stream mode is selected only when the installed launch boundary is explicitly controllable", () => { + const f = fixture(); + const withoutBoundary = runInstall(f, ["--lifecycle-hooks-supported"]); + assert.equal(withoutBoundary.status, 0, withoutBoundary.stderr); + assert.equal(JSON.parse(withoutBoundary.stdout).watchdog.mode, "none"); + + const withBoundary = runInstall(f, [ + "--lifecycle-hooks-supported", + "--stream-launch-controlled", + ]); + assert.equal(withBoundary.status, 0, withBoundary.stderr); + const result = JSON.parse(withBoundary.stdout); + assert.equal(result.watchdog.mode, "stream"); + assert.equal(result.watchdog.launcherPath, join(f.target, "scripts", "codex-with-watchdog.mjs")); +}); + +test("unsupported hosts report watchdog unavailability without blocking skill installation", () => { + const f = fixture(); + const result = spawnSync( + process.execPath, + [ + INSTALL, + "--source", + f.source, + "--target", + f.target, + "--codex-home", + f.codexHome, + "--host", + "unknown", + "--apply", + ], + { cwd: ROOT, encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); + const receipt = JSON.parse(result.stdout); + assert.equal(receipt.watchdog.mode, "none"); + assert.equal(receipt.watchdog.degradationReason, "progress_watchdog_unavailable"); + assert.equal(readFileSync(join(f.target, "marker.txt"), "utf8"), "installed\n"); +});