diff --git a/beehive/CHECKPOINT.md b/beehive/CHECKPOINT.md index ae7fd28c04..3c95651bf9 100644 --- a/beehive/CHECKPOINT.md +++ b/beehive/CHECKPOINT.md @@ -1,134 +1,146 @@ # Beehive executable checkpoint Worktree `/Users/loganj/.buzz/REPOS/beehive-cbfd9440`, branch `beehive/cbfd9440`. -Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; this continuation starts at -`c4f30176765bdedd075e9634f33e4d4576ce3619`. Candidate is the commit containing +Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; continuation starts at +`023c9274767ef50fa0f5b37ef1883336f8be59fd`. Candidate is the commit containing this checkpoint (`git rev-parse HEAD`). No push/PR/merge/release. -## Implemented: actual signed reply in the isolated installed-binary path +## Implemented: ordinary multi-conversation Buzz CLI tool authority -Host -> existing installed buzz-acp -> escaping childless ACP shim -> separately -host-owned ACP fixture harness -> escaping childless MCP shim -> host-resident -TypeScript MCP adapter -> separately host-owned **actual installed Buzz CLI**. -The CLI, not Beehive/test publisher code, resolves members/thread and signs/publishes -an actual reply. The isolated relay verifies canonical event hash, Schnorr signature, -persistent fixture agent pubkey, channel, parent and sole explicit owner recipient. -This is legacy kind-9 fixture evidence, NOT current production kind-40002 compatibility, -real community admission, live Databricks/provider evidence or full product completion. +Host -> installed buzz-acp -> childless ACP shim -> separately host-owned ACP +harness -> childless MCP shim -> host-resident TypeScript MCP adapter -> separately +host-owned installed Buzz CLI. No second chat/agent runtime; no Rust/client edits. -`conversation-setup` optionally provisions a canonical installed Buzz CLI executable -and a **fixed single-thread channel/parent/owner-recipient grant**. Existing identity, -provider context and assignment are preserved. No remote executable/env/key/scope -policy is accepted. The local configuration is frozen at launch; the ACP caller must -still supply EMPTY MCP provisioning. The broker substitutes only its own fixed -stdio MCP descriptor, and rejects foreign workspace/executable/env/MCP requests. +`conversation-setup` optionally provisions only a canonical installed Buzz CLI +executable. `buzz({argv, stdin})` exposes its native command contract to the model, +including native help. No local channel/parent/recipient grants and no prompt +scraping in the host. One persistent agent can answer different conversations +without another host-admin step. Old single-thread setup objects are explicitly +rejected with reprovisioning guidance, never silently widened. Executable path/hash, +agent signer, relay and optional attestation remain host-owned snapshots. Caller +executable/env/shell/MCP provisioning and identity/relay/attestation flag overrides +are rejected. Normal CLI file operations have local service-process authority; +this adapter is not an OS filesystem sandbox or a shell tool. -`src/reply-tool.ts` exposes only `buzz_reply({content})`, during a model-confirmed -active prompt. No shell, arbitrary CLI argv, file upload or caller-selected destination. -CLI invocation is fixed `messages send --channel ... --reply-to ... --mention ... ---content -`; content goes through stdin. Mention syntax is conservatively rejected -so implicit CLI mention resolution cannot widen the sole provisioned recipient. -Host-local signer/relay/optional attestation override all context. HOME is isolated; -no owner cache/profile or ambient env inheritance. Raw CLI output is bounded and -withheld from management/tool diagnostics. MCP is newline stdio only, not HTTP/SSE. +### Short source-driven comparison / authority choice -**Scope limitation is intentional and explicit:** the tool does not discover dynamic -per-turn authority. It only replies into the locally provisioned thread, with an -additional fail-closed check that the upstream prompt contains that channel and -`--reply-to` instruction. Text parsing cannot enlarge the local grant and is not -signed-event provenance. General automatic multi-thread routing still needs a real -request-local scope interface; do not market this one-thread provisioning UX as -Desktop parity. A prompt for another thread fails rather than silently replying there. +- Desktop `managed_agents/runtime.rs:290` selects canonical MCP; `:574` injects + the agent private key and relay; `:708` injects optional owner attestation. +- `buzz-acp/src/lib.rs:5894` builds MCP with that agent signer/relay/attestation. + `pool.rs:1732` adds git hints, not authoritative per-turn permission metadata. +- `buzz-dev-mcp/src/lib.rs:168` delegates its buzz personality to the existing CLI. +- `buzz-acp/src/queue.rs:1380–1407` renders ordinary reply guidance and explicitly + permits requested channel-root posts. It does NOT mint a per-turn scope grant. +- `buzz-cli/src/commands/messages.rs:611–680` owns explicit/implicit mention + resolution, member checks, immediate-parent/root lookup and publication. +- `buzz-cli/src/lib.rs:78–106` identifies relay/private-key/auth-tag override + flags; the adapter rejects these while preserving normal command arguments. -## Source-grounded ownership and teardown +Choice: reuse the CLI/runtime as destination/thread owner and relay membership / +agent authentication / optional attestation as permission authority, not a second +controller. Owner-only inbound dispatch remains unchanged. Outgoing mentions need +not be restricted to the owner when other channel participants are authorized. +No permissions are manufactured from untrusted model prompt prose. The fixture +model interprets upstream instructions only to choose tool arguments, as a model +would; that is not a host authorization decision. -Bounded read at base source: -- `buzz-acp/src/lib.rs:5894` build_mcp_servers derives stdio executable and signer/relay/ - attestation env; `pool.rs:1732` adds git-origin hints, not authoritative reply scope. -- `buzz-acp/src/queue.rs:1383` and following render ordinary reply instructions into - prompts; the existing CLI owns actual member/thread resolution and signed publication - (`buzz-cli/src/commands/messages.rs:611` and following). -- **Two deliberate process-group escapes** preclude simply enabling dev MCP: - `buzz-agent/src/mcp.rs:758` detaches its MCP subprocess, and - `buzz-dev-mcp/src/shell.rs:677` detaches tool shell processes. Directly forwarding - dev-mcp would lose host ownership of shell groups on abrupt parent death. +## Process ownership and model evidence preserved -Only the existing childless stdio shim can escape here. MCP adapter lives in the -host; every actual CLI invocation uses `spawnOwned`, with retained live group anchor. -Supervisor now forwards only numeric runner exit code so CLI failure is not confused -with successful publication. TERM-resistant in-group descendants are killed through -the living anchor; PID observations never grant kill authority. Stop, cancellation, -failed startup, CLI failure and MCP disconnect retain/await teardown promises. -Broker failure starts tool cleanup immediately and final Stop checks the same promise. -Connections, IDs, frames, calls, stdout/stderr bytes and deadlines are bounded. -Host IPC loss continues to use the existing anchored group cleanup. Arbitrary trusted -executables that deliberately escape groups remain outside this POSIX containment claim. +Upstream `buzz-agent/src/mcp.rs:758` and `buzz-dev-mcp/src/shell.rs:677` deliberately +detach process groups. Only childless shims may escape here; actual harness and +CLI launches use separately retained `spawnOwned` anchors. Stop/cancel/disconnect/ +failed startup retain and await teardown, including TERM-resistant descendants. +No recovered numeric PID confers kill authority. Resource limits bound sockets, +requests, frames, calls, output and deadlines. Current per-connection request and +per-run shim/session caps still require future long-running recovery work. -## Review reconciled: B1 optional-setting rejection +Tool stdout is bounded UTF-8 and returned after anchored cleanup/drain, so reads +are useful. Stderr is withheld. A normal CLI nonzero exit becomes MCP `isError`, +not fatal conversation teardown; model can correct a failed member/argument request. +Containment/protocol errors still fail closed. Exact-model same-session acknowledgement, +model-before-prompt, malformed-tail fences and actual completed response proof remain. -Consumed independent `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/BROKER_REVIEW_C4F30176.md` -(pinned c4f30176; independent strict + 11/11 and installed replay). It found one -medium B1: optional non-model config rejection cleared model eligibility permanently. -Upstream optional effort/permission rejection intentionally falls back rather than -aborting (`pool.rs:1880–1930`, `1973–2035`). Broker now conservatively re-acknowledges -the exact session/model BEFORE forwarding the original optional-setting error. -It does not assume the error had no side effects. Rejected model-setting changes -remain fail-closed. Production broker tests cover accepted optional setting, -rejected optional setting then successful prompt, failed re-ack and rejected model -config sibling. Existing wrong-model/conflict/cancellation/malformed-tail guards remain. -New MCP code and this B1 fix have NOT received independent delta review yet. +B1 from independent `BROKER_REVIEW_C4F30176.md` was fixed in the predecessor: +optional config rejection is followed by exact-model re-ack BEFORE forwarding the +original error. Existing accepted/rejected optional config, failed re-ack and +model-changing rejection regressions remain green. Consumed independent `TOOL_REVIEW_023C9274.md` (workspace +`WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/`): no new high-confidence blocking defect, +B1 resolved through original/extended independent probes; strict/full12/12 and +independently verified installed signed publication. Its retained invariants are +host executable/env/key/relay ownership, childless shims, exact-model fencing, +distinct tool/ACP/publication evidence and bounded resources. This ordinary-CLI +delta has NOT yet received independent review. Long-lived request/session cap +recovery remains explicit subsequent work, not general MCP compatibility. -## Actual executable evidence +## Observable installed isolated outcome -Installed `/Applications/Buzz.app/Contents/MacOS/buzz-acp` SHA256: -`10612d0025d1420bdd9e9afcc2e7129377da2414e75049a8b640e81d857441ea`. -Installed `/Applications/Buzz.app/Contents/MacOS/buzz` SHA256: -`147cc2ccf276ddedc5b84d13f5399a95282066303c9dca566bb2af5a37b856c5`. -Binary digests are independent of the source base; neither binary is rebuilt/modified. +Actual installed binaries (read-only, freshly rehashed): +- `/Applications/Buzz.app/Contents/MacOS/buzz-acp` SHA256 + `10612d0025d1420bdd9e9afcc2e7129377da2414e75049a8b640e81d857441ea` +- `/Applications/Buzz.app/Contents/MacOS/buzz` SHA256 + `147cc2ccf276ddedc5b84d13f5399a95282066303c9dca566bb2af5a37b856c5` -One isolated run verified NIP-42, two subscriptions, exact-model same-session ACP -completion, and signed threaded publication: -- reply `47f814433a621434fc76c8d282f46e3d4c2311081823def7d4aabde281a9dfff` -- fixture agent `3daf86eecb494ae8db349ea57ca2551c55c02eb3b2b923d54bd653000dbf5522` -- parent `d5e3633b90efcc7d76f66a1e1f0079c6782c1287e4c7b9aae640414449f3b9be` -- channel `aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee` +`installed-conversation.test.ts` provisions fresh fixture credentials once, starts +one installed runtime and uses two authorized channels/threads, then a later owner +prompt in the first thread. All three actual CLI-published replies have canonical +hashes, valid Schnorr signatures, the SAME provisioned agent pubkey, expected +channel/parent, and explicit non-owner member recipient. Later reply references the later owner event, which itself references the original +root. The installed legacy CLI emits an immediate-parent `e` reply tag only; it +does not duplicate the root tag (unlike the current source contract). Three native `channels list` reads return useful channel data. +Non-member mentions fail without publication; a member who is not the configured +owner cannot trigger a reply. Harness model/session checks occur before every +prompt. The broker remains healthy after the later turns; owned Stop removes +harnesses, shims and resistant descendants. Test code never manufactures the agent +reply. No reconfiguration or new agent key between prompts. -Fixture keys/events change each run and the loopback relay is removed afterward. -This is reproducible test evidence, not an event on the production relay. Harness, -MCP shim and resistant descendant PID files are checked absent after Stop. Tests -never manufacture the expected agent reply; the fixture merely calls the real tool, -as an LLM would. No provider, OAuth or real-owner credential access occurs. +One full-suite replay observed agent +`9aa6385895b9b03438af14e5ede3f947c0e3d71e2eb378b923dd571aec770a26` +and replies `591297eb4f81d62656c896e7ee4e2b391d5cf971e3790cb6675533113e7ca746`, +`8de7f8c4b4d15f6edbff7a7ad24cf9f78eb4ac70997bea118435cf197b25fef5`, +`81f42e7f3a5ed59c151d5152159b30e729eb72cad64b4dd502864ae91680bb41`. +These are disposable loopback fixture events, not production relay links. -## Validation / next action +Fixture model remains a fixture. This is installed runtime/CLI execution with +legacy kind-9 NIP-42/NIP-29 fixture evidence, NOT current production kind-40002, +real admission, provider/OAuth or Databricks evidence. Explicit hex mentions are +validated; unresolved implicit display-name mentions are NOT claimed to work. -Hermit Node24.15.0 / pnpm11.4.0. Fresh node_modules and fresh store frozen install -fetched all seven locked packages from the approved mirror. No new dependencies, -borrowed modules, global config changes or lockfile delta. +## Validation + +Hermit Node24.15.0 / pnpm11.4.0; standalone registry-agnostic lock unchanged. +Predecessor fresh-store frozen install fetched all seven locked packages from the +approved mirror. No dependencies, global config changes or borrowed modules added. ``` . ./bin/activate-hermit cd beehive -pnpm install --ignore-workspace --frozen-lockfile --ignore-scripts \ - --registry https://global.block-artifacts.com/artifactory/api/npm/square-npm/ npm run check BEEHIVE_REAL_BUZZ_ACP=/Applications/Buzz.app/Contents/MacOS/buzz-acp npm test ``` -Full package suite with installed opt-in: **12/12 pass** (including additional -B1/tool authority/cleanup cases within top-level tests); strict TS and diff check -pass. Exact candidate is re-run before commit. No repo-wide just ci, production -operations, owner OAuth, live provider or new independent MCP review. +Strict TS, complete installed-enabled package suite **12/12**, and diff check pass; +final candidate rerun before commit. Self-review added legacy-scope opt-in migration, +UTF-8/drain correctness and explicit relay/attestation/env override negatives. No +repo-wide `just ci`, production operations, owner credential/profile reads or live +provider calls. Configured Logan Johnson author/committer and DCO retained; no +cryptographic signing key configured, so no invented signer/signature claim. -**ONE next executable step:** independently review/replay this pinned fixed-reply -MCP delta and B1 regression, including the installed isolated signed-reply test, -before expanding to automatically provisioned per-turn scopes. +## Remaining product scope / ONE next executable step -Still open: actual community admission/attestation and named-service-user normal -Databricks v2 OAuth/live model proof; current Buzz protocol compatibility; catalog -auth ambiguity; general per-turn reply scope (not prompt parsing); reconnect/ACKs, -durable TUI intents and crash recovery; multiple hosts/agents/setups; profiles; -key import/revocation; Restart/Move; remaining Desktop harnesses/presets/providers/ -relay-mesh/compute. S1 two-host identity unproven. Selected-next != actual-run; -stopped identity remains assigned. No remote secrets, credential/workspace/session -transfer or unreachable-host takeover. Hash/spawn TOCTOU, hostile journals, -rollback/clones, outbox pruning and stronger OS containment remain outside evidence. +**Next:** implement durable relay management ACK/reconnect replay with a dropped- +connection test proving saved intent converges exactly once after host/controller +reconnect, rather than requiring users to resubmit uncertain lifecycle operations. +Independent delta review can proceed in parallel against this pinned artifact. + +README retains the full parity matrix: reconnect/ACKs/crash recovery; multiple +hosts/agents/setups and S1 two-host identity; reusable profiles/metadata/history; +key import/revocation; Restart/Move; other Desktop harness/provider/preset/relay- +mesh/compute support and final independent real UI review. None is excluded. +Production admission/protocol and named-service-user normal Databricks v2 OAuth / +live model remain unproven. The later live gate requires exact named-service-user +local auth and necessary community admission, not replacement of missing engineering. +Dedicated management TS relay remains explicit; no hidden privileged lifecycle API. +Selected-next != actual-run; stopped identity remains assigned. No remote secrets, +credential/workspace/session transfer or unreachable-host takeover. Hash/spawn +TOCTOU, hostile journals, rollback/clones, outbox pruning and stronger OS containment +remain outside evidence. diff --git a/beehive/README.md b/beehive/README.md index 2201771dce..67d188102c 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -158,7 +158,7 @@ seam remains missing in the external protocol; do not relabel it authenticated. An empty/filtered catalog does not prevent trying an explicitly saved exact model; only same-session acknowledgement plus completed response establishes probe evidence. -## External conversation setup (fixed-thread reply tool; broader delivery incomplete) +## External conversation setup (ordinary Buzz CLI tools) `node src/cli.ts conversation-setup /absolute/existing-host-directory` attaches an installed **buzz-acp** and separate conversation relay to the SAME existing @@ -181,19 +181,29 @@ isolated NIP-42/NIP-29 relay fixture, not a real community or provider. That bin subscribes to legacy kind 9; this is not current Buzz relay compatibility evidence. **An actual signed threaded reply is now observed** through the installed Buzz CLI in that isolated test, with a deterministic ACP harness (not a live model). Local -`conversation-setup` can optionally provision a canonical Buzz CLI path and one -fixed channel/parent/owner-recipient scope. The host supplies a narrow stdio MCP -`buzz_reply({content})` tool during confirmed prompts. It invokes the existing CLI, -which owns signing, membership and thread semantics. MCP/CLI argv and destinations -cannot come from relay/ACP callers; foreign provisioning is still rejected. +`conversation-setup` can optionally provision a canonical Buzz CLI executable. +The fixed stdio MCP `buzz({argv, stdin})` tool passes native arguments to that CLI +under the provisioned agent identity. Use its `--help` to discover operations. +The existing CLI owns member/mention/thread semantics; no local channel, parent, +or recipient setup is required when another owner conversation arrives. +Explicit hex `--mention` supports authorized non-owner channel members too. +Implicit display-name mention behavior remains the installed CLI's contract and +has not been validated here. -This is deliberately **single-thread local provisioning**, not automatic per-turn -routing or final UX parity. Another thread fails closed. Mention syntax in content -is currently unsupported (the host supplies the sole recipient). There is no shell, -file upload, arbitrary CLI or HTTP/SSE MCP support. No server is launched by an -escaping subprocess: the MCP shim stays childless and each CLI group has its own -host-owned anchor. See CHECKPOINT.md for source trace, cleanup and signed evidence. -The new tool surface still needs independent review. +The host rejects caller executable/env/MCP provisioning and relay/signer/attestation +flag overrides. There is no shell parser or HTTP/SSE MCP support. Native CLI +operations (including file operations) have local service-process authority, not +an OS sandbox. Old single-thread tool configurations require explicit rerunning of +`conversation-setup`; they are not silently broadened. MCP shims remain childless +and every actual CLI group retains its own host anchor through cleanup. Bounded +stdout reaches the model for reads; stderr stays withheld. Ordinary CLI errors are +tool errors, not a reason to destroy a healthy conversation. + +Installed isolated validation covers two channels/threads and a later prompt in +the first thread, three signed replies under one agent identity, native channel +reads, non-member mention rejection and owner-only incoming dispatch. No local +reconfiguration or key generation occurs between prompts. This new surface still +needs independent delta review; see CHECKPOINT.md for exact evidence and limits. Community admission/attestation and normal OAuth in the named host-service context remain explicit local/operator actions. No live Databricks request has occurred. @@ -206,7 +216,7 @@ remain explicit local/operator actions. No live Databricks request has occurred. | Host inventory | Real relay, fixed authority, freshness | multiple setups/agents, service install, reconnect/reconciliation | | Remote configuration | CAS model/workspace/default profile selection | reusable profiles, metadata, config history, setup revision pinning | | Lifecycle | fixture Start/Stop, UI-independent host | strict real launch, Restart, host-owned irreversible Move | -| Buzz Agent Databricks v2 | Local setup + TypeScript ACP probe boundary | general per-turn reply scope, live OAuth/model proof and catalog provenance | +| Buzz Agent Databricks v2 | Local setup + TypeScript ACP probe boundary | live OAuth/model proof, production protocol compatibility and catalog provenance | | Other Buzz Agent providers | Not implemented | Anthropic, OpenAI-compatible, Databricks legacy, OpenRouter | | Goose / Claude Code / Codex | Not implemented | harness-specific local auth and adapters/catalog/launch | | Ten presets / custom ACP | Not implemented | explicit executable/env trust, preset-specific capability grounding | diff --git a/beehive/TESTING.md b/beehive/TESTING.md index bff97adc28..ccf05ed516 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -49,7 +49,7 @@ strict TypeScript checking passed on Node 24.15.0 / pnpm 11.4.0. Keep and teardown including TERM-resistant descendants. B1 now tests accepted and rejected optional config changes, preserving the original application error only after exact-model re-ack; failed re-ack and rejected model configs still fail. - Tool-enabled foreign MCP and wrong-thread prompts remain rejected. + Tool-enabled foreign MCP provisioning remains rejected; thread arguments now belong to the CLI. - `cancellation.test.ts` also exercises the conversation replacement path for admitted Stop, host close, non-authority attempts and malformed completions. - `installed-conversation.test.ts`: opt-in with @@ -58,20 +58,22 @@ strict TypeScript checking passed on Node 24.15.0 / pnpm 11.4.0. Keep fixture. Verifies actual installed executable AUTH signatures, membership, owner mention dispatch, and exact-model same-conversation ACP completion through the broker. Its actual kind-9 subscription is respected. No provider is called. - An actual signed threaded reply is required through the installed sibling `buzz` - CLI and the fixed host MCP adapter. Verifies canonical hash/signature, same agent, - channel/parent and sole owner p-tag; absence after Stop includes the MCP shim. + Three actual signed replies across two channels and a later prompt in the first + thread are required through the installed sibling `buzz` CLI and fixed adapter. + Verifies canonical hash/signature, same agent, channel/parent and explicit + non-owner member p-tag; native channels list returns useful output, non-member + mentions fail, and a non-owner inbound prompt is ignored; absence after Stop includes the MCP shim. Test code supplies the owner event and invokes the tool, never signs/publishes the expected agent response. This does not test a live LLM deciding to invoke tools. - `reply-tool.test.ts`: production stdio adapter under detached childless shim; - caller destination/mention expansion and inactive calls cannot spawn a CLI; + caller executable/env/signer/relay/attestation overrides and inactive calls cannot spawn a CLI; fixed argv/environment; success, cancellation, disconnect, failed startup and - CLI error all clean up the shim and any TERM-resistant in-group descendants. + CLI error (returned without killing the session) all clean up the shim and any TERM-resistant in-group descendants. Tests never open existing owner keys/profiles/cache, production services or native client resources. With installed opt-in: 12/12 pass; without: 11 pass/1 explicit skip. The installed fixture relay does not establish actual community admission, -current relay compatibility, provider/model attestation or general multi-thread reply delivery. +current relay compatibility, provider/model attestation. Multi-thread delivery is isolated legacy-binary evidence only. ## Independent evidence and limits @@ -79,8 +81,10 @@ Prior reviews are in workspace `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/EXECUTABLE_REVIEW_{94D1DA0E,6BD85947}.md`. Prior c4f30176 broker independently passed strict/11 tests and installed replay in `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/BROKER_REVIEW_C4F30176.md`. Its B1 optional-config -finding is fixed with production regressions here. This new fixed-reply MCP delta -and B1 fix have not yet received independent review. The generic ACP fixture and signed/encrypted WS +finding is fixed with production regressions here. Independent `TOOL_REVIEW_023C9274.md` found no new blocking defect in the +predecessor MCP delta and independently confirmed B1 resolved (strict/full12/12, +original/extended B1 probes and signed installed replay). The new ordinary-CLI +multi-conversation delta has not yet received independent review. The generic ACP fixture and signed/encrypted WS management protocol are different seams; passing them does not prove current Buzz relay auth/member/owner semantics. @@ -88,7 +92,7 @@ No full repo `just ci`, multi-host Move, arbitrary escaped descendant containmen strong OS sandbox, hostile journal, log-full/reconnect/ACK, rollback, host SIGKILL recovery or real provider proof. Genuine ownership loss retains quarantine/lock; no stale numeric PID grants cleanup authority. See CHECKPOINT.md for exact next -executable action and the wired broker, narrow fixed-thread tool, and remaining general per-turn scope gap. +executable action and the wired broker, ordinary fixed-executable CLI tool, and remaining product scope. ## Installed executable pins @@ -97,6 +101,5 @@ executable action and the wired broker, narrow fixed-thread tool, and remaining Both are read-only `/Applications/Buzz.app/Contents/MacOS/` binaries. Their digests are not a claim that they were built from the source base. The MCP adapter supports -only newline stdio, fixed content-only reply calls, and host-local single-thread -scope. Optional local attestation is forwarded using the normal CLI environment; +newline stdio and native CLI argv/stdin, with host-owned executable/identity/relay. Optional local attestation is forwarded using the normal CLI environment; no live admission/delegation policy proof is claimed from the fixture relay. diff --git a/beehive/src/broker.ts b/beehive/src/broker.ts index 20a04db46c..b2ea38b131 100644 --- a/beehive/src/broker.ts +++ b/beehive/src/broker.ts @@ -151,7 +151,7 @@ export class ConversationSession { PATH: '/usr/bin:/bin', HOME: this.prepared.plan.home, BUZZ_PRIVATE_KEY: this.plan.env.BUZZ_PRIVATE_KEY, BUZZ_RELAY_URL: this.plan.env.BUZZ_RELAY_URL, ...(this.plan.env.BUZZ_AUTH_TAG ? { BUZZ_AUTH_TAG: this.plan.env.BUZZ_AUTH_TAG } : {}), - }, () => this.fail('Scoped Buzz reply tool failed (diagnostics withheld)')) : undefined; + }, () => this.fail('Buzz CLI tool failed (diagnostics withheld)')) : undefined; if (tool) this.tools.push(tool); const pending = new Map(); const sessions = new Set(); @@ -177,7 +177,7 @@ export class ConversationSession { const p = msg.params; // Session transport does not grant a shim authority to provision tools, // workspaces or credentials. Replace only empty upstream provisioning with - // the host-local fixed reply adapter; never forward a supplied command. + // the host-local fixed CLI adapter; never forward a supplied command. if (['session/new', 'session/load', 'session/resume'].includes(msg.method)) { if (p?.cwd !== this.prepared.plan.workspace || !Array.isArray(p.mcpServers) || p.mcpServers.length) throw Error(); if (tool) p.mcpServers = [tool.descriptor]; @@ -193,14 +193,7 @@ export class ConversationSession { if (['session/set_model', 'session/set_config_option'].includes(msg.method)) sessions.delete(p?.sessionId); if (msg.method === 'session/prompt') { if (!sessions.has(p?.sessionId) || prompts.has(p.sessionId) || msg.id === undefined) throw Error(); - if (tool) { - if (prompts.size) throw Error(); - // This is a locally pinned single-thread grant, NOT authority parsed - // out of user text. Refuse prompts lacking the expected upstream routing - // instruction; parsing cannot enlarge the host's fixed destination. - const text = JSON.stringify(p.prompt); - if (!text.includes(`--reply-to ${this.plan.replyTool!.parent}`) || !text.includes(this.plan.replyTool!.channel)) throw Error(); - } + if (tool && prompts.size) throw Error(); tool?.setActive(true); prompts.set(p.sessionId, { id: msg.id, hash: createHash('sha256'), text: false, cancelled: false }); } diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 11cd4af6d7..68e1a048a4 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -60,18 +60,15 @@ async function main() { try { const executable = realpathSync(text(await ui.question('Absolute installed buzz-acp executable: '))); const relay = text(await ui.question('Buzz CONVERSATION relay URL (not the Beehive management relay): ')); - const replyTool = (await ui.question('Enable a fixed single-thread Buzz reply tool? This is not automatic per-turn routing. [yes/no]: ')) === 'yes' ? { + const replyTool = (await ui.question('Enable the installed Buzz CLI tool under this agent identity? [yes/no]: ')) === 'yes' ? { executable: realpathSync(text(await ui.question('Absolute installed buzz CLI executable: '))), - channel: text(await ui.question('Locally authorized reply channel UUID: ')), - parent: text(await ui.question('Locally authorized reply parent event hex ID: ')), - recipient: publicKey(setup.ownerSecret), } : undefined; const conversation = { executable, relay, ...(replyTool ? { replyTool } : {}) }; const plan = prepareConversation(conversation, { executable: setup.runner, args: setup.args, workspace: setup.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: text(setup.databricksHost), model: 'databricks-claude-haiku-4-5' }, setup.agentSecret, publicKey(setup.ownerSecret)); if ((await ui.question('Save onto existing identity? Start waits for an admitted conversation and local provider sign-in. [yes/no]: ')) !== 'yes') return; // One atomic replacement; keys, assignment and provider auth context unchanged. writePrivate(join(dir, 'setup.json'), { ...setup, conversation }); - console.log(`Conversation setup saved for existing agent ${plan.agentPublicKey}. No network connection, login or process started. The community operator must admit this public key and owner to the chosen relay/channels (or provision a valid owner attestation locally). Admission remains unverified. Start uses the host-owned ACP broker; fixed reply scope, when enabled, must match the incoming conversation. General automatic per-turn reply provisioning remains unsupported.`); + console.log(`Conversation setup saved for existing agent ${plan.agentPublicKey}. No network connection, login or process started. The community operator must admit this public key and owner to the chosen relay/channels (or provision a valid owner attestation locally). Admission remains unverified. Start uses the host-owned ACP broker; the Buzz CLI tool, when enabled, uses agent membership authority across conversations without local thread configuration.`); } finally { ui.close(); } } finally { rmdirSync(lock); } } else if (command === 'auth-info') { diff --git a/beehive/src/conversation.ts b/beehive/src/conversation.ts index 5a47e728e7..25a3b6cbc6 100644 --- a/beehive/src/conversation.ts +++ b/beehive/src/conversation.ts @@ -18,7 +18,6 @@ export type ConversationPlan = ReturnType; export function prepareConversation(input: ConversationSetup, agent: AgentLaunch, agentSecret: string, owner: string) { const prepared = prepareAgent(agent); const agentPublicKey = publicKey(agentSecret); - if (input.replyTool && input.replyTool.recipient !== owner) throw Error('This reply tool supports the explicit owner recipient only'); if (!/^[a-f0-9]{64}$/.test(owner)) throw Error('Invalid conversation owner public key'); if (!isAbsolute(input.executable) || realpathSync(input.executable) !== input.executable) throw Error('Conversation executable must be a canonical absolute path'); accessSync(input.executable, constants.X_OK); @@ -57,5 +56,5 @@ export function prepareConversation(input: ConversationSetup, agent: AgentLaunch /** Safe remote summary. Credentials and executable paths are never included. */ export function conversationSummary(setup: ConversationSetup) { - return { replyTool: setup.replyTool ? 'fixed-owner-thread' : 'disabled', transport: 'external-buzz-acp', admission: 'unverified', modelEvidence: 'not-observed', launch: 'host-owned ACP broker; awaiting conversation evidence' }; + return { replyTool: setup.replyTool ? 'agent-cli' : 'disabled', transport: 'external-buzz-acp', admission: 'unverified', modelEvidence: 'not-observed', launch: 'host-owned ACP broker; awaiting conversation evidence' }; } diff --git a/beehive/src/reply-tool.ts b/beehive/src/reply-tool.ts index 691d1f4634..90f07c838e 100644 --- a/beehive/src/reply-tool.ts +++ b/beehive/src/reply-tool.ts @@ -1,4 +1,5 @@ import { createServer, type Socket } from 'node:net'; +import { finished } from 'node:stream/promises'; import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -6,15 +7,15 @@ import { randomBytes, createHash } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { spawnOwned, type OwnedProcess } from './owned.ts'; -/** Local-only, single-thread reply authority. No scope or executable comes from ACP. */ -export type ReplyToolSetup = { executable: string; channel: string; parent: string; recipient: string }; -/** Snapshot the explicitly provisioned CLI and reply destination before launching anything. */ +/** Local-only executable provisioning. CLI and relay retain conversation authority. */ +export type ReplyToolSetup = { executable: string }; +/** Snapshot the explicitly provisioned CLI before launching anything. */ export function prepareReplyTool(input: ReplyToolSetup) { + if (Object.keys(input).some(k => k !== 'executable')) throw Error('Legacy scoped tool setup requires explicit conversation-setup reprovisioning'); if (!isAbsolute(input.executable) || realpathSync(input.executable) !== input.executable) throw Error('Reply CLI must be a canonical absolute path'); accessSync(input.executable, constants.X_OK); if (!statSync(input.executable).isFile()) throw Error('Reply CLI must be a regular file'); - if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(input.channel) || !/^[a-f0-9]{64}$/.test(input.parent) || !/^[a-f0-9]{64}$/.test(input.recipient)) throw Error('Invalid local reply scope'); - return Object.freeze({ executable: input.executable, channel: input.channel, parent: input.parent, recipient: input.recipient, + return Object.freeze({ executable: input.executable, executableHash: createHash('sha256').update(readFileSync(input.executable)).digest('hex') }); } @@ -40,15 +41,16 @@ export class ReplyTool { private plan; private cwd; private env; private fail; constructor(path: string, plan: ReturnType, cwd: string, env: NodeJS.ProcessEnv, fail: () => void) { this.plan = plan; this.cwd = cwd; this.env = Object.freeze({ ...env }); this.fail = fail; - this.descriptor = Object.freeze({ name: 'beehive-buzz-reply', command: process.execPath, + this.descriptor = Object.freeze({ name: 'beehive-buzz', command: process.execPath, args: [fileURLToPath(new URL('./acp-shim.ts', import.meta.url)), path, this.capability], env: [] }); this.server.on('error', fail); this.ready = new Promise((resolve, reject) => { this.server.once('error', reject); this.server.listen(path, resolve); }); void this.ready.catch(fail); } - /** Only a model-confirmed active ACP prompt can exercise the local reply grant. */ + /** Only a model-confirmed active ACP prompt can exercise the provisioned tool. */ setActive(active: boolean) { this.active = active; + if (active) this.calls = 0; if (!active && this.current) void this.stopOwned(this.current).catch(this.fail); } private stopOwned(p: OwnedProcess) { @@ -83,37 +85,43 @@ export class ReplyTool { if (!(typeof m.id === 'number' && Number.isSafeInteger(m.id)) && !(typeof m.id === 'string' && m.id.length <= 200)) throw Error(); if (ids.has(m.id)) throw Error(); ids.add(m.id); - if (m.method === 'initialize') send(m.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'beehive-buzz-reply', version: '0.0.1' } }); - else if (m.method === 'tools/list') send(m.id, { tools: [{ name: 'buzz_reply', description: 'Send an ordinary reply using the actual Buzz CLI to the locally authorized channel, parent and recipient. Destination is fixed by the host, not by tool arguments. Mention syntax (@, nostr URIs, npub/nprofile) is unsupported; the host supplies the sole explicit recipient.', inputSchema: { type: 'object', properties: { content: { type: 'string', minLength: 1, maxLength: 16384 } }, required: ['content'], additionalProperties: false } }] }); + if (m.method === 'initialize') send(m.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'beehive-buzz', version: '0.0.1' } }); + else if (m.method === 'tools/list') send(m.id, { tools: [{ name: 'buzz', description: 'Run the installed Buzz CLI under your provisioned agent identity. Supply native argv without the executable, and optional stdin. Use --help for the CLI contract. CLI/relay enforce membership and resolve mentions and threads. Relay, signer and attestation are host-owned; no shell or environment overrides. Use explicit hex --mention for reliable notification.', inputSchema: { type: 'object', properties: { argv: { type: 'array', items: { type: 'string' }, maxItems: 128 }, stdin: { type: 'string', maxLength: 16384 } }, required: ['argv'], additionalProperties: false } }] }); else if (m.method === 'ping') send(m.id, {}); else if (m.method === 'tools/call') { const args = m.params?.arguments; - if (!this.active || this.busy || ++this.calls > 8 || m.params?.name !== 'buzz_reply' || !args || Object.keys(args).length !== 1 || typeof args.content !== 'string' || !args.content.trim() || Buffer.byteLength(args.content) > 16384 || /@|nostr:|npub1|nprofile1/i.test(args.content)) throw Error(); + if (!this.active || this.busy || ++this.calls > 32 || m.params?.name !== 'buzz' || !args || Object.keys(args).some(k => !['argv', 'stdin'].includes(k)) || !Array.isArray(args.argv) || args.argv.length > 128 || args.argv.some((a: unknown) => typeof a !== 'string' || a.includes('\0') || /^(--relay|--private-key|--auth-tag)(=|$)/.test(a)) || (args.stdin !== undefined && (typeof args.stdin !== 'string' || Buffer.byteLength(args.stdin) > 16384)) || Buffer.byteLength(JSON.stringify(args)) > 32768) throw Error(); this.busy = true; - void this.call(args.content).then(() => send(m.id, { content: [{ type: 'text', text: 'Buzz CLI accepted the scoped reply.' }] }), () => { send(m.id, { isError: true, content: [{ type: 'text', text: 'Buzz reply failed; diagnostics withheld.' }] }); this.fail(); }).finally(() => { this.busy = false; }); + void this.call(args.argv, args.stdin ?? '').then(result => send(m.id, result), () => { send(m.id, { isError: true, content: [{ type: 'text', text: 'Buzz tool execution failed.' }] }); this.fail(); }).finally(() => { this.busy = false; }); } else throw Error(); } } catch { socket.destroy(); this.fail(); } }); } - private async call(content: string) { + private async call(argv: string[], content: string) { if (this.closed || !this.active) throw Error(); - const p = spawnOwned(this.plan.executable, ['messages', 'send', '--channel', this.plan.channel, '--reply-to', this.plan.parent, '--mention', this.plan.recipient, '--content', '-'], this.cwd, this.env); + const p = spawnOwned(this.plan.executable, argv, this.cwd, this.env); this.owned.push(p); this.current = p; - let bytes = 0; - const account = (chunk: Buffer) => { bytes += chunk.length; if (bytes > 128 * 1024) this.fail(); }; + let bytes = 0; let stdout = ''; + p.child.stdout.setEncoding('utf8'); + p.child.stdout.on('data', (chunk: string) => { if (bytes + Buffer.byteLength(chunk) <= 128 * 1024) stdout += chunk; }); + const account = (chunk: Buffer | string) => { bytes += Buffer.byteLength(chunk); if (bytes > 128 * 1024) this.fail(); }; p.child.stdout.on('data', account); p.child.stderr.on('data', account); p.child.stdin.on('error', () => this.fail()); // Supervisor forwards the actual runner exit code, not its own anchor exit. - const completion = new Promise((resolve, reject) => { + const completion = new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('Reply timed out')), 10_000); - p.child.on('message', (m: any) => { if (m?.type === 'runner-exit' || m?.type === 'runner-error') { clearTimeout(timer); m.type === 'runner-exit' && m.code === 0 ? resolve() : reject(Error('Reply CLI failed')); } }); + p.child.on('message', (m: any) => { if (m?.type === 'runner-exit' || m?.type === 'runner-error') { clearTimeout(timer); m.type === 'runner-exit' && Number.isInteger(m.code) ? resolve(m.code) : reject(Error('Buzz CLI failed')); } }); p.child.once('error', () => { clearTimeout(timer); reject(Error('Reply unavailable')); }); }); void completion.catch(() => {}); await p.ready; p.child.stdin.end(content); - try { await completion; } finally { this.current = undefined; } - await this.stopOwned(p); + try { + const code = await completion; + await this.stopOwned(p); + await finished(p.child.stdout, { signal: AbortSignal.timeout(1000) }); + return { ...(code ? { isError: true } : {}), content: [{ type: 'text', text: stdout || `Buzz CLI exited ${code}; stderr withheld.` }] }; + } finally { this.current = undefined; await this.stopOwned(p); } } /** Retain all anchors through teardown; no recovered numeric PID is kill authority. */ stop() { diff --git a/beehive/test/broker.test.ts b/beehive/test/broker.test.ts index 72a681db39..e74c866755 100644 --- a/beehive/test/broker.test.ts +++ b/beehive/test/broker.test.ts @@ -8,13 +8,13 @@ import { ConversationSession } from '../src/broker.ts'; import { newKey, publicKey } from '../src/protocol.ts'; test('broker enforces model, completion health, cancellation and owned teardown across escaped shims', async () => { - for (const mode of ['ok', 'optional-ok', 'optional-reject', 'optional-reack-fails', 'model-config-reject', 'reject', 'wrong-model', 'conflict', 'bad-tail', 'delayed', 'cancel', 'foreign-workspace', 'injected-mcp', 'tool-scope-mismatch']) { + for (const mode of ['ok', 'optional-ok', 'optional-reject', 'optional-reack-fails', 'model-config-reject', 'reject', 'wrong-model', 'conflict', 'bad-tail', 'delayed', 'cancel', 'foreign-workspace', 'injected-mcp']) { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'bh-broker-test-'))); const owner = publicKey(newKey()); const executable = realpathSync(process.execPath); const runtime = join(dir, 'runtime'); writeFileSync(runtime, `#!/bin/sh\nexec '${executable}' '${resolve('test/conversation-runtime-fixture.ts')}'\n`, { mode: 0o700 }); writeFileSync(join(dir, 'mode'), mode); - const session = new ConversationSession({ executable: runtime, relay: 'ws://127.0.0.1:1', ...(['injected-mcp', 'tool-scope-mismatch'].includes(mode) ? { replyTool: { executable, channel: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', parent: 'a'.repeat(64), recipient: owner } } : {}) }, { + const session = new ConversationSession({ executable: runtime, relay: 'ws://127.0.0.1:1', ...(['injected-mcp'].includes(mode) ? { replyTool: { executable } } : {}) }, { executable, args: [resolve('test/conversation-harness-fixture.ts')], workspace: dir, home: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid', model: 'databricks-claude-haiku-4-5', }, newKey(), owner, 2000); diff --git a/beehive/test/conversation-harness-fixture.ts b/beehive/test/conversation-harness-fixture.ts index d137207e07..581c69a2c5 100644 --- a/beehive/test/conversation-harness-fixture.ts +++ b/beehive/test/conversation-harness-fixture.ts @@ -1,6 +1,6 @@ /** Owned ACP harness fixture: exact session/model, bidirectional RPC, resistant child. */ import { createInterface } from 'node:readline'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { publicKey } from '../src/protocol.ts'; const mode = readFileSync('mode', 'utf8'); @@ -8,10 +8,10 @@ let tool: any; let configRejected = false; const sessionId = 'conversation-session'; let selected = ''; let prompt: number | undefined; writeFileSync('harness-identity', publicKey(process.env.BUZZ_PRIVATE_KEY!)); writeFileSync('harness-pid', String(process.pid)); -const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{}); setTimeout(()=>{},15000)"], { stdio: 'ignore' }); +const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{}); setTimeout(()=>{},60000)"], { stdio: 'ignore' }); writeFileSync('descendant-pid', String(child.pid)); process.on('SIGTERM', () => {}); -setTimeout(() => process.exit(0), 15000).unref(); +setTimeout(() => process.exit(0), 60000).unref(); const send = (m: any) => process.stdout.write(JSON.stringify(m) + '\n'); for await (const line of createInterface({ input: process.stdin })) { const m = JSON.parse(line); let result: any; @@ -52,8 +52,23 @@ for await (const line of createInterface({ input: process.stdin })) { await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'fixture', version: '1' } }); mcp.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); const tools = await rpc('tools/list', {}); - if (tools.tools[0].name !== 'buzz_reply') throw Error('Missing scoped reply tool'); - await rpc('tools/call', { name: 'buzz_reply', arguments: { content: 'Private conversation fixture response' } }); + if (tools.tools[0].name !== 'buzz') throw Error('Missing Buzz CLI tool'); + // Fixture model interprets upstream instructions, NOT host permission logic. + const text = m.params.prompt.map((p: any) => p.text ?? '').join('\n'); + writeFileSync('last-prompt', text); + const parent = [...text.matchAll(/--reply-to ([a-f0-9]{64})/g)].at(-1)?.[1]; + const channel = [...text.matchAll(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g)].at(-1)?.[0]; + const recipient = readFileSync('recipient', 'utf8'); + if (!parent || !channel) throw Error('Fixture model could not interpret routing'); + const read = await rpc('tools/call', { name: 'buzz', arguments: { argv: ['channels', 'list'] } }); + if (!JSON.stringify(read).includes(channel)) throw Error('CLI read did not return channel'); + let rejected = false; + try { await rpc('tools/call', { name: 'buzz', arguments: { argv: ['messages', 'send', '--channel', channel, '--reply-to', parent, '--mention', readFileSync('outsider', 'utf8'), '--content', 'must not publish'] } }); } + catch { rejected = true; } + if (!rejected) throw Error('CLI accepted non-member mention'); + appendFileSync('rejected-mentions', parent + '\n'); + await rpc('tools/call', { name: 'buzz', arguments: { argv: ['messages', 'send', '--channel', channel, '--reply-to', parent, '--mention', recipient, '--content', '-'], stdin: 'Private conversation fixture response' } }); + appendFileSync('completed-tools', parent + '\n'); // Remain connected until host Stop; the MCP shim has no children. } const update = { jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Private conversation fixture response' } } } }; diff --git a/beehive/test/installed-conversation.test.ts b/beehive/test/installed-conversation.test.ts index 63498db221..1638a0f3b5 100644 --- a/beehive/test/installed-conversation.test.ts +++ b/beehive/test/installed-conversation.test.ts @@ -12,37 +12,51 @@ import { ConversationSession } from '../src/broker.ts'; import { newKey, publicKey } from '../src/protocol.ts'; // Explicitly opt-in installed executable; never opens an owner profile or provider. -test('installed buzz-acp completes a signed scoped threaded reply through the actual Buzz CLI', { skip: !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => { +test('installed buzz-acp completes multi-conversation signed replies and normal CLI tools through the actual Buzz CLI', { skip: !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'bh-installed-'))); writeFileSync(join(dir, 'mode'), 'ok'); const secret = newKey(); const ownerSecret = newKey(); const agent = publicKey(secret); const owner = publicKey(ownerSecret); const channel = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; - let replyID = ''; + const replies: any[] = []; + const subscriptionsBySocket = new Map(); + let expected: any; + let channelReads = 0; let authenticated = false; let subscriptions = 0; let delivered = false; function observeReply(e: any) { if (![9, 40002].includes(e.kind) || e.pubkey !== agent) return; const hash = createHash('sha256').update(JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags, e.content])).digest('hex'); assert.equal(e.id, hash); assert.ok(schnorr.verify(e.sig, hash, agent)); - assert.deepEqual(e.tags.filter((t: string[]) => t[0] === 'p').map((t: string[]) => t[1]), [owner]); - assert.ok(e.tags.some((t: string[]) => t[0] === 'h' && t[1] === channel)); - assert.ok(e.tags.some((t: string[]) => t[0] === 'e' && t[1] === inbound.id)); + assert.deepEqual(e.tags.filter((t: string[]) => t[0] === 'p').map((t: string[]) => t[1]), [recipient]); + assert.ok(e.tags.some((t: string[]) => t[0] === 'h' && t[1] === expected.tags.find((t: string[]) => t[0] === 'h')[1])); + assert.ok(e.tags.some((t: string[]) => t[0] === 'e' && t[1] === expected.id)); assert.ok(e.content.includes('Private conversation fixture response')); - replyID = e.id; delivered = true; + replies.push(e); delivered = true; } function event(kind: number, tags: string[][], content: string, key = ownerSecret) { const pubkey = publicKey(key); const created_at = Math.floor(Date.now() / 1000); const id = createHash('sha256').update(JSON.stringify([0, pubkey, created_at, kind, tags, content])).digest('hex'); return { id, pubkey, created_at, kind, tags, content, sig: Buffer.from(schnorr.sign(id, key)).toString('hex') }; } - const members = event(39002, [['d', channel], ['p', agent], ['p', owner]], ''); + const recipientKey = newKey(); const recipient = publicKey(recipientKey); + writeFileSync(join(dir, 'recipient'), recipient); + const outsiderKey = newKey(); + writeFileSync(join(dir, 'outsider'), publicKey(outsiderKey)); + const secondChannel = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff'; + const members = event(39002, [['d', channel], ['p', agent], ['p', owner], ['p', recipient]], ''); const metadata = event(39000, [['d', channel], ['name', 'fixture'], ['type', 'channel']], ''); const inbound = event(9, [['h', channel], ['p', agent]], 'Please respond to this isolated fixture conversation.'); + const secondMembers = event(39002, [['d', secondChannel], ['p', agent], ['p', owner], ['p', recipient]], ''); + const secondMetadata = event(39000, [['d', secondChannel], ['name', 'second'], ['type', 'channel']], ''); + const second = event(9, [['h', secondChannel], ['p', agent]], 'Second isolated owner conversation.'); + const later = event(9, [['h', channel], ['p', agent], ['e', inbound.id, '', 'root']], 'Later prompt in first thread.'); + const inbounds = [inbound, second, later]; expected = inbound; const http = createServer(async (req, res) => { let body = ''; for await (const chunk of req) body += chunk; res.setHeader('Content-Type', 'application/json'); if (req.url?.includes('/query')) { const filters = JSON.parse(body); const serialized = JSON.stringify(filters); - res.end(JSON.stringify(serialized.includes('39002') ? [members] : serialized.includes('39000') ? [metadata] : serialized.includes(inbound.id) ? [inbound] : [])); + if (serialized.includes('39002')) channelReads++; + res.end(JSON.stringify(serialized.includes('39002') ? [members, secondMembers] : serialized.includes('39000') ? [metadata, secondMetadata] : inbounds.filter(e => serialized.includes(e.id)))); } else if (req.url?.includes('/events')) { const e = JSON.parse(body); observeReply(e); res.end(JSON.stringify({ id: e.id, accepted: true })); @@ -63,16 +77,17 @@ test('installed buzz-acp completes a signed scoped threaded reply through the ac } else if (m[0] === 'EVENT') { observeReply(m[1]); socket.send(JSON.stringify(['OK', m[1].id, true, ''])); } else if (m[0] === 'REQ') { subscriptions++; socket.send(JSON.stringify(['EOSE', m[1]])); + for (const f of m.slice(2)) for (const c of f['#h'] ?? []) { subscriptionsBySocket.set(c, { socket, id: m[1] }); } if (m.slice(2).some((f: any) => f['#h']?.includes(channel))) setTimeout(() => socket.send(JSON.stringify(['EVENT', m[1], inbound])), 100); } }); }); await new Promise(resolve => http.listen(0, '127.0.0.1', resolve)); const address = http.address(); assert.ok(address && typeof address !== 'string'); - const session = new ConversationSession({ executable: realpathSync(process.env.BEEHIVE_REAL_BUZZ_ACP!), relay: `ws://127.0.0.1:${address.port}`, replyTool: { executable: realpathSync(join(process.env.BEEHIVE_REAL_BUZZ_ACP!, '..', 'buzz')), channel, parent: inbound.id, recipient: owner } }, { + const session = new ConversationSession({ executable: realpathSync(process.env.BEEHIVE_REAL_BUZZ_ACP!), relay: `ws://127.0.0.1:${address.port}`, replyTool: { executable: realpathSync(join(process.env.BEEHIVE_REAL_BUZZ_ACP!, '..', 'buzz')) } }, { executable: realpathSync(process.execPath), args: [resolve('test/conversation-harness-fixture.ts')], workspace: dir, home: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid', model: 'databricks-claude-haiku-4-5', - }, secret, owner, 10_000); + }, secret, owner, 20_000); try { await session.ready; for (let i = 0; i < 100 && !subscriptions; i++) await delay(50); @@ -80,8 +95,28 @@ test('installed buzz-acp completes a signed scoped threaded reply through the ac assert.ok(subscriptions > 0, 'installed executable must enter subscription loop'); const evidence = await session.verify(); assert.equal(evidence.session, 'conversation-session'); + assert.equal(evidence.agentPublicKey, agent); assert.ok(delivered, 'the actual Buzz CLI must publish a signed threaded agent reply, not just return tool JSON'); - console.log(`isolated installed runtime: NIP-42 verified, subscriptions=${subscriptions}, harnessSpawned=${existsSync(join(dir, 'harness-pid'))}, same-session completed=true, signed threaded reply observed=${delivered}, reply=${replyID}, agent=${agent}, parent=${inbound.id}`); + const unauthorized = event(9, [['h', channel], ['p', agent]], 'Not the configured owner.', recipientKey); + const firstSubscription = subscriptionsBySocket.get(channel)!; + firstSubscription.socket.send(JSON.stringify(['EVENT', firstSubscription.id, unauthorized])); + await delay(1200); assert.equal(replies.length, 1, 'upstream owner-only admission rejects a different sender'); + for (const next of [second, later]) { + const previous: number = replies.length; expected = next; + const c = next.tags.find(t => t[0] === 'h')![1]!; + const subscription = subscriptionsBySocket.get(c); assert.ok(subscription); + subscription.socket.send(JSON.stringify(['EVENT', subscription.id, next])); + for (let i = 0; i < 300 && replies.length === previous; i++) await delay(50); + assert.equal(replies.length, previous + 1, 'each later conversation must produce its own signed reply'); + } + for (let i = 0; i < 100 && readFileSync(join(dir, 'completed-tools'), 'utf8').trim().split('\n').length < 3; i++) await delay(50); + assert.equal(readFileSync(join(dir, 'rejected-mentions'), 'utf8').trim().split('\n').length, 3); + assert.equal(replies.length, 3); assert.equal(new Set(replies.map(e => e.id)).size, 3); + assert.ok(channelReads >= 3, 'additional CLI channels list operation exercised'); + assert.equal(readFileSync(join(dir, 'harness-identity'), 'utf8'), agent); + await delay(100); assert.ok(session.healthy, 'broker remains model-confirmed and healthy after later turns'); + console.log(`isolated installed runtime: NIP-42 verified, subscriptions=${subscriptions}, signed replies=${replies.map(e => e.id).join(',')}, agent=${agent}, two channels/three owner prompts; explicit non-owner member recipient=${recipient}`); + } finally { await session.stop(); for (const file of ['harness-pid', 'descendant-pid', 'tool-shim-pid']) { diff --git a/beehive/test/reply-tool.test.ts b/beehive/test/reply-tool.test.ts index d244279684..2e622acc43 100644 --- a/beehive/test/reply-tool.test.ts +++ b/beehive/test/reply-tool.test.ts @@ -11,8 +11,8 @@ import { ReplyTool, prepareReplyTool } from '../src/reply-tool.ts'; const scope = { executable: realpathSync(process.execPath), channel: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', parent: 'a'.repeat(64), recipient: 'b'.repeat(64) }; const absent = (pid: number) => { try { process.kill(pid, 0); return false; } catch (e) { return (e as NodeJS.ErrnoException).code === 'ESRCH'; } }; -test('fixed reply MCP rejects caller authority and owns CLI descendants on success, cancel, disconnect and failure', async () => { - for (const mode of ['success', 'cancel', 'disconnect', 'failure', 'startup', 'destination', 'mention', 'inactive']) { +test('fixed Buzz CLI MCP rejects caller authority and owns CLI descendants on success, cancel, disconnect and failure', async () => { + for (const mode of ['success', 'cancel', 'disconnect', 'failure', 'startup', 'executable', 'identity', 'environment', 'relay', 'attestation', 'inactive']) { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'bh-tool-'))); // Node executes this extensionless JS-subset TypeScript fixture as its fixed // "messages" program. Production passes the very same argv to real Buzz CLI. @@ -25,10 +25,11 @@ test('fixed reply MCP rejects caller authority and owns CLI descendants on succe process.stdin.resume(); process.stdin.on('end', () => { ${mode === 'success' ? 'setTimeout(()=>process.exit(0),100)' : mode === 'failure' ? 'process.exit(2)' : "process.on('SIGTERM',()=>{});setTimeout(()=>process.exit(0),15000)"} }); `); - let plan = prepareReplyTool(scope); + assert.throws(() => prepareReplyTool(scope), /reprovisioning/); + let plan = prepareReplyTool({ executable: scope.executable }); if (mode === 'startup') { const executable = join(dir, 'unavailable'); writeFileSync(executable, '', { mode: 0o700 }); - plan = prepareReplyTool({ ...scope, executable }); chmodSync(executable, 0o600); + plan = prepareReplyTool({ executable }); chmodSync(executable, 0o600); } let failed = false; const tool = new ReplyTool(join(dir, 'mcp'), plan, dir, { PATH: '/usr/bin:/bin', BUZZ_PRIVATE_KEY: 'fixture-only' }, () => { failed = true; void tool.stop().catch(() => {}); }); @@ -44,12 +45,12 @@ test('fixed reply MCP rejects caller authority and owns CLI descendants on succe shim.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); }); try { - assert.equal((await rpc('initialize', {})).serverInfo.name, 'beehive-buzz-reply'); + assert.equal((await rpc('initialize', {})).serverInfo.name, 'beehive-buzz'); assert.equal((await rpc('tools/list', {})).tools.length, 1); tool.setActive(mode !== 'inactive'); - const call = rpc('tools/call', { name: 'buzz_reply', arguments: { content: mode === 'mention' ? '@other' : 'fixture', ...(mode === 'destination' ? { channel: 'arbitrary' } : {}) } }); + const call = rpc('tools/call', { name: 'buzz', arguments: { argv: mode === 'identity' ? ['--private-key=foreign'] : mode === 'relay' ? ['--relay', 'ws://foreign.invalid'] : mode === 'attestation' ? ['--auth-tag=foreign'] : ['messages', 'send', '--channel', scope.channel, '--reply-to', scope.parent, '--mention', scope.recipient, '--content', '-'], stdin: 'fixture', ...(mode === 'executable' ? { executable: '/bin/sh' } : mode === 'environment' ? { env: { BUZZ_PRIVATE_KEY: 'foreign' } } : {}) } }); void call.catch(() => {}); - if (['destination', 'mention', 'inactive', 'startup'].includes(mode)) { + if (['executable', 'identity', 'environment', 'relay', 'attestation', 'inactive', 'startup'].includes(mode)) { for (let i = 0; i < 100 && !failed; i++) await delay(10); assert.ok(failed); assert.ok(!existsSync(join(dir, 'argv'))); } else { @@ -58,7 +59,7 @@ test('fixed reply MCP rejects caller authority and owns CLI descendants on succe if (mode === 'success') assert.ok(!(await call).isError); else if (mode === 'cancel') tool.setActive(false); else if (mode === 'disconnect') shim.stdin.end(); - else { for (let i = 0; i < 100 && !failed; i++) await delay(10); assert.ok(failed); } + else { assert.ok((await call).isError); assert.equal(failed, false); } assert.deepEqual(JSON.parse(readFileSync(join(dir, 'argv'), 'utf8')), ['send', '--channel', scope.channel, '--reply-to', scope.parent, '--mention', scope.recipient, '--content', '-']); assert.equal(readFileSync(join(dir, 'identity'), 'utf8'), 'fixture-only'); }