mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(beehive): expose owned Buzz CLI across agent conversations
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string | number, Pending>();
|
||||
const sessions = new Set<string>();
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -18,7 +18,6 @@ export type ConversationPlan = ReturnType<typeof prepareConversation>;
|
||||
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' };
|
||||
}
|
||||
|
||||
@@ -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<typeof prepareReplyTool>, 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<void>((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<void>((resolve, reject) => {
|
||||
const completion = new Promise<number>((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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' } } } };
|
||||
|
||||
@@ -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<string, { socket: any; id: string }>();
|
||||
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<void>(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']) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user