feat(beehive): expose owned Buzz CLI across agent conversations

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 23:17:26 -04:00
parent 023c927476
commit f8f161ffc1
11 changed files with 265 additions and 192 deletions

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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 });
}

View File

@@ -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') {

View File

@@ -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' };
}

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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' } } } };

View File

@@ -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']) {

View File

@@ -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');
}