@staix/agent-hub 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -8
  5. package/docs/agent-notes/daemon.md +5 -2
  6. package/docs/agent-notes/local-worker.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -1
  8. package/docs/agent-notes/tests.md +6 -3
  9. package/docs/dogfood.md +9 -3
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +66 -28
  12. package/docs/quickstart.md +9 -5
  13. package/docs/security.md +22 -13
  14. package/docs/smoke.md +3 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
  16. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  17. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  18. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  19. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  20. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  21. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  22. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  23. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  24. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  25. package/docs/verification/claude-permission-transport-native.py +187 -0
  26. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  27. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  28. package/docs/verification/pi-140-model-free.ts +57 -0
  29. package/docs/verification/pi-140-native.mjs +21 -0
  30. package/docs/verified.json +14 -14
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +273 -68
  34. package/src/adapters/agy/entry.ts +1 -1
  35. package/src/adapters/claude-channel.ts +7 -3
  36. package/src/adapters/claude-implementer.ts +5 -3
  37. package/src/adapters/compat.ts +32 -18
  38. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  39. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  40. package/src/adapters/definitions/native-factories.ts +7 -2
  41. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  42. package/src/adapters/pi.ts +39 -3
  43. package/src/adapters/qwen-dual.ts +25 -0
  44. package/src/adapters/registry-types.ts +6 -2
  45. package/src/adapters/registry.ts +1 -0
  46. package/src/cli/agents.ts +69 -7
  47. package/src/cli/claude-shell-wrapper.ts +54 -0
  48. package/src/cli/main.ts +30 -24
  49. package/src/cli/output.ts +4 -0
  50. package/src/hub/agent-drift.ts +61 -0
  51. package/src/hub/board.ts +14 -2
  52. package/src/hub/check-runner.ts +148 -18
  53. package/src/hub/claude-shell-wrapper.ts +349 -0
  54. package/src/hub/config-edit.ts +2 -2
  55. package/src/hub/config-tier.ts +2 -2
  56. package/src/hub/daemon.ts +197 -35
  57. package/src/hub/host-executable.ts +20 -1
  58. package/src/hub/instances.ts +18 -2
  59. package/src/hub/lifecycle.ts +1 -1
  60. package/src/hub/operational-authority.ts +7 -0
  61. package/src/hub/operational-view.ts +6 -10
  62. package/src/hub/read-only-shell-proof.ts +7 -6
  63. package/src/hub/review-scope.ts +13 -0
  64. package/src/hub/sdlc-metrics.ts +6 -4
  65. package/src/hub/task-shell-command.ts +85 -0
  66. package/src/hub/task-shell-default.ts +78 -0
  67. package/src/hub/task-shell-proof.ts +171 -0
  68. package/src/hub/task-shell-sandbox.ts +61 -0
  69. package/src/hub/tasks.ts +139 -13
  70. package/src/local/deny.ts +91 -11
  71. package/src/local/sandbox.ts +67 -28
  72. package/src/local/shell-environment.ts +10 -0
  73. package/src/ui/index.html +1 -0
@@ -0,0 +1,62 @@
1
+ # Headless Claude host shell wrapper receipt
2
+
3
+ Date: 2026-10-26. Scope: #845 AC6-AC8. A fresh model-free native run passed on both binaries below.
4
+
5
+ | Native version | Binary SHA-256 |
6
+ | --- | --- |
7
+ | Claude Code 2.1.309 | `5eadc0009cdcdbc0d06e58110d7b2baf88ced6088b1c33a60a21217fe410619c` |
8
+ | Claude Code 2.1.310 | `ffb64331bc0ee1b69efb7c2c428bd3ab516bc31b75bf60886b356c97c1c25ffd` |
9
+
10
+ ## Method
11
+
12
+ The installed native binaries ran sequentially with isolated HOME and CLAUDE_CONFIG_DIR directories, empty settings sources, explicit fixture settings asking for Bash, a strict fixture MCP inventory and a deterministic loopback provider. The provider supplied Bash calls and a final completion response. No personal authentication files, real provider request, account or model inference was used. Each native case had an external 25-second deadline. Native leaders were reaped, remaining live group members were checked, and the host fixture shut down the production wrapper through its owned cleanup path. Binary hashes were unchanged after every case.
13
+
14
+ The producers are [claude-shell-wrapper-native.py](claude-shell-wrapper-native.py) and [claude-shell-wrapper-host.ts](claude-shell-wrapper-host.ts). The host fixture imports the actual `HostShellWrapper` and fixed CLI entry from production source. It supplies synthetic task, generation and branch bindings and a current-root predicate; it does not simulate a real task-board assignment or certify the daemon's authorization policy. Real daemon fixtures separately cover task/child/root/branch, mode, privacy and action-policy admission.
15
+
16
+ For each version, the control ran `cd sub && pwd` followed by `pwd`, returning the original input in both permission replies. Both outputs named `sub`, demonstrating persistent native shell cwd. The wrapper case first performed the same `cd`, then requested `pwd` twice. Both later permission replies returned fresh `updatedInput` objects containing the same issued fixed-wrapper command and single-use ticket. The selected host executable, outside the worktree, printed its identity, cwd and environment fields and incremented an effect counter. Root and executable paths included spaces and apostrophes.
17
+
18
+ Native HOME, PATH and cwd differed from the host execution context. Native `GIT_DIR` and `RIPGREP_CONFIG_PATH` were injected with poison paths. The actual target received the host's fixed PATH/HOME/LANG/TERM and peer identity, with its owned command TMPDIR. Helper startup uses fixed private HOME/cwd, config and dotenv suppression. The host supervisor receives exact target argv/env over private fd4 rather than shell interpolation or argv serialization, and preserves the target environment without adding shell variables.
19
+
20
+ ## Native observations
21
+
22
+ | Leg | 2.1.309 | 2.1.310 |
23
+ | --- | --- | --- |
24
+ | Persistent-shell control reports `sub` twice | pass | pass |
25
+ | Fresh rewritten input executes the fixed root after the earlier `cd` | pass | pass |
26
+ | Target identity equals the absolute host-selected executable | pass | pass |
27
+ | Injected native GIT_DIR and RIPGREP_CONFIG_PATH are absent | pass | pass |
28
+ | Target HOME/PATH/TERM and peer identity equal the host fixture values | pass | pass |
29
+ | Owned TMPDIR replaces native state | pass | pass |
30
+ | Same ticket's second execution returns the fixed refusal and native tool error | pass | pass |
31
+ | Effect counter remains exactly 1 after replay | pass | pass |
32
+ | Spaces and apostrophes in root/executable paths | pass | pass |
33
+ | Native exit 0, host stopped, no remaining live native group members | pass | pass |
34
+ | Binary unchanged across the case | pass | pass |
35
+
36
+ All four cases passed. Each control made three loopback requests and two MCP permission calls; each wrapper case made four requests and three permission calls. The verifier requires exact original native permission arguments and exact rewritten replies. Successful output comes from native stream-json `tool_use_result.stdout`, with the corresponding tool-result block equal to that stdout, empty stderr and no interruption. Replay must be a native error containing the fixed refusal, and the counter must still be 1. Provider-facing text with native system reminders is not treated as raw command output.
37
+
38
+ ## Reproduction and retained evidence
39
+
40
+ Run from the repository with explicit installed binary paths and a new private output directory:
41
+
42
+ ```sh
43
+ python3 docs/verification/claude-shell-wrapper-native.py NEW_OUTPUT --bun ABSOLUTE_BUN --claude INSTALLED_2.1.309 --claude INSTALLED_2.1.310
44
+ ```
45
+
46
+ The successful local diagnostic directory is `claude513-wrapper-native-20261026-r1`. It retains native stdout/stderr, exact MCP request/reply records, loopback request bodies, the host fixture diagnostics and `receipt.json`. No actual account credentials are in those fixtures. The framework used host Bun 1.4.3; separate Linux fixtures exercised the same wrapper on pinned Bun 1.4.2. Package and CI evidence remain separate.
47
+
48
+ | Artifact | SHA-256 |
49
+ | --- | --- |
50
+ | Native producer | `ecf818acd4b4d42db87416a83dc3f5d2dfbfff3a07c70915fabd25abd8ed2649` |
51
+ | Host fixture | `616e6711e392f0eee7e3fa1c7bdc47a537dfa86bcc8ffb30e549ca94e5a71cef` |
52
+ | Production wrapper service | `a573956e9c408546aa618afe6b776932fb0d5dd462c089e02993e2629adaf675` |
53
+ | Fixed CLI wrapper | `8ff56514a6e3f25bce0784775d396df199b58b1b93f55ad0e8747bbc729d2fd8` |
54
+ | Four-case receipt JSON | `3db74019a71be0b556d88e21b8273da93e1fdbeace200190e1a5c801a23b6ea8` |
55
+
56
+ The earlier direct-rewrite diagnostic and its initial provider-text parsing failure are preserved in this document's Git history and retained raw artifacts. This receipt uses a fresh run of the strict structured-output verifier, not a restamp of that earlier failed producer result.
57
+
58
+ ## Host fixtures and ceilings
59
+
60
+ Mac fixtures separately passed the existing #816 Seatbelt recursive-deny boundary, profile/read-only-data mutation refusal and actual environment parity against `sandboxedExec`. The sandbox source remains byte-identical to its accepted baseline. Current host policy requires the local-worker Seatbelt profile plus all-Git-metadata denial for read-only and check/test/build grants; unsupported hosts ask. Closed hardened host Git add/commit/push and gh remain outside it. This native transport fixture does not exercise those privileged Git mutators; their helper/config and authorization regressions are separate. Additional daemon fixtures cover a model-written test that cannot read the live control token or SSH key even when the canonical checkout is readable. These authorization fixtures are separate from this generic native wrapper fixture. Portable fixtures cover binary fd output, numeric status, expiry/replay/quotas, helper config/env isolation, current proof/root/argv/env mutation, cancellation, deadline, disconnection and descendant pipe cleanup. Task and daemon fixtures cover persistent branch binding, action-policy asks, unchanged ask behavior and old-client protocol refusal.
61
+
62
+ The [earlier permission transport receipt](2026-10-23-headless-claude-permissions.md) remains evidence for its original allow/deny/error/hook cases. A native version upgrade repeats both this wrapper procedure and the original permission-transport procedure before extending the verified set. This wrapper receipt adds no version to COMPAT and does not prove real provider authentication, model tool selection, task-session resume, a person's approval interaction or isolation of the entire native Claude process. The executable task-code profile is host policy verified separately by Darwin fixtures. It is not a runtime receipt gate. Native version admission and binary fencing remain required. Exact-head package gate and CI results are recorded separately in the PR handoff.
@@ -0,0 +1,49 @@
1
+ # Jcode 0.99.1 receipt (#843 AC5)
2
+
3
+ Date: 2026-10-26. Refs #843. Installed release build, read through `--quiet version --json`: `semver` 0.99.1, `version` `v0.99.1 (7206efd0c)`, from [1jehuang/jcode](https://github.com/1jehuang/jcode) tag v0.99.1 (published 2026-10-22). Host macOS arm64, Bun 1.4.3.
4
+
5
+ This repeats the ACP, serve and MCP contract legs of [the 0.98.1 receipt](2026-10-19-jcode-0.98.1.md) and the 0.99.0 steering check. Every leg passed, so 0.99.1 is added to jcode `verified` in `src/adapters/compat.ts` in the same change.
6
+
7
+ ## Isolation
8
+
9
+ - Every process ran under an isolated `HOME` and `JCODE_HOME` (fresh directories under `/tmp/ahub-jc0991`, an empty `mcp.json`, no credentials, no account), with `JCODE_NO_TELEMETRY=1` and `JCODE_DISABLE_CLAUDE_MCP=1` as the adapter sets them. The real `~/.jcode` was never the home.
10
+ - SHA-256 of the four real config files that sit directly in `~/.jcode` (`auth.json`, `auth-refresh-state.json`, `config.toml` and `mcp.json`) was taken before the first leg and compared after the last: unchanged.
11
+ - The probes built every argv with the adapter's own `jcodeCommand` and spawned `serve` with `JCODE_SOCKET` and `JCODE_READY_FD=3` the way `JcodePeer` does. After each run `pgrep` found no process whose command line named the sandbox, and no telemetry file was written into the isolated home.
12
+
13
+ ## Contract legs (idle server, no model)
14
+
15
+ - **serve** with the adapter's socket name `j.sock`: ready on the private socket in 102 to 104 ms across two runs. It leaves `j-debug.sock`, `j.sock.hash`, `j.sock.server.json` and `j.sock.daemon.lock` beside it, and the server info file carries `argv`, `debug_socket_path`, `idle_timeout_secs`, `owner_pid`, `pid`, `ppid`, `schema_version`, `scope`, `socket_path` and `started_at`. The process group is gone after SIGTERM.
16
+ - **ACP** (`acp --mcp-tools eager` against the same private server):
17
+ - `initialize`: `protocolVersion` 1, `agentInfo` `{ name: "jcode", title: "Jcode", version: "0.99.1" }`, `loadSession: true`, `authMethods: []`.
18
+ - `session/new` returns `configOptions`, `models` and `sessionId`.
19
+ - `session/load` returns `configOptions` and `models` only, with no `sessionId`. The restored identity stays unproven, so `requireLoadSessionEcho` and the withheld automatic recovery are unchanged.
20
+ - `session/set_mode` and `session/request_permission` are still `Unsupported ACP method`, as on 0.98.1.
21
+ - **MCP**: a project `.jcode/mcp.json` entry that names no peer was started by the eager ACP child, and its environment held exactly `AGENTHUB_PEER_ID` and `AGENTHUB_LAUNCH_ID` from the owned process environment (values set on the `acp` child by the probe), nothing from the entry itself.
22
+ - **Telemetry**: with the adapter's `JCODE_NO_TELEMETRY=1` no telemetry file appeared in the isolated home.
23
+
24
+ ## Steering leg (the 0.99.0 check, now on a running turn)
25
+
26
+ The 0.99.0 check was established on an idle session. This receipt also exercises it on a running turn, still with no account: the isolated home holds one `openai-compatible` profile `fakegw` (added with `jcode provider add ... --no-api-key`, written only into the isolated `config.toml`) pointing at a local server on `127.0.0.1:18999` that streams a slow, fixed reply and records each request it receives. No real provider, key or model was involved.
27
+
28
+ - `serve` and `acp` were started with the adapter's argv carrying `--model fake-model --provider-profile fakegw`, a session was created and `session/prompt` started a turn.
29
+ - While that turn streamed, a second client on the private socket ran the same steps as `nativeSteer`: `ping` listed `soft_interrupt_ids` in `capabilities`, `subscribe` with `target_session_id` joined the same session (no new session), `state` reported `is_processing: true`, `soft_interrupt` with `urgent: false` was acknowledged.
30
+ - The prompt then ended with `end_turn`, not `cancelled`. The local server saw two requests. The first held only the original prompt. The second held the original prompt, the assistant text streamed so far and the steer text as a new user message, so the steer was injected at the end of the running response and the turn was not cancelled, as the adapter's comment on `nativeSteer` states.
31
+ - The idle variant (the same ping, subscribe and state steps against an idle session, run in the contract legs above) reports `is_processing: false`, which `nativeSteer` refuses with "the session has no running turn" without sending anything. That refusal is read from the adapter's source; the probe sent no steer to an idle session.
32
+
33
+ ## Source comparison v0.99.0..v0.99.1
34
+
35
+ 232 commits, 300 files, read only through the GitHub compare API (nothing built).
36
+
37
+ - Protocol (`crates/jcode-protocol`): additions only. `SwarmMemberRuntime` gains four optional fields, `Request::PersistSession` is new, and a swarm send can be refused for a non-coordinator. No existing request, field or `soft_interrupt_ids` capability was removed or renamed.
38
+ - MCP (`tool/mcp.rs`): the model-facing `mcp_search` and `mcp_call` tools become internal aliases of `mcp action=search` and `mcp action=call`. The hub's configuration and its injected tools never name those tools, so nothing here changes the owned MCP set.
39
+ - No change under the ACP, socket-name or `JCODE_READY_FD` paths was found among the files read; the fixes listed in the release notes (OpenAI schema, TUI recovery from a vanished server, configured model for fresh sessions, stale daemon token recovery) are native behavior behind the same contract.
40
+
41
+ ## Not verified
42
+
43
+ - A real account, provider or model, and a model turn that calls a hub tool through the project MCP entry, as in every earlier jcode receipt. The steering leg proves delivery to the provider request, not how a real model reacts to it.
44
+ - The TUI form (`jcode --resume`), covered separately for 0.98.1 in [the TUI receipt](2026-10-20-jcode-tui-0.98.1.md), was not repeated.
45
+ - A second instance running beside the base peer, and Linux or Intel macOS builds.
46
+
47
+ ## Decision
48
+
49
+ All legs passed, so `0.99.1` joins `verified` for jcode. The accepted range (0.98.0 up to, not including, 1.0.0) is unchanged. A failed leg on a later release would have meant no bump, as the task requires.
@@ -0,0 +1,53 @@
1
+ # Kimi 2.3.2 fresh-reviewer compatibility receipt
2
+
3
+ ## Scope and identity
4
+
5
+ This #843 (AC5, task #508) receipt repeats the model-free 2.3.1 procedure (`2026-10-25-kimi-231-reviewer-compat.md`) on the installed Kimi 2.3.2. No real account, provider inference, personal configuration, native login, credential refresh or personal machine trust was used or changed. As for 2.3.1 the result is a version verification only: no `kimi-review` instance is declared, and Kimi stays a pull-only TUI peer. The reviewer remains off until a project declares one.
6
+
7
+ | Artifact | Binding |
8
+ | --- | --- |
9
+ | Kimi native macOS arm64 executable | version `2.3.2`; SHA-256 `d84a94c7d73405283a279348d2de78abbf31d2b1f7c92a8d739eeb40cf2a2f9f` |
10
+ | Kimi source | tag `@moonshot-ai/kimi-code@2.3.2`, commit [`b8dd3a134060c6e80ba4a1659335cd63b68483f2`](https://github.com/MoonshotAI/kimi-code/tree/b8dd3a134060c6e80ba4a1659335cd63b68483f2) |
11
+
12
+ ## What changed since 2.3.1
13
+
14
+ Five commits (`684bae9e..b8dd3a13`, 22 files, 2.3.1 to 2.3.2): a test change that isolates cloud config in the TUI message-flow tests (e79422aa), "leave swarm mode to the user" (c9a48369), "launch subagents only on explicit request" (fd2e2c7e), a changeset wording fix (00e7007a) and the release (b8dd3a13). Read for reviewer-isolation impact:
15
+
16
+ - **Tools, MCP, config, trust and session code:** none of the 22 changed files touches tool registration, the MCP client or its config, `trustEnv.ts`, `workspaceMcpConfigService.ts`, session locks, the agent-file loader, the provider or OAuth code, or `--skills-dir`. The files are prompt text (`agent.md`, `system.md`, `enter-plan-mode.md`, `exit-reminder.md`, `agent-swarm.md`), the swarm service, the swarm tool, one profile description string, a transcript fold, docs and tests.
17
+ - **Swarm and subagents:** `AgentSwarmService.enter('tool')` now throws `Only the user can turn on swarm mode.`, `AgentSwarmTool` returns a fixed error result while swarm mode is off, and the system prompt tells the model not to launch subagents unless asked. This narrows what a model can start and does not widen any tool. The reviewer agent file already declares `subagents: []` and exactly two tools; the receipt's `prepare` leg asserts both on 2.3.2.
18
+ - **Profiles:** `profiles.ts` changes only the `whenToUse` string of the `explore` profile. The reviewer does not use that profile.
19
+
20
+ The probe sets `KIMI_CODE_TRUST_WORKSPACE` in the host environment as a decoy and asserts it is absent from the launch environment, as for 2.3.1.
21
+
22
+ ## Results
23
+
24
+ The 2.3.1 producer was copied with only the version, source commit, output paths and the decoy comment changed (`diff` of the two producers shows exactly those lines). It ran against an isolated HOME and a synthetic native `KIMI_CODE_HOME` (managed `kimi` provider, invented access-only OAuth record, device identity). The native print leg redirected only the managed provider endpoint and its scoped synthetic OAuth slot to a loopback fixed-response server, which performs no inference (`modelInferenceCalls: 0`).
25
+
26
+ | Leg | Fixture trust run | Verified run (no trust) |
27
+ | --- | --- | --- |
28
+ | `kimiReviewerBackend.prepare` (private config, exact two-tool agent, empty skills, replace env, `current()`, version `2.3.2`) | pass | pass (`fixtureTrustOnly: false`, with `assessVersion("kimi-review", "2.3.2", [])` asserted `verified`) |
29
+ | Task-bound MCP: exactly `hub_review` and `hub_task_show`; other task id, third tool and missing auth refused; revocation refuses reads | pass | pass |
30
+ | Native exact-two-tools: 4 requests, every inventory exactly the two hub names, both calls reached the host, injected `Write` refused, marker `unchanged` | pass | pass |
31
+ | Native positive control: `Write` added to the agent; inventory includes it, marker written (2 requests) | pass | pass |
32
+ | Trust-env decoy absent from the launch environment | pass | pass |
33
+ | Cleanup of owned groups and temporary trees | pass | pass |
34
+
35
+ The verified run was made with `2.3.2` already in the local `verified` list and no trust record. Before and after both runs, the SHA-256 of the real `~/.kimi-code` `config.toml`, `mcp.json` and `device_id` and a digest over every file in `credentials/` and `oauth/` were identical.
36
+
37
+ One environment note, not a Kimi finding: the producer first refused with `private cwd ancestor contains ambient configuration` because this machine's agent shell exported `TMPDIR` under `~/.jcode/scratch`, whose ancestor `~` holds a real `.kimi-code`. That is the reviewer backend's own isolation check working. Both recorded runs used the macOS default temporary root (`/var/folders/.../T/`), which has no ambient marker among its ancestors.
38
+
39
+ The producer ran with Bun 1.4.3 (the installed one). The hub code it exercises is plain TypeScript with no bundle step.
40
+
41
+ ## Not verified
42
+
43
+ A real-account review, attached-TUI coexistence during a review, OAuth refresh under a real token, review quality, token usage and quota behaviour remain unverified, as for 2.3.1. No future version or compatible line is inferred.
44
+
45
+ ## Evidence
46
+
47
+ Kept outside the repository in `scratchpad/temp/agent-hub-508`:
48
+
49
+ | Item | SHA-256 |
50
+ | --- | --- |
51
+ | `kimi-probe.ts` | `fa09ab4bfeb42becc0c87d1a959e7756082267906ba4aec4f50772ebda57d242` |
52
+ | `kimi-native-result.json` (fixture trust) | `098bb78f287de48c2772c89f3d0f7db3e93031d97614ecbe1b61bca17be5ba9e` |
53
+ | `kimi-verified-result.json` (no trust) | `4338c63103523585703c2b8b958a8ff92a0199fde668e169355abe8a5e06ce35` |
@@ -0,0 +1,41 @@
1
+ # Pi 1.4.0 model-free compatibility receipt
2
+
3
+ ## Identity and scope
4
+
5
+ - Issue #843 AC2, task #505. Initial source baseline: `991df88ffbe89497278082ad2e3b485d83b3b8b4` plus this PR; the native producer passed again after rebasing onto `9279711666d3ba2f01e83bfca65c6e7e12afdb94` with the exact agent-mem 0.9.1 pin.
6
+ - Native launcher: `~/.pi/agent/bin/pi`, managed package `@earendil-works/pi-coding-agent` version `1.4.0`.
7
+ - Native `dist/cli.js` SHA-256: `8189b66abc4f9f431dbb70941dcba690d76d040de1fbfff212886be35a53639d`.
8
+ - Measured on macOS arm64 with Bun 1.4.3 and Node 26.12.0 on 2026-10-26.
9
+ - Each native run uses an empty temporary HOME and `PI_CODING_AGENT_DIR`, a temporary cwd/session directory, and an environment containing only PATH, those isolated paths and invented loopback bridge settings. No account, real token, prompt, inference or personal trust entry is used.
10
+
11
+ ## Results
12
+
13
+ | Leg | Result | Evidence |
14
+ | --- | --- | --- |
15
+ | Native identity | pass | Isolated launcher `--version` returned exactly `1.4.0`, exit 0, empty stderr; package name/version checked independently. |
16
+ | Real CLI extension load and RPC session identity | pass | Native launcher in `--mode rpc`, `--no-builtin-tools`, `--no-skills`, `--no-prompt-templates`, `--no-extensions --extension <hub extension>`; `get_state` returned success, nonempty session id/file, messageCount 0, no pending messages; real extension emitted session_start/session_shutdown. |
17
+ | Installed SDK extension registration | pass | Pi 1.4.0's real `DefaultResourceLoader` and `createAgentSession` loaded this checkout's extension, bound its session context and registered exactly `compat_probe` as an active tool. No builtin tools were enabled. |
18
+ | Managed tool callback and receipt replay | pass | The native SDK's registered tool callback was invoked directly, without an agent prompt. It posted session/call ids to the isolated bridge using the real `PiToolReceipts`; two identical calls returned identical success with exactly one effect. |
19
+ | Failed tool result | pass | A distinct bridge fixture denial returned `failed: true`; the real extension returned `isError: true`. The fixture denial tests result propagation, not the host's authorization policy. |
20
+ | Host permission modes | pass (host fixtures) | `test/permission-modes.test.ts`, "Pi mode grants edits once, keeps shell on the console, and ask restores the console path": 1 pass, 12 assertions. ask-when-needed grants managed edits and asks for an unproven shell; never-ask changes approval frequency; ask restores the prompt. `test/pi-compat.test.ts` separately confirms read-only is refused by the production factory before a native probe. These modes remain host-owned; the launch adds no native permission-mode flag. |
21
+ | Version admission and launch paths | pass (host fixtures) | Exact verified/refused/trusted verdicts; headless, TUI, daemon peer_start and auto-start all reach `createPi` and refuse a fabricated 9.9.9 with `ahub agents trust pi 9.9.9`. A source guard confines production PiPeer construction to this registry factory. |
22
+ | Provider requests | pass | Zero requests reached the loopback provider endpoint; no prompt was submitted. Native and SDK stderr were empty. |
23
+
24
+ The SDK callback leg does not claim model-generated tool dispatch. Host mode fixtures use a synthetic Pi transport and loopback model metadata, independently of the native SDK leg. Their distinction is intentional: Pi supplies the extension API; the hub supplies authorization and durable receipts.
25
+
26
+ ## Repeat
27
+
28
+ The producer asserts package identity, native version, extension registration, RPC identity, replay effect count, failed-result propagation and zero provider requests. It retains its isolated temporary directory for inspection and prints no personal data.
29
+
30
+ ```sh
31
+ bun docs/verification/pi-140-model-free.ts "$HOME/.pi/agent/bin/pi" "$HOME/.pi/agent/install/releases/1.4.0/node_modules/@earendil-works/pi-coding-agent"
32
+ env -u AGENTHUB_HOME -u AGENTHUB_PROJECT_DIR -u AGENTHUB_STATE_DIR bun test test/pi-compat.test.ts test/compat.test.ts
33
+ env -u AGENTHUB_HOME -u AGENTHUB_PROJECT_DIR -u AGENTHUB_STATE_DIR bun test test/permission-modes.test.ts --test-name-pattern 'Pi mode grants'
34
+ ```
35
+
36
+ Native producer result: `pi 1.4.0 model-free receipt: PASS`. The host compatibility/path suite passed (31 tests), followed by the added native-error impersonation regression (1 test); host Pi mode test: 1 pass, 0 fail.
37
+
38
+ ## Not verified
39
+
40
+ - Account-backed inference, a model-generated tool request, a real provider's quota behavior and interactive TUI coexistence are unverified. They need a separately authorized live leg; this receipt makes no such claim.
41
+ - No other Pi release or compatible semver line is inferred. The COMPAT entry has no `line`; person trust lifts only the exact version refusal and leaves host permissions and receipt checks intact.
@@ -0,0 +1,60 @@
1
+ # Qwen Code 0.26.1 ACP receipt (#843 AC5)
2
+
3
+ Date: 2026-10-26. Refs #843. Installed npm release `@qwen-code/qwen-code` 0.26.1. This repeats the Qwen legs of [the 0.26.0 receipt](2026-10-22-kimi-qwen-compat.md) — native ACP identity, mode ids, permission callbacks, all without a model — on 0.26.1, and says whether the two ACP fixes in the 0.26.1 release notes change the hub contract. Every leg passed, so 0.26.1 is added to the `qwen` entry's `verified` in `src/adapters/compat.ts` in the same change.
4
+
5
+ ## Installed artifact
6
+
7
+ Package root `~/.local/share/fnm/node-versions/v24.18.1/installation/lib/node_modules/@qwen-code/qwen-code`. npm `dist.integrity` `sha512-4kpIzrlrgmawivim0yCZjr1AP5cfHTanyL7eL1g2R8WYU6QDBq7j9fiAye7REXL4EEXkRIq45D7+ngTw5n3GqQ==`, `gitHead` `b475278c4a300acebe6cfc990a9e1b1d18819bfc`. SHA-256 of `cli-entry.js` `113efe4104a9fc66f507d34bfd6abbd896ceb77de47f4b468a0825a327fdc6a5`; SHA-256 of `chunks/acpAgent-6HTF6TM6.js` (the chunk holding the ACP agent and the permission emitters) `1171efba62467d7578a332a0c1bb1a313c336c69efd400d0b860252734a1cf14`.
8
+
9
+ ## Isolation
10
+
11
+ - Every process ran under an isolated `HOME` and cwd (fresh directories under `/tmp/ahub-509-qwen-evidence`), with `--auth-type openai --model fixture-model --openai-base-url http://127.0.0.1:9/v1 --telemetry=false`, so nothing could reach a real account or provider. The real `~/.qwen` was never the home.
12
+ - A preload guard blocked every outbound connection attempt and counted them: the native run made 6 attempts (telemetry/update-check paths), all refused, 0 connected; the permission fixture made none.
13
+ - After each run the process group was verified gone (`groupGone` true after SIGTERM, `leaderExit` -15). No session transcript was written into the real home.
14
+
15
+ ## Leg 1: native ACP identity and mode ids (idle, no model)
16
+
17
+ Spawned `qwen --acp` (the production adapter argv is `["qwen","--acp"]`; the probe added `--bare --advisor off` and the fixture auth args) with 0 prompts sent:
18
+
19
+ - `initialize`: `protocolVersion` 1, `agentInfo` `{ name: "qwen-code", title: "Qwen Code", version: "0.26.1" }` — the identity `qwenNativeIdentity` checks. `authMethods` offers the `openai` terminal method only. `capabilities`: `loadSession`, image and audio and embedded-context prompt content, session list and resume, MCP `sse` and `http`.
20
+ - `session/new` returned session `9c73767d-a12c-4a84-a330-8f49b94c3fd9`, `modes` advertising `default`, `plan`, `auto-edit`, `auto`, `yolo` with `currentModeId` `default`, and `configOptions` for mode and model (the fixture model only). The hub's `QWEN_MODE_IDS` (ask → `default`, ask-when-needed → `auto-edit`, never-ask → `yolo`; `auto` and `plan` stay unmapped) is a subset of what 0.26.1 advertises, unchanged from 0.26.0.
21
+ - The cycle `default` → `auto-edit` → `yolo` → `default` through `session/set_mode` succeeded (ids 3–6 each `result: {}`). Each switch now also arrives as an extra `qwen/notify/session/mode-update` notification (`{ v: 1, sessionId, currentModeId }`, 4 in all, one per switch). The hub ignores unknown notifications, so this changes nothing in the contract; the 0.26.0 receipt recorded the same accompaniment.
22
+
23
+ ## Leg 2: permission callbacks (real installed code, no model)
24
+
25
+ The native permission path was exercised against the real installed chunk, not a reimplementation: a scratch copy of `chunks/acpAgent-6HTF6TM6.js` that only added exports (`SubAgentTracker`, `toPermissionOptions`, `resolvePermissionOutcome`, `ToolCallEmitter`) was written beside the chunk inside the package's `chunks/` directory (relative `./chunk-*.js` imports resolve only there), the real `SubAgentTracker.createApprovalHandler` was driven with synthetic client/tool/event ports, and the scratch copy was removed from the shared package afterwards (verified: none left).
26
+
27
+ - An MCP tool-call request produced a native `session/request_permission` with exactly the option set the hub's `answerPermission` selects from: `proceed_always_project` and `proceed_always_user` (`allow_always`), `proceed_once` named "Allow" (`allow_once`), `cancel` named "Reject" (`reject_once`).
28
+ - The Allow turn responded `proceed_once`; the cancellation turn responded `cancel`; both got the empty result the handler expects. An unoffered option was refused with `Permission response selected unoffered option: not-offered`. That error text changed from 0.26.0's `invalid option` wording; the hub only ever selects an option the request announced, so this is not a contract change.
29
+ - While a request was pending, the parent tool call received `tool_call_update` progress with "Waiting for permission: hub_task_show (agent-hub MCP Server)" — the same turn-pinned progress the hub surfaces.
30
+
31
+ ## The two 0.26.1 ACP fixes
32
+
33
+ The 0.26.1 release notes list exactly two APR-tagged ACP fixes (checked against the GitHub release and the installed chunk):
34
+
35
+ - **#15279 — stop blanking replay App html that cannot reclaim bytes.** This is the MCP App replay HTML path (`blankReplayedMcpAppHtml`). The hub's Qwen adapter never uses replay App html, so this changes nothing for the hub contract.
36
+ - **#15346 — refuse an edit whose arguments were cut off or arrived incomplete.** The native `runTool` now refuses Edit-kind calls whose arguments hit `MAX_TOKENS` or arrived incomplete, before PreToolUse and before the permission request, with codes `output_truncated` / `invalid_tool_params` and `executionStatus: "not_started"` (both code strings verified present in the installed chunk). The hub already refuses to let a cut payload buy a session-wide grant (`answerPermission` marks any raw input over 600 chars `[cut, N chars]` and withholds the allow_always options on it; an explicit allow_once is still answered), so this strengthens the same protection from the native side, arriving to the hub as an ordinary tool failure. It does not change the contract.
37
+
38
+ Verdict: neither fix changes the hub contract, and no leg failed, so the bump is allowed.
39
+
40
+ ## Not verified
41
+
42
+ - A permission turn reached by a real model call, a real login or account, and a real provider — as in every earlier Qwen receipt. Leg 2 drives the real handler through synthetic ports, which proves the callback shapes, not a model's behavior.
43
+ - Linux and Intel macOS builds. The 0.26.0→0.26.1 diff review was the release notes plus the two fix code paths in the installed chunk, not a full source diff.
44
+
45
+ ## Decision
46
+
47
+ All legs passed, so `0.26.1` joins `verified` for `qwen`; the `qwen-dual` entry (a different contract, receipted at [2026-10-25-qwen-dual-output-t0.md](2026-10-25-qwen-dual-output-t0.md)) is unchanged. A failed leg would have meant no bump, as the task requires.
48
+
49
+ Host validation: gate `scripts/check.sh --changed` on the PR head — see the PR.
50
+
51
+ Evidence fingerprints (producers and result JSONs, SHA-256):
52
+
53
+ | Item | SHA-256 |
54
+ | --- | --- |
55
+ | `qwen-acp-probe.py` | `b6e98f172dad31d3d7dedbcbeaf52788f28326073d16c0bb34caa297b095f7eb` |
56
+ | `native-result.json` | `b361b27acda4b85636271491d37e1dcb83ddc363581f129298622c57e4431d06` |
57
+ | `permission-fixture.mjs` | `63566432a965172dfad46fd5453dd554eda24fbafc8bcfea2419ab7890cd65ff` |
58
+ | `permission-result.json` | `97c639a4fb887681e0554a613f4dd8c131ff242d9c3b77e25d28d9e558178493` |
59
+ | `build-scratch-copy.py` | `1c2e7221a734c63183a065e39dd5d9c01f0ba5c95b82a5be03a1e09af2c7e375` |
60
+ | scratch copy `ahub509-native-permission-exports.mjs` | `85102aec216e363c645295225bddcd881424c4b59b5639366abe083c3b577d9a` |
@@ -0,0 +1,86 @@
1
+ # 0.19.0 release verification
2
+
3
+ This record covers release preparation and source review. It claims no installation, no running-hub upgrade to 0.19.0 and no live TUI launch.
4
+
5
+ ## Candidate
6
+
7
+ Version 0.19.0, built from merged main 08590801 (25 commits since v0.18.0, 8 of them `feat`). The release commits carry the version, the CHANGELOG section, the bundle (plugin 0.19.0, built with the pinned Bun 1.4.2 and the worktree's own frozen `node_modules`; against main the bundle differs only in its version string), the document corrections and the stamps. Control protocol stays 16; `@staix/agent-mem` is pinned at 0.10.0.
8
+
9
+ What changed (see `CHANGELOG.md` for the full list):
10
+ - Host-wrapper shell grants for the headless Claude implementer in `ask-when-needed`, with code gates confined away from project Git metadata (#845: #857).
11
+ - Code-only review scope and evidence-only verdicts (#852: #853); optional `model` for the headless Claude implementer (#844: #848).
12
+ - Agent CLI drift: a daily public-source watch and a planner notice for unverified installed versions (#849, #855); a native version gate for Pi (#843: #847); new verified versions for agy, Jcode, kimi-review, headless Qwen and claude-implementer (#850, #851, #856, #858, #859).
13
+ - Planner work-ready notices (#785: #842) and the lockfile-keyed dependency volume for the remote runner (#793: #846).
14
+ - Sandbox protection for submodule git dirs and `config.worktree` (#862, #866), the Qwen background-task notice fix (#861), conductor error preservation (#812: #839).
15
+ - CI: the suite watchdog stops on lost progress (#516: #870) and CI admission retries a transient GitHub 5xx or 429 (#872: #873).
16
+
17
+ ## Reviews
18
+
19
+ Every PR below merged after its hub review, with bot review threads answered and exact-head CI green at merge (the conductor read it). The review counts are the GitHub reviews each PR carries.
20
+
21
+ | PR | Merge | GitHub reviews |
22
+ | --- | --- | --- |
23
+ | #839 (#812) | 050bdf88 | 2 |
24
+ | #840 | 991df88f | 2 |
25
+ | #841 | 92797116 | 2 |
26
+ | #842 (#785) | 01bc6c13 | 2 |
27
+ | #846 (#793) | 94d71ab1 | 4 |
28
+ | #847 (#843) | 6651da46 | 2 |
29
+ | #848 (#844) | 99f3534c | 6 |
30
+ | #849 | bbfd4adc | 5 |
31
+ | #850 (#843) | cbbe23bd | 2 |
32
+ | #851 (#843) | 0431abff | 1 |
33
+ | #853 (#852) | 4f52cbd4 | 4 |
34
+ | #854 (#515) | 661eb9a1 | 2 |
35
+ | #855 | 58525a1b | 5 |
36
+ | #856 (#843) | 21fb4a56 | 2 |
37
+ | #857 (#845) | 1ad9b7c7 | 2 |
38
+ | #858 (#843) | d64e787e | 4 |
39
+ | #859 (#843) | 671e9f90 | 2 |
40
+ | #860 (#843) | a7c815cb | 2 |
41
+ | #861 | 3e08dee2 | 2 |
42
+ | #862 | 3cfefa05 | 4 |
43
+ | #866 (#865) | e02aa141 | 4 |
44
+ | #868 (#867) | 125e82f1 | 2 |
45
+ | #870 (#516) | 8ea5d00b | 4 |
46
+ | #871 | ae22bb1b | 2 |
47
+ | #873 (#872) | 08590801 | 5 |
48
+
49
+ Two merges were bound to a hub approval at an earlier head of the same change: #870 (hub task #516 approved at f8ee3e52, merged at d5e16a3c, a rebase onto main with only CHANGELOG context differing) and #873 (hub task #525 approved at a159cc37, merged at 44344f23 after `gh pr update-branch` merged main into it). Each merge was pinned with `--match-head-commit` to the head whose CI was green. #857's first macOS leg and three #873 macOS runs were killed by the pre-#870 1440 s total budget or hit the `test/path-policy.test.ts` hardlink flake below; none of those failures was in the changed code.
50
+
51
+ ## Document review
52
+
53
+ Fourteen documents were stale against the 0.18.0 stamps (af22ee0d). Each was rechecked against `git log af22ee0d..HEAD` (through #873) over its stamped paths and the source, by four parallel recheck passes (one per document group) and then read by the conductor before committing. Most features had been documented by the PR that introduced them; the corrections are the gaps those PRs left.
54
+
55
+ | Document | Result | Checked claims and correction |
56
+ | --- | --- | --- |
57
+ | `src/cli/help.ts` | no change | 050bdf88 changes error handling only; no command, subcommand or flag was added or removed since the stamp. Flags in `main.ts`, `launch.ts` and the native entries match the help rows both ways; `test/help.test.ts` passes. |
58
+ | `README.md` | corrected | Status 0.19.0, control protocol 16; the CLI version paragraph names the planner drift notice (#855); the verification link points to this report. |
59
+ | `docs/quickstart.md` | corrected | Install pin `v0.19.0`; a new "Upgrading to 0.19.0" section (Pi gate, drift notice, newly verified versions, implementer shell grants and `model`, review scope, planner notices, dependency volume, sandbox, agent-mem 0.10.0). |
60
+ | `docs/operations.md` | corrected | Header and protocol table name 0.19.0. Added: the Pi version gate (#847), Pi and the headless Claude implementer in the gated-CLI list, planner work-ready notices (#842), the Qwen background-task notice (#861), Qwen 0.26.1, Jcode 0.99.1 and kimi-review 2.3.2 receipts. Corrected: the local-worker sandbox said submodule git dirs under `.git/modules/` were not covered; #862 and #866 cover them. |
61
+ | `docs/security.md` | corrected | `ask-when-needed` names the implementer's host-wrapper grants (#857); the local-worker sandbox bullet describes the #862/#866 modules-tree rules and drops the removed refs/logs exemption; agent-mem pin 0.10.0; conductor operations audited as refused only on an authority refusal (#839); `review_scope` in tier 3 (#853); the version policy lists the implementer, the Qwen dual-output TUI and Pi; Qwen 0.26.1, Jcode 0.99.1 and the claude-implementer 2.1.310 receipt. |
62
+ | `docs/agent-notes/adapters.md` | corrected | `instances.modelSelect` in the fixed-builtin list and the `--model` argv rule (#848); `OperationalAuthorityError` only from an authority fence (#839); the Pi version gate (#847); compat `receipt` (#849); agy 1.4.7 (#851). Scope gains the #845 shell-wrapper files. |
63
+ | `docs/agent-notes/local-worker.md` | corrected | `config.worktree` beside `config`, the linked-worktree pattern rules, and the removed refs/logs residual (#862, #866); the host wrapper reuses `profile()` and must change with `sandboxedExec` (#857). |
64
+ | `docs/agent-notes/tests.md` | corrected | Scope gains `scripts/progress-tee.sh` (#870); the CI admission retry bound for a 429 or 5xx (#873); Pi fakes must answer `--version` (#847); daemon tests with a GitHub origin and a headless implementer pass `shellDefaultBranchProbe` (#857). History moved to the notes README. |
65
+ | `docs/agent-notes/tasks.md` | corrected | Scope gains `review-scope.ts`, `sdlc-merge.ts`, `sdlc-mirror.ts`; the planner work-ready notice rule (#842); `Tasks.bindShellBranch` as a board writer (#857). Review-scope history moved to the notes README. |
66
+ | `docs/agent-notes/daemon.md` | corrected | Conductor operational refusals (#839); scope gains `agent-drift.ts` and `cli/agents.ts` (#855); validation of instance `model` and `review_scope` (#848, #853). |
67
+ | `docs/agent-notes/README.md` | corrected | Background for the 0.19.0 rules (#852 classifier history, #843 fixtures, #516 watchdog). |
68
+ | `docs/agent-notes/benchmarks.md` | no change | The benchmark driver builds `PiPeer` directly, so the #847 preflight does not apply; #862/#866 are covered by the existing `profile()` change rule. |
69
+ | `docs/agent-notes/budget.md` | no change | No budget path changed; the headless implementer still uses `budget.account: "claude"`. |
70
+ | `docs/agent-notes/bus.md` | no change | No `bus.ts` change; the new notices use the existing publish and withdraw paths. |
71
+
72
+ Also corrected, outside the stamped set: `AGENTS.md` area-note path lists (the #845, #852, #855 and #870 files), and `docs/memory.md` named agent-mem pin 0.8.0 and now says 0.10.0.
73
+
74
+ ## Checks
75
+
76
+ - `node scripts/check-docs.mjs --require-fresh`: `docs: OK (0 stale documents)` at the stamp commit; every stamp names bc8a7f25, a commit of this branch.
77
+ - `bun x tsc --noEmit` clean; `bun scripts/build.mjs --check` clean (bundle built with the pinned Bun 1.4.2); `node scripts/check-package.mjs`: `package: OK (573 files, @staix/agent-hub@0.19.0)`.
78
+ - `test/help.test.ts`, `test/check-docs.test.ts` and `test/ci-reuse.test.ts`: 132 pass, 0 fail.
79
+ - The full `scripts/check.sh` was not run locally (host load was above the workspace heavy-work threshold); the release PR's exact-head CI on Ubuntu and macOS is the full gate, and the PR names its run.
80
+
81
+ ## Known gaps, not blocking
82
+
83
+ - The recheck was done by recheck passes reading source and commits, then the conductor; the reviewer of this PR is the independent check on it.
84
+ - No running hub has been upgraded to 0.19.0 here. The implementer shell grants, the drift notice and the Pi gate are source and fixture tested; their live receipts follow the upgrade.
85
+ - Flaky tests seen while preparing this release: `test/path-policy.test.ts` "read-only managed writes ... refuse hardlink aliases (#259)" on hosted macOS (realpath of a hardlink returned the other name), and `test/memory-code-head-684.test.ts` "#367 cached HEAD proof" (hub fail note d13a75e3).
86
+ - Follow-up candidate: the hub's `shell branch bound` history entry (#857) is not in `QUIET_EVENTS` in `src/hub/tasks.ts`, so it may count as a non-quiet entry in checks that read history.
@@ -0,0 +1,187 @@
1
+ # Model-free native receipt of the headless Claude permission transport (#843 AC5, task #507).
2
+ # Repeats the case table of 2026-10-23-headless-claude-permissions.md on each installed binary given.
3
+ # Usage: python3 claude-permission-transport-native.py OUTPUT --claude /absolute/claude [--claude ...]
4
+ # No account, no provider, no model: a loopback server answers with a fixed Write tool call, then a completion.
5
+ import argparse, hashlib, http.server, json, os, pathlib, signal, subprocess, threading, time
6
+
7
+ parser = argparse.ArgumentParser()
8
+ parser.add_argument('output')
9
+ parser.add_argument('--claude', action='append', required=True)
10
+ args = parser.parse_args()
11
+ ROOT = pathlib.Path(args.output).resolve()
12
+ ROOT.mkdir(mode=0o700, parents=False, exist_ok=False)
13
+
14
+ # Fixture stdio MCP server. It records the call first, then behaves as the case says.
15
+ MCP = r'''import sys, json, os, time
16
+ case = os.environ['CASE']
17
+ for line in sys.stdin:
18
+ try: q = json.loads(line)
19
+ except Exception: continue
20
+ method, rid = q.get('method'), q.get('id')
21
+ if rid is None: continue
22
+ if method == 'initialize':
23
+ r = {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'fixture', 'version': '1'}}
24
+ elif method == 'tools/list':
25
+ tools = [] if case == 'missing-tool' else [{'name': 'approve', 'description': 'Person approval fixture', 'inputSchema': {'type': 'object', 'properties': {'tool_name': {'type': 'string'}, 'input': {'type': 'object'}, 'tool_use_id': {'type': 'string'}}, 'required': ['tool_name', 'input']}}]
26
+ r = {'tools': tools}
27
+ elif method == 'tools/call':
28
+ args = q.get('params', {}).get('arguments', {})
29
+ with open(os.environ['RECEIPT'], 'a') as f: print(json.dumps({'arguments': args}), file=f)
30
+ if case == 'pending': time.sleep(120)
31
+ if case == 'disconnect': sys.exit(1)
32
+ if case == 'mcp-error':
33
+ print(json.dumps({'jsonrpc': '2.0', 'id': rid, 'error': {'code': -32000, 'message': 'fixture hub unavailable'}}), flush=True); continue
34
+ if case == 'deadline': time.sleep(.1)
35
+ if case in ('deny', 'hook-deny', 'deadline'):
36
+ value = {'behavior': 'deny', 'message': 'no person answered' if case == 'deadline' else 'fixture denied'}
37
+ else:
38
+ value = {'behavior': 'allow', 'updatedInput': args.get('input', {})}
39
+ r = {'content': [{'type': 'text', 'text': json.dumps(value)}]}
40
+ else:
41
+ r = {}
42
+ print(json.dumps({'jsonrpc': '2.0', 'id': rid, 'result': r}), flush=True)
43
+ '''
44
+ (ROOT / 'mcp.py').write_text(MCP)
45
+
46
+
47
+ class Handler(http.server.BaseHTTPRequestHandler):
48
+ def log_message(self, *a): pass
49
+
50
+ def do_POST(self):
51
+ q = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
52
+ state = self.server.state
53
+ state['requests'] += 1
54
+ if 'count_tokens' in self.path:
55
+ self.send_response(200); self.end_headers(); self.wfile.write(json.dumps({'input_tokens': 100}).encode()); return
56
+ if state.get('tools_file') and not os.path.exists(state['tools_file']):
57
+ with open(state['tools_file'], 'w') as f: json.dump(q.get('tools', []), f)
58
+ msgs = q.get('messages', [])
59
+ results = sum(1 for m in msgs if isinstance(m.get('content'), list) for x in m['content'] if isinstance(x, dict) and x.get('type') == 'tool_result')
60
+ done = results >= 1
61
+ content = [{'type': 'text', 'text': 'Fixture finished.'}] if done else [{'type': 'tool_use', 'id': 'toolu_probe_0', 'name': 'Write', 'input': {'file_path': state['target'], 'content': 'probe'}}]
62
+ result = {'id': 'msg_probe_%d' % results, 'type': 'message', 'role': 'assistant', 'model': 'claude-sonnet-4-5', 'content': content, 'stop_reason': 'end_turn' if done else 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 100, 'output_tokens': 10}}
63
+ if not q.get('stream'):
64
+ self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(json.dumps(result).encode()); return
65
+ self.send_response(200); self.send_header('Content-Type', 'text/event-stream'); self.end_headers()
66
+ start = dict(result); start.update(content=[], stop_reason=None, usage={'input_tokens': 100, 'output_tokens': 0})
67
+ events = [('message_start', {'type': 'message_start', 'message': start})]
68
+ for i, c in enumerate(content):
69
+ block = dict(c)
70
+ if c['type'] == 'tool_use': block['input'] = {}
71
+ else: block['text'] = ''
72
+ delta = {'type': 'input_json_delta', 'partial_json': json.dumps(c['input'])} if c['type'] == 'tool_use' else {'type': 'text_delta', 'text': c['text']}
73
+ events.extend([('content_block_start', {'type': 'content_block_start', 'index': i, 'content_block': block}), ('content_block_delta', {'type': 'content_block_delta', 'index': i, 'delta': delta}), ('content_block_stop', {'type': 'content_block_stop', 'index': i})])
74
+ events.extend([('message_delta', {'type': 'message_delta', 'delta': {'stop_reason': result['stop_reason'], 'stop_sequence': None}, 'usage': {'output_tokens': 10}}), ('message_stop', {'type': 'message_stop'})])
75
+ try:
76
+ for e, v in events:
77
+ self.wfile.write(('event: ' + e + '\ndata: ' + json.dumps(v) + '\n\n').encode()); self.wfile.flush()
78
+ except (BrokenPipeError, ConnectionResetError):
79
+ pass
80
+
81
+
82
+ def sha(file):
83
+ digest = hashlib.sha256()
84
+ with file.open('rb') as stream:
85
+ for block in iter(lambda: stream.read(1024 * 1024), b''):
86
+ digest.update(block)
87
+ return digest.hexdigest()
88
+
89
+
90
+ def empty_group(pid):
91
+ try:
92
+ table = subprocess.run(['/bin/ps', '-axo', 'pgid=,stat='], env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C', 'TZ': 'UTC'}, capture_output=True, text=True, timeout=3)
93
+ except (OSError, subprocess.TimeoutExpired):
94
+ return False
95
+ if table.returncode or len(table.stdout) > 1024 * 1024: return False
96
+ rows = [line.split() for line in table.stdout.splitlines() if line.strip()]
97
+ return bool(rows) and all(len(r) == 2 for r in rows) and not any(r[0] == str(pid) and not r[1].startswith('Z') for r in rows)
98
+
99
+
100
+ def rows(file):
101
+ return [json.loads(line) for line in file.read_text().splitlines() if line] if file.exists() else []
102
+
103
+
104
+ HOOK_FAIL = {'hooks': {'PreToolUse': [{'matcher': 'Write', 'hooks': [{'type': 'command', 'command': 'exit 1'}]}]}}
105
+ # case -> (settings, expected native exit, mcp calls, file written, tool_result is_error, external stop expected)
106
+ CASES = {
107
+ 'allow': ({}, 0, 1, True, False, False),
108
+ 'deny': ({}, 0, 1, False, True, False),
109
+ 'missing-tool': ({}, 1, 0, False, None, False),
110
+ 'hook-deny': (HOOK_FAIL, 0, 1, False, True, False),
111
+ 'hook-allow': (HOOK_FAIL, 0, 1, True, False, False),
112
+ 'mcp-error': ({}, 0, 1, False, True, False),
113
+ 'disconnect': ({}, 0, 1, False, True, False),
114
+ 'pending': ({}, None, 1, False, None, True),
115
+ 'deadline': ({}, 0, 1, False, True, False),
116
+ }
117
+
118
+ results = []
119
+ for index, raw in enumerate(args.claude):
120
+ binary = pathlib.Path(raw).resolve()
121
+ digest = sha(binary)
122
+ # The index keeps two binaries with the same basename or the same version in separate directories.
123
+ ident = ROOT / ('identity-%d-%s' % (index, binary.name)); ident.mkdir()
124
+ v = subprocess.run([str(binary), '--version'], env={'HOME': str(ident), 'CLAUDE_CONFIG_DIR': str(ident / 'config'), 'PATH': '/usr/bin:/bin'}, capture_output=True, text=True, timeout=10)
125
+ assert v.returncode == 0, v
126
+ version = v.stdout.strip().split()[0]
127
+ for case, (settings, want_exit, want_calls, want_file, want_error, want_stop) in CASES.items():
128
+ d = ROOT / ('%d-%s-%s' % (index, version, case)); d.mkdir()
129
+ home = d / 'home'; home.mkdir(); work = d / 'work'; work.mkdir(); work = work.resolve()
130
+ target = work / 'probe.txt'
131
+ out = {'version': version, 'binarySha256': digest, 'case': case}
132
+ srv = native = None
133
+ try:
134
+ srv = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
135
+ srv.state = {'requests': 0, 'target': str(target), 'tools_file': str(d / 'tools.json')}
136
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
137
+ (d / 'mcp.json').write_text(json.dumps({'mcpServers': {'fixture': {'command': '/usr/bin/python3', 'args': [str(ROOT / 'mcp.py')], 'env': {'CASE': case, 'RECEIPT': str(d / 'mcp.jsonl')}}}}))
138
+ (d / 'settings.json').write_text(json.dumps(settings))
139
+ env = {'HOME': str(home), 'CLAUDE_CONFIG_DIR': str(home / 'config'), 'PATH': '/usr/bin:/bin', 'ANTHROPIC_BASE_URL': 'http://127.0.0.1:%d' % srv.server_port, 'ANTHROPIC_API_KEY': 'synthetic-probe-not-a-credential', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC': '1', 'DISABLE_AUTOUPDATER': '1'}
140
+ argv = [str(binary), '-p', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', str(d / 'mcp.json'), '--setting-sources', '', '--settings', str(d / 'settings.json'), '--permission-mode', 'default', '--permission-prompts', 'host', '--permission-prompt-tool', 'mcp__fixture__approve', '--model', 'claude-sonnet-4-5', 'Run the fixture write.']
141
+ started = time.monotonic()
142
+ native = subprocess.Popen(argv, cwd=work, env=env, stdout=open(d / 'stdout', 'w'), stderr=open(d / 'stderr', 'w'), start_new_session=True)
143
+ stopped_externally = False
144
+ try:
145
+ native.wait(timeout=25)
146
+ except subprocess.TimeoutExpired:
147
+ stopped_externally = True
148
+ os.killpg(native.pid, signal.SIGKILL); native.wait(timeout=5)
149
+ elapsed = round(time.monotonic() - started, 3)
150
+ calls = rows(d / 'mcp.jsonl')
151
+ frames = rows(d / 'stdout')
152
+ tool_results = [b for f in frames if f.get('type') == 'user' for b in f.get('message', {}).get('content', []) if isinstance(b, dict) and b.get('type') == 'tool_result']
153
+ final = [f for f in frames if f.get('type') == 'result']
154
+ written = target.exists() and target.read_text() == 'probe'
155
+ out.update(nativeExit=None if stopped_externally else native.returncode, stoppedExternally=stopped_externally, mcpCalls=len(calls), fileWritten=written, toolResultErrors=[b.get('is_error') for b in tool_results], resultFrame=[{'is_error': f.get('is_error'), 'denials': len(f.get('permission_denials', []))} for f in final], elapsedSeconds=elapsed, requests=srv.state['requests'])
156
+ assert stopped_externally == want_stop, ('external stop', stopped_externally)
157
+ if want_exit is not None: assert native.returncode == want_exit, ('exit', native.returncode, (d / 'stderr').read_text()[-400:])
158
+ assert len(calls) == want_calls, ('mcp calls', calls)
159
+ if want_calls:
160
+ call = calls[0]['arguments']
161
+ assert call['tool_name'] == 'Write' and call['input'] == {'file_path': str(target), 'content': 'probe'} and call['tool_use_id'] == 'toolu_probe_0', call
162
+ assert written == want_file, ('file written', written)
163
+ # A successful native tool_result carries no is_error key at all; only a failed one sets it to true.
164
+ if want_error is not None: assert [b.get('is_error') is True for b in tool_results] == [want_error], tool_results
165
+ if case in ('deny', 'hook-deny', 'deadline'): assert final and final[0].get('permission_denials') and len(final[0]['permission_denials']) == 1, final
166
+ out['passed'] = True
167
+ except Exception as error:
168
+ out.update(passed=False, error=repr(error))
169
+ finally:
170
+ if native is not None:
171
+ if native.poll() is None:
172
+ try: os.killpg(native.pid, signal.SIGKILL)
173
+ except ProcessLookupError: pass
174
+ native.wait(timeout=5)
175
+ end = time.monotonic() + 1
176
+ while not empty_group(native.pid) and time.monotonic() < end: time.sleep(.02)
177
+ out['nativeGroupEmpty'] = empty_group(native.pid)
178
+ if not out['nativeGroupEmpty']: out.update(passed=False, cleanup='native group not established empty')
179
+ if srv: srv.shutdown()
180
+ out['binaryUnchanged'] = sha(binary) == digest
181
+ if not out['binaryUnchanged']: out.update(passed=False, error='native binary changed during probe')
182
+ results.append(out)
183
+ print(json.dumps(out), flush=True)
184
+ receipt = {'cases': results, 'certified': all(r['passed'] for r in results), 'account': False, 'inference': False}
185
+ (ROOT / 'receipt.json').write_text(json.dumps(receipt, indent=2))
186
+ print(json.dumps({'receipt': str(ROOT / 'receipt.json'), 'certified': receipt['certified']}), flush=True)
187
+ raise SystemExit(0 if receipt['certified'] else 1)
@@ -0,0 +1,20 @@
1
+ // Model-free native receipt fixture. Run by claude-shell-wrapper-native.py, never by production startup.
2
+ import { readFileSync } from "node:fs";
3
+ import { createHostShellWrapper } from "../../src/hub/claude-shell-wrapper.ts";
4
+ import { hostShellEnvironment } from "../../src/local/shell-environment.ts";
5
+ import { realPath } from "../../src/hub/project.ts";
6
+
7
+ const fixture = JSON.parse(readFileSync(process.argv[2]!, "utf8")) as { root: string; executable: string };
8
+ const root = realPath(fixture.root), executable = realPath(fixture.executable), signal = new AbortController();
9
+ const wrapper = await createHostShellWrapper();
10
+ const command = wrapper.issue({ peer: "claude-fixture", task: 1, generation: "native-fixture:1", root, branch: "fixture/task", executable, argv: [],
11
+ env: hostShellEnvironment({ AGENTHUB_PEER_ID: "claude-fixture" }), signal: signal.signal, timeoutMs: 5000,
12
+ current: () => !signal.signal.aborted && realPath(fixture.root) === root && realPath(fixture.executable) === executable });
13
+ if (!command) throw new Error("host receipt fixture refused");
14
+ process.stdout.write(`${JSON.stringify(command)}\n`);
15
+ let closing: Promise<void> | undefined;
16
+ const close = () => closing ??= (async () => { signal.abort(); await wrapper.close(); })();
17
+ process.on("SIGTERM", () => { void close().then(() => process.exit(0), () => process.exit(1)); });
18
+ process.on("SIGINT", () => { void close().then(() => process.exit(0), () => process.exit(1)); });
19
+ for await (const _chunk of process.stdin) { /* Parent ownership pipe, not command input. */ }
20
+ await close();