diff --git a/beehive/CHECKPOINT.md b/beehive/CHECKPOINT.md index 71c38271ec..940076812a 100644 --- a/beehive/CHECKPOINT.md +++ b/beehive/CHECKPOINT.md @@ -1,110 +1,124 @@ # Beehive executable checkpoint Worktree `/Users/loganj/.buzz/REPOS/beehive-cbfd9440`, branch `beehive/cbfd9440`. -Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; prior reviewed milestone -`94d1da0e8113c27f73865233a18b1157f2ea8e4c`. This continuation is the commit -containing this checkpoint (resolve with `git rev-parse HEAD`); do not restart -repo/design discovery or create a second implementation worktree. +Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; previous reviewed milestone +`6bd859479503832a331cb9f8eb0a495082c8f9d8`. This continuation is the commit +containing this checkpoint (`git rev-parse HEAD`). No push/PR/merge/release. -## Implemented now +## Outcome and exact remaining critical gap -Standalone TypeScript package; dedicated loopback real WebSocket ciphertext relay, -signed/encrypted commands, owner-only atomic storage, fixed host/agent authority, -durable revision/fingerprint admission and receipts/outbox, selected-next separate -from captured actual run, assigned host retained stopped, local setup and remote -line-oriented TUI. Existing native clients/relay/harness source untouched. +**The requested identity-bearing relay conversation is NOT delivered.** The +existing executable path is still a retained anonymous ACP greeting probe. +Do not call its evidence a Buzz conversation, provider attestation or live-model +proof. New `conversation-setup ` provisions the external +buzz-acp contract onto the SAME existing key, atomically under the host exclusion +lock. It does not create a key, initiate auth or connect to any conversation relay. +Configured conversation Start fails BEFORE spawning or committing actual-run state. +Inventory and CLI explicitly say blocked; Save/Stop remain credential-neutral. -**External harness boundary:** `src/acp.ts` implements bounded newline ACP RPC to -external buzz-agent, not buzz-acp. Grounded in pinned source initialize/session/new/ -session/set_model explicit {sessionId,modelId} ack, then session/prompt. Captures -local immutable launch inputs, scrubbed env with Databricks v2, dedicated HOME and -BUZZ_AGENT_CONFIG_DIR; requires exact model/session acknowledgement and completed -same-session text response. Stores response SHA-256, model, session and executable -SHA-256, never raw text/diagnostics. Host Start uses this production path; Save and -Stop do not consult auth. No unmerged BUZZ_ACP_REQUIRED_MODEL assumption. +`src/conversation.ts` validates and snapshots the external executable, selected +Buzz Agent launch, conversation relay, exact existing agent secret, owner pubkey, +optional host-local opaque NIP-OA tag, exact model, owner-only inbound gate and +local default permission mode. Env is allowlisted; identity and relay are +explicitly authoritative. No owner secret, arbitrary env or remote paths/argv are +accepted. Upstream comma-delimited args reject lossy comma/NUL/empty elements. +Remote summary contains no credential/executable path. This is a tested launch +PLAN, not a launched identity-bearing session. CLI currently asks no attestation; +community operator must admit the public key and owner to intended channels, or +locally provision a valid NIP-OA tag. Neither that action nor live OAuth occurred. -`setup` option 2 provisions buzz-agent plus fresh service-home/agent-config dirs; -`auth-info ` prints the exact shell-quoted local OAuth command/context, -host name and current uid, without starting login. Operator must use the host -service's OS account. No token broker, remote auth RPC, inherited provider secrets, -key import or historical cache access. Existing option-2 setup (buzz-acp) requires -explicit local reprovisioning, not automatic migration. +## Source-grounded runtime decision (not a second agent runtime) -**Honest narrow seam:** current upstream session/new may return fallback catalog -on OAuth/discovery failure. Thus not-probed/reported/empty/filtered/failed are -explicit, but authentication stays unverified. Authenticated-empty, expired, -denied vs missing auth cannot be inferred. Catalog success is not readiness. +Pinned upstream `051c3a2`: +- `desktop/src-tauri/src/managed_agents/runtime.rs::spawn_agent_child` passes + BUZZ_PRIVATE_KEY, BUZZ_RELAY_URL, BUZZ_AUTH_TAG, external agent command/args, + model/provider/effort. No BUZZ_ACP_REQUIRED_MODEL contract exists here. +- `crates/buzz-acp/src/config.rs::CliArgs` defines these external env/CLI inputs. +- `crates/buzz-acp/src/lib.rs::resolve_agent_owner` verifies NIP-OA against the + agent pubkey or falls back to explicit owner. Startup uses HarnessRelay::connect + with that key/auth tag, then the actual inbound author/member gate. +- `crates/buzz-acp/src/pool.rs::apply_model_switch` treats application rejection + as Rejected and proceeds with the default. Model env alone is insufficient. +- **Critical containment seam:** `crates/buzz-acp/src/acp.rs::AcpClient::spawn` + calls `cmd.process_group(0)` on Unix. Replacing the probe with buzz-acp under + the current outer anchor would let the ACP subtree escape owned Stop (F1). -**Not yet a Buzz conversation agent:** this boundary keeps an ACP session alive -following a greeting probe; persistent Beehive key binds control authority but is -not supplied to stdio probe. Relay conversation/prompt bridge and actual live -Databricks execution remain gates. An external executable's model acknowledgement -is not provider attestation. Fixtures are not live proof. +Reuse existing buzz-acp for all Nostr/owner/membership/mention/reply/delivery +semantics. Do not invent a chat protocol or second Buzz dispatcher. Smallest +coherent next implementation: host-owned TypeScript ACP stdio broker/proxy so the +actual adapter is spawned under a host-retained live anchor, not as an escaping +buzz-acp descendant; the buzz-acp-launched TS shim only forwards stdio. Bound and +close every shim/broker session on Stop, retain adapter ownership after parent +exit, enforce session/model acknowledgement, and capture prompt completion on +THAT identity-bearing conversation session. This broker is NOT implemented yet. +The current guard deliberately prevents unsafe direct launch in the meantime. -## Independent feedback consumed and corrected +Management transport remains the dedicated loopback TypeScript ciphertext WS +protocol, NOT a Nostr/Buzz conversation relay. No existing clients or Rust changed. -Read UX_ACCEPTANCE.md and EXECUTABLE_REVIEW_94D1DA0E.md in -WORK_LOGS/BEEHIVE_DESIGN_183FFAF0 (review artifacts untouched). -- F1: ordinary runner exit must not disable teardown of owned descendants. - Added `owned.ts` + internal `supervisor.ts`: a live TypeScript group anchor - outlives the external runner, preserving group identity. Stop is an IPC request - to that living anchor, which signals ITS OWN group; host only polls group - absence. No kill to a possibly reused numeric PID/group after leader exit. - Quarantine retains verified-owned Stop/close; failed teardown retains lock. - Unexpected anchor loss still fails closed (no PID recovery). -- F2: validate URL before lock; unwind only newly acquired startup lock after - connect/save failure before accepting any operation. Malformed URL and refused - connection followed by successful retry covered; competing live lock preserved. -- F3: intentional Quit prints independent host lifetime, not UNKNOWN. Unexpected - disconnect scopes uncertainty to live reachability/unresolved requests and - does not erase received receipts. +## Independent review integrated -New candidate is NOT independently re-reviewed. S1 two-host/same-identity not -established; only single-host authority and stopped assignment tests exist. +Read `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/EXECUTABLE_REVIEW_6BD85947.md`. +Earlier F1/F2/F3 remain covered. This continuation addresses its D1–D3: +- D1: live supervisor ignores its own TERM, remains through a 500 ms graceful + interval, then sends KILL to its OWN still-owned group. Host only polls absence; + transient EPERM is not success and is retried within the same bounded wait. + IPC disconnect/send failure invokes the same cleanup rather than killing the + anchor prematurely. Resistant orphan descendants covered through remote Stop, + host close and parent IPC loss. Genuine anchor loss still fails closed. +- D2: exact-authority/current-revision/body-valid Stop signals ACP cancellation + outside the serialized mutation queue. Close cancels before waiting. Cancelled + Start fails; queued Stop retains ordinary receipt/admission handling. Delayed + responses cannot commit evidence after failure. Wrong agent/revision/body does + not cancel. No blanket stale-revision bypass or recovered-PID kill added. +- D3: request promise commit and evidence commit recheck session health; host + rechecks before running. Coalesced completed-response + malformed-tail regression + fails at both ACP and host production seams, rather than accepting then waiting + for heartbeat quarantine. -## Commands and evidence +This candidate has NOT received independent delta re-review. Report fixtures +remain independent; no review artifacts modified. -Activate Hermit from repo root (`. ./bin/activate-hermit`), then `cd beehive`. -`npm test`: **5/5 top-level tests pass** on Node 24.15.0; table cases include -wrong model/session, cancelled/rejected/no-response protocol, malformed JSON, -timeout/flood, catalog empty/filtered ambiguity and sanitized output. Real WS -host→external ACP fixture Start/Save/Stop, auth-directory removal before Save/Stop, -actual auth-info CLI, two TUI subprocess reopen/quit sessions, initial-connect -unwind, persistent retries/conflicts and group-anchor parent-exit regressions for -both remote Stop and host close. All test identities/processes/stores isolated. +## Dependency route and validation -`node node_modules/typescript/bin/tsc --noEmit`: **FAIL exit 2**, missing -@types/node/@types/ws declarations (diagnostics also cascade into implicit-any). -`npm run check` cannot find the tsc CLI because ignored borrowed dependency -symlinks have no .bin entry. No strict-check pass, lockfile or clean install claim. -Dependency investigator a2b36832 is separate; no approved dependency result has -arrived/been applied at this checkpoint. Continue integration, do not pause work -for the registry. No dependency-investigator artifacts modified. +Read and applied `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/DEPENDENCY_INSTALLATION.md`. +Public npm registry is host-policy blocked; approved Block mirror is the supported +route, not a policy bypass. Borrowed ignored modules removed. A standalone +registry-agnostic `pnpm-lock.yaml` now pins all seven packages with integrity. +No global/user npm/Git config or machine-path dependency committed. Use: -Installed executable safe probe: fresh temp HOME/config, no credentials/env -inheritance, `/Applications/Buzz.app/Contents/MacOS/buzz-agent --help` exits 2 -with BUZZ_AGENT_PROVIDER required. This proves executable presence only, not -version/capabilities/auth/model. No OAuth, live provider call or owner store read. +``` +. ./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 +npm test +``` -`git diff --check` passes. No repo-wide just ci, push or PR; no merge/release-ready -claim. Commit uses configured Logan Johnson and DCO. +Fresh empty node_modules + fresh temporary pnpm store fetched all seven packages +successfully on pnpm 11.4.0 / Node 24.15.0. Strict TypeScript check passes; +**npm test: 9/9 top-level tests pass** (13.2 seconds). Tests include original +ACP/WS/TUI regressions, D1–D3, and blocked conversation setup/launch contract. +`git diff --check` passes. Local commit uses configured Logan Johnson + author/committer and DCO; no persistent Git config edits. +No real Databricks OAuth, model call, conversation relay admission or external +buzz-acp network session was exercised. Generic ACP fixtures are NOT Buzz relay +parity. No repo-wide `just ci` or production readiness claim. -## Next executable step / remaining scope +## Next executable action / full scope -Next: add a relay-driven conversation/probe operation to the retained ACP session -with run-bound request/response receipts and strict cancellation (no remote paths, -argv, env or credentials). Test it through the real host and external ACP fixture, -then have a human provision the exact host-service auth context for live-model -acceptance. Integrate approved dependency result and fix strict diagnostics as soon -as provided; request independent review of this candidate, including the anchor. +**Implement the host-owned ACP stdio broker described above, then remove the +conversation Start gate only when adapter/shim teardown and exact-model real +conversation correlation pass tests.** Do not spend another run rediscovering +upstream routing. Preserve the provisioned agent key and the two-relay distinction. -Full requested parity remains open: hosted/current-Buzz relay admission (dedicated -loopback protocol is NOT compatible), reconnect/ACKs and durable TUI pending -intents, crash recovery/strong OS containment, multi-agent/setups, profiles and -instructions/metadata, key import/revocation, Restart/Move (same key destination-local, -no unreachable takeover or secret/session/workspace transfer), all Desktop harnesses, -presets/providers, mesh and compute deployment. Agent key authority and host assignment -must remain durable; no scope exclusions are owner-approved. Executable/script/config -replacement between hashing and spawn is still trusted-local-operator territory, -not immutable file-handle pinning. No hostile-journal validation, outbox pruning, -rollback/clone protection or cryptographic deployment certification. +Still open: actual Buzz-relay conversation/owner admission, live provider proof, +catalog auth ambiguity, reconnect/ACKs and durable TUI intents, crash recovery and +strong OS containment, multiple agents/hosts/setups, profiles/instructions/metadata, +key import/revocation, Restart/Move, other Desktop harnesses/presets/providers, +relay-mesh and compute. S1 two-host same identity is unproven. Source setup is one +user-facing object but initial key setup is still coupled to first harness setup; +conversation attachment now independently preserves keys. No remaining scope row +is an owner-approved exclusion. Hash-to-spawn TOCTOU, hostile journals, rollback/ +clone protection and outbox pruning remain explicitly outside current evidence. diff --git a/beehive/README.md b/beehive/README.md index 7debea2965..d1bae2537c 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -12,11 +12,12 @@ native clients: it is a separately authorized product experiment. ## Run an isolated fixture -Requires Node >=22.18 (tested with 26.8.1), POSIX process groups, npm dependencies. +Requires Node >=22.18 (tested with Hermit 24.15.0), POSIX process groups and +pnpm 11.4.0 for the standalone lockfile. Activate Hermit from the repository root. ```sh cd beehive -npm install +pnpm install --ignore-workspace --frozen-lockfile --ignore-scripts npm test npm run check node src/cli.ts identity /tmp/my-beehive-owner @@ -31,6 +32,11 @@ node src/cli.ts host /tmp/my-beehive-host ws://127.0.0.1:19481 node src/cli.ts tui /tmp/my-beehive-owner/identity.json ws://127.0.0.1:19481 ``` +On Block-managed machines, the public npm registry is policy-blocked. Append +`--registry https://global.block-artifacts.com/artifactory/api/npm/square-npm/` +to the install command to use the approved mirror. No credentials or persistent +registry config are needed. The committed lockfile is registry-agnostic. + TUI: `hosts`, `select `, `show`, `start`, `save`, `stop`, `quit`. Save asks for advertised model, workspace and independent behavior profile. Only one fixture model and the `default` profile are currently supported; @@ -78,7 +84,8 @@ assumes non-cloned, non-rollback, exclusively supervised installations. launch must pin all setup/credential/script generations and close that gap. - Stop uses a live TypeScript group anchor and private in-lifetime IPC channel. The anchor outlives the external runner, preserving group identity if its leader - exits. Stop asks that anchor to signal its own group, then confirms group absence; + exits. Stop asks that anchor to signal its own group, retaining the living anchor + through a 500 ms TERM grace period before ownership-safe KILL escalation, then confirms group absence; it never sends a kill to a recovered or possibly reused numeric ID. Quarantine preserves this teardown route. A lost anchor fails closed and retains the lock. This is POSIX in-group supervision, not containment of escaping descendants. @@ -151,6 +158,28 @@ 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 (launch blocked) + +`node src/cli.ts conversation-setup /absolute/existing-host-directory` attaches +an installed **buzz-acp** executable and separate Buzz conversation relay URL to +an existing Buzz Agent setup. It never generates/replaces the provisioned agent +identity, reads OAuth or initiates a connection. Close the host first; setup uses +the same atomic exclusion lock and leaves the key/auth context unchanged. + +**This does not enable a conversation yet.** A configured conversation Start is +rejected before spawn; inventory explains why. Upstream `AcpClient::spawn` calls +`process_group(0)`, so putting buzz-acp inside the existing outer supervisor would +let its ACP adapter escape owned Stop. `apply_model_switch` can also proceed on +a default model after rejection. A host-owned TS ACP stdio broker must retain +adapter ownership and observe exact-model/session completions in actual Buzz +conversations before this gate can be removed. Buzz-acp—not Beehive—will continue +to own Nostr auth, membership, author gates, mentions, replies and delivery. + +The launch plan supplies the existing agent key, explicit owner, optional local +NIP-OA attestation and authoritative relay/model env. None has been exercised +against a real conversation relay. Community admission and host-service-user OAuth +remain explicit operator actions; this package has not performed them. + ## Supported / remaining parity | Surface | This slice | Required next work | diff --git a/beehive/TESTING.md b/beehive/TESTING.md index bd46eeb94b..7f1adc713e 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -1,60 +1,68 @@ # Package validation -`npm test` runs Node's real TypeScript test runner. Test identities and stores -are fresh temporary owner-only resources. No production services or provider -credentials are required. `npm run check` performs strict TypeScript checking. +Activate Hermit from repo root, then enter `beehive`: -Automated evidence in `test/slice.test.ts`: -- malformed/wrong-owner/tampered envelope rejection; -- actual loopback WebSocket relay persistence and encrypted payload checks; -- external deterministic TypeScript runner Start/Stop; -- wrong-agent authority denial, operation fingerprint conflict, stale revision; -- selected-next Save while a run continues; -- two actual CLI TUI subprocess sessions, each showing running actual state and - quitting while the host survives; -- relay client close/reopen, duplicate retry, clean host close/restart with - assignment/revision retained. +```sh +pnpm install --ignore-workspace --frozen-lockfile --ignore-scripts +npm run check +npm test +``` -Fixture is not an ACP agent or OAuth/model test. Crash-safe multi-host Move, -unknown descendant containment, relay outages/full log, host SIGKILL, hostile -local journal modifications and production private admission are not proven. +On Block-managed machines append +`--registry https://global.block-artifacts.com/artifactory/api/npm/square-npm/` +to install: the approved mirror is accessible anonymously while public npm is +policy-blocked. `pnpm-lock.yaml` is standalone and registry-agnostic. No borrowed +machine-path modules, alternate credential stores, or persistent config edits. +A fresh-store/fresh-node_modules frozen install fetched all seven packages; +strict TypeScript checking passed on Node 24.15.0 / pnpm 11.4.0. Keep +`--ignore-workspace` so installation does not expand to unrelated client packages. -Current environment: registry.npmjs.org requests are redirected to Block's -Cloudflare Dependency Confusion block page (HTML rather than JSON). Offline npm -has no package metadata cache. Local tests use symlinks to already-installed -public JS packages from -`~/.local/lib/janet/a8d6cdb-darwin-arm64/package/candidate/node_modules/` -(`@noble/curves` 1.2.0, `ws` 8.21.0, TypeScript 5.9.3); no private Janet data is -used and nothing there was modified. Symlinks are ignored, not committed. -`@types/node` / `@types/ws` are not installed locally; typecheck currently fails -with missing declaration diagnostics. This is not a passing typecheck or a -reproducible clean install. Restore approved dependency access, generate a lock -file, run strict check and package tests before proposing merge/production use. -No policy bypass or alternate registry workaround was attempted. +## Automated production seams -## ACP and independent-review regressions (continuation) +- `slice.test.ts`: malformed/wrong-owner/tampered encryption envelopes; real + loopback WS relay; durable encrypted records, operation fingerprints/revisions, + wrong-agent denial, selected-next Save, external runner Start/Stop; two actual + TUI subprocess sessions close/reopen while host survives; host restart retains + assignment. F2 startup lock unwind and competing live lock exclusion; F3 deliberate + Quit does not print UNKNOWN. F1 parent exit retains descendant ownership. +- `acp.test.ts`: external TypeScript ACP fixture under production supervisor, + catalog provenance, model/session acknowledgement, completed text hash, wrong + model/session, cancelled/rejected/timeout/malformed/flood cases. D3 completed + response plus malformed line in one stdout write must fail evidence commit. + Real encrypted management WS Start publishes fixture evidence; removing isolated + auth directory does not prevent Save/Stop. Actual auth-info CLI does not login. +- `cancellation.test.ts`: real management WS and external delayed ACP prompt. + D2 Stop at the in-flight Start's current revision and host close interrupt promptly + rather than wait for the response; interrupted Start cannot commit running. + Wrong-agent/revision/body Stop cannot interrupt. D3 malformed completion tail + fails at the host seam as well as AgentSession. +- D1 `slice.test.ts` variants: resistant orphan descendants observe TERM but stay + alive, then are removed by bounded live-anchor KILL escalation through both + remote Stop and host close. `disconnect.test.ts` verifies parent IPC loss during + runner life uses the same ownership-preserving teardown, including closed-IPC + notification handling. Fixtures self-expire to bound failed regression runs. +- `conversation.test.ts`: exact local external buzz-acp launch contract, preserved + provisioned identity, authoritative owner/relay/model env, no ambient provider + token, rejected lossy args and unsafe URLs, sanitized remote summary. Real + management WS refuses unsafe conversation Start before any process/run state, + preserves Save/Stop and keys, and exercises CLI exclusion-lock cleanup. -`npm test` now runs five top-level tests (many table cases) under Hermit Node -24.15.0. `acp.test.ts` launches `acp-fixture.ts` through the actual TypeScript -supervisor and ACP boundary. Cases cover exact session/model acknowledgement, -response hashing, empty/filtered catalog provenance, wrong model/session, rejected -operation (secret-bearing error withheld), cancelled turn, malformed JSON, timeout -and output flood. The host integration uses real encrypted WS commands and proves -Save → Start → same-session fixture evidence → Save → remove isolated auth directory -→ Stop, retaining stopped host assignment. No fixtures call an LLM or read a cache. +Tests use isolated keys/directories/ports/processes only. Generic external ACP +fixtures are **not** Buzz conversation relay protocol fixtures. There is no +end-to-end identity-bearing Buzz conversation test yet. No real OAuth/model call, +owner credential store, production relay, native service or old app is touched. -The review at pinned 94d1da0e is in the workspace's -WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/EXECUTABLE_REVIEW_94D1DA0E.md. Its F1/F2/F3 are -addressed with production regressions in slice.test.ts: -- orphan-fixture.ts exits its runner but leaves a non-escaping descendant; a live - group anchor must remain, and both remote Stop and host close kill the group; -- malformed relay URL and initial connection refusal, then successful startup; - second-live-host lock rejection remains tested; -- two actual TUI sessions intentionally quit without printing UNKNOWN. +## Independent evidence and limits -The new anchor pins group identity while the external runner is gone. Cleanup is -an IPC request to that living anchor, which signals its own group. No negative-PID -kill from a stale parent child handle is used. Group absence polling is read-only. -Unexpected loss of the anchor still requires local reconciliation and is not a -claim of general OS containment or crash recovery. These fixes and the ACP slice -need independent review on their new head; the old review is not approval of them. +Prior reviews are in workspace +`WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/EXECUTABLE_REVIEW_{94D1DA0E,6BD85947}.md`. +This candidate integrates F1/F2/F3 and D1/D2/D3 fixes but has not received an +independent delta re-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. + +No full repo `just ci`, multi-host Move, arbitrary escaped descendant containment, +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 intentionally blocked external conversation topology. diff --git a/beehive/pnpm-lock.yaml b/beehive/pnpm-lock.yaml new file mode 100644 index 0000000000..54a122f817 --- /dev/null +++ b/beehive/pnpm-lock.yaml @@ -0,0 +1,83 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@noble/curves': + specifier: 1.2.0 + version: 1.2.0 + ws: + specifier: 8.21.0 + version: 8.21.0 + devDependencies: + '@types/node': + specifier: 22.18.0 + version: 22.18.0 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + +packages: + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@types/node@22.18.0': + resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/hashes@1.3.2': {} + + '@types/node@22.18.0': + dependencies: + undici-types: 6.21.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.18.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + ws@8.21.0: {} diff --git a/beehive/src/acp.ts b/beehive/src/acp.ts index 510c780459..74b16fba98 100644 --- a/beehive/src/acp.ts +++ b/beehive/src/acp.ts @@ -85,6 +85,12 @@ export class AgentSession { for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(Error(reason)); } this.pending.clear(); } + /** Interrupt outstanding startup work; late completions cannot commit readiness. */ + cancel() { + if (this.failed) return; + if (this.session && this.prompting) this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: this.session } }) + '\n'); + this.fail('ACP Start cancelled'); + } /** Protocol failures invalidate readiness even if the OS process has not exited yet. */ get healthy() { return !this.failed; } @@ -118,10 +124,14 @@ export class AgentSession { if (this.failed) return Promise.reject(Error('ACP session unavailable')); if (this.pending.size) return Promise.reject(Error('ACP operation already in progress')); const id = ++this.sequence; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => this.fail('ACP operation timed out'), this.timeoutMs); this.pending.set(id, { resolve, reject, timer }); this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }).then(value => { + // The same stdout batch may resolve RPC then fail parsing its trailing line. + if (this.failed) throw Error('ACP session failed before operation commit'); + return value; }); } /** Discover a session catalog, explicitly retaining its ambiguous authentication provenance. */ @@ -147,6 +157,7 @@ export class AgentSession { this.response = ''; try { const reply = record(await this.request('session/prompt', { sessionId: this.session, prompt: [{ type: 'text', text: 'Reply with a short greeting only. Do not use tools or access files.' }] })); + if (this.failed) throw Error('ACP session failed before evidence commit'); if (reply.stopReason !== 'end_turn' || !this.response.trim()) throw Error('No completed same-session text response'); return { session: this.session, model, responseHash: hash(this.response), stopReason: 'end_turn', source: 'external-acp-session', executableHash: this.prepared.executableHash }; } finally { this.prompting = false; this.response = ''; } diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 293be6ea6f..8bd92f75d9 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -1,12 +1,13 @@ import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; -import { existsSync, mkdirSync, realpathSync } from 'node:fs'; +import { existsSync, mkdirSync, realpathSync, rmdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { relay } from './relay.ts'; import { host, validateSetup } from './host.ts'; import { connect } from './client.ts'; import { message, newKey, publicKey, object, text, type Message } from './protocol.ts'; import { readPrivate, writePrivate } from './storage.ts'; +import { prepareConversation } from './conversation.ts'; const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`; const [command, ...args] = process.argv.slice(2); @@ -14,6 +15,7 @@ const help = `Beehive — isolated development preview (loopback relay only) identity Create a NEW local owner identity setup Guided local harness + key setup auth-info Print exact local sign-in context (no login) + conversation-setup Attach external buzz-acp to EXISTING identity (Start gated) relay Dedicated ciphertext relay host Persistent foreground host tui Relay-connected terminal UI @@ -46,6 +48,26 @@ async function main() { console.log('Local setup saved. Start the host separately; the TUI never owns its lifetime.'); if (mode === '2') console.log(`Not authenticated. Run auth-info ${shellQuote(dir)} for the exact local host/service-user login command. Start uses an ACP greeting probe, not yet a Buzz relay conversation agent.`); } finally { ui.close(); } + } else if (command === 'conversation-setup') { + const dir = resolve(text(args[0])); + const lock = join(dir, 'host.lock'); + mkdirSync(lock, { mode: 0o700 }); // Same atomic exclusion as host startup; never remove a competing lock. + try { + if (existsSync(join(dir, 'journal.json')) && object(readPrivate(join(dir, 'journal.json'))).phase !== 'stopped') throw Error('Reconcile the prior run before local setup changes'); + const setup = validateSetup(readPrivate(join(dir, 'setup.json'))); + if (setup.mode !== 'buzz-agent-databricks-v2') throw Error('Provision Buzz Agent first; this action never creates or replaces agent keys'); + const ui = createInterface({ input: stdin, output: stdout }); + 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 conversation = { executable, relay }; + 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 remains BLOCKED pending safe ACP subtree containment. [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 is blocked until host-owned ACP bridge containment is implemented.`); + } finally { ui.close(); } + } finally { rmdirSync(lock); } } else if (command === 'auth-info') { const setup = validateSetup(readPrivate(join(resolve(text(args[0])), 'setup.json'))); if (setup.mode !== 'buzz-agent-databricks-v2') throw Error('This harness setup has no provider sign-in'); diff --git a/beehive/src/conversation.ts b/beehive/src/conversation.ts new file mode 100644 index 0000000000..e2f5de301f --- /dev/null +++ b/beehive/src/conversation.ts @@ -0,0 +1,67 @@ +import { accessSync, constants, readFileSync, realpathSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { createHash } from 'node:crypto'; +import { prepareAgent, type AgentLaunch } from './acp.ts'; +import { publicKey } from './protocol.ts'; + +/** Host-local external transport configuration. Never accepted through management messages. */ +export type ConversationSetup = { executable: string; relay: string; authTag?: string }; +/** Launch intent, not a claim of relay admission or a model-confirmed conversation. */ +export type ConversationPlan = ReturnType; + +/** + * Exact upstream external contract at 051c3a2: spawn_agent_child, CliArgs, + * resolve_agent_owner and HarnessRelay::connect. No ambient environment is merged. + * A NIP-OA tag is an opaque host-local credential; only upstream verifies it. + */ +export function prepareConversation(input: ConversationSetup, agent: AgentLaunch, agentSecret: string, owner: string) { + const prepared = prepareAgent(agent); + const agentPublicKey = publicKey(agentSecret); + 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); + const url = new URL(input.relay); + if (!['ws:', 'wss:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw Error('Invalid conversation relay URL'); + if (url.protocol === 'ws:' && !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) throw Error('Non-loopback conversation transport requires TLS'); + // Upstream uses comma-delimited arguments, not shell parsing. Reject lossy encoding. + if (prepared.plan.args.some(a => a.includes(',') || a.includes('\0') || !a.length)) throw Error('ACP arguments cannot contain commas, NUL or empty values'); + if (input.authTag !== undefined && (typeof input.authTag !== 'string' || input.authTag.length > 16384 || !input.authTag.length)) throw Error('Invalid local owner attestation'); + return Object.freeze({ + executable: input.executable, + executableHash: createHash('sha256').update(readFileSync(input.executable)).digest('hex'), + workspace: prepared.plan.workspace, + agentPublicKey, + owner, + relay: url.href, + agentExecutableHash: prepared.executableHash, + // Authoritative identity/transport applied after provider env; no arbitrary env input. + env: Object.freeze({ ...prepared.env, + BUZZ_PRIVATE_KEY: agentSecret, + BUZZ_RELAY_URL: url.href, + BUZZ_ACP_AGENT_OWNER: owner, + ...(input.authTag === undefined ? {} : { BUZZ_AUTH_TAG: input.authTag }), + BUZZ_ACP_AGENT_COMMAND: prepared.plan.executable, + BUZZ_ACP_AGENT_ARGS: prepared.plan.args.join(','), + BUZZ_ACP_MODEL: prepared.plan.model, + BUZZ_ACP_MCP_COMMAND: '', + BUZZ_ACP_AGENTS: '1', + BUZZ_ACP_RESPOND_TO: 'owner-only', + BUZZ_ACP_ALLOWED_RESPOND_TO: 'owner-only', + BUZZ_ACP_PERMISSION_MODE: 'default', + }), + }); +} + +/** + * Do not regress owned Stop: upstream AcpClient::spawn uses process_group(0), + * escaping the existing outer group anchor. A host-owned ACP stdio broker must + * supervise the actual adapter before this plan is safe to execute. + */ +export function requireConversationContainment(): never { + throw Error('Conversation Start unavailable: external buzz-acp creates a separate ACP process group; host-owned ACP bridge containment is not implemented. No process started.'); +} + +/** Safe remote summary. Credentials and executable paths are never included. */ +export function conversationSummary(_setup: ConversationSetup) { + return { transport: 'external-buzz-acp', admission: 'unverified', modelEvidence: 'not-observed', launch: 'blocked: ACP subtree containment required' }; +} diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 94016124a1..2ba46f59cd 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -6,8 +6,9 @@ import { digest, fields, message, object, publicKey, text, type Message } from ' import { readPrivate, writePrivate } from './storage.ts'; import { spawnOwned, type OwnedProcess } from './owned.ts'; import { AgentSession, prepareAgent, type AgentLaunch, type Catalog, type Evidence } from './acp.ts'; +import { prepareConversation, requireConversationContainment, conversationSummary, type ConversationSetup } from './conversation.ts'; -export type Setup = { host: string; ownerSecret: string; agentSecret: string; runner: string; args: string[]; workspace: string; mode: 'fixture' | 'buzz-agent-databricks-v2'; databricksHost?: string; serviceHome?: string; configDirectory?: string }; +export type Setup = { host: string; ownerSecret: string; agentSecret: string; runner: string; args: string[]; workspace: string; mode: 'fixture' | 'buzz-agent-databricks-v2'; databricksHost?: string; serviceHome?: string; configDirectory?: string; conversation?: ConversationSetup }; type Selection = { model: string; workspace: string; profile: string }; type State = { binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | { selection: Selection; executableHash: string; run: string; evidence?: Evidence }; operations: Record; outbox: Message[] }; export function validateSetup(value: unknown): Setup { @@ -38,15 +39,22 @@ export async function host(directory: string, url: string) { function inventory() { return message('inventory',setup.host,agent,state.revision, { assignedHost: setup.host, phase: state.phase, selectedNext: state.selected, actualRun: state.actual, + ...(setup.conversation ? { conversation: conversationSummary(setup.conversation) } : {}), catalog, setup: setup.mode, models: [setup.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5'], - workspaces: [setup.workspace], profiles: ['default'], readiness: setup.mode === 'fixture' ? 'fixture-only' : (state.phase === 'running' && state.actual?.evidence ? 'ACP model acknowledged + same-session response (not provider attestation or Buzz relay agent)' : 'unverified: Start runs an ACP greeting probe; sign in locally as host service user if required'), observedAt: Date.now(), + workspaces: [setup.workspace], profiles: ['default'], readiness: setup.conversation ? 'Conversation Start blocked: host-owned ACP subtree containment required; relay/auth/model unverified' : setup.mode === 'fixture' ? 'fixture-only' : (state.phase === 'running' && state.actual?.evidence ? 'ACP model acknowledged + same-session response (not provider attestation or Buzz relay agent)' : 'unverified: Start runs an ACP greeting probe; sign in locally as host service user if required'), observedAt: Date.now(), }); } let closing = false; let queue = Promise.resolve(); let initialized = false; let client: ReturnType; - try { client = connect(url,setup.ownerSecret,m => { if (initialized) queue = queue.then(() => handle(m)).catch(() => { state.phase = 'quarantined'; save(); }); }); } + try { client = connect(url,setup.ownerSecret,m => { + if (!initialized) return; + // Cancellation is signalled outside the serialized mutation queue. Admission is + // still exact-authority/revision and never bypasses durable receipt processing. + if (!closing && m.type === 'stop' && m.host === setup.host && m.agent === agent && m.revision === state.revision && state.phase === 'transitioning' && state.actual && !Object.hasOwn(state.operations, m.id) && Object.keys(m.body).length === 0) acp?.cancel(); + queue = queue.then(() => handle(m)).catch(() => { state.phase = 'quarantined'; save(); }); + }); } catch (e) { rmdirSync(lock); throw e; } const publish = (m: Message) => { try { client.send(m); } catch { /* Durable outbox retained for reconnect/restart. */ } }; async function stopOwned() { @@ -82,6 +90,11 @@ export async function host(directory: string, url: string) { if (realpathSync(setup.workspace) !== setup.workspace || !statSync(setup.workspace).isDirectory()) throw Error('Workspace changed'); const launch: AgentLaunch | undefined = setup.mode === 'fixture' ? undefined : { executable: setup.runner, args: setup.args, workspace: state.selected.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: text(setup.databricksHost), model: state.selected.model }; if (launch) prepareAgent(launch); // Prerequisite failure preserves stopped state. + if (setup.conversation) { + if (!launch) throw Error('Conversation requires a Buzz Agent harness setup'); + prepareConversation(setup.conversation, launch, setup.agentSecret, state.binding.owner); + requireConversationContainment(); // Fail before journaling a run or spawning anything. + } state.actual = { selection: structuredClone(state.selected), executableHash: createHash('sha256').update(readFileSync(setup.runner)).digest('hex'), run: m.id }; state.phase = 'transitioning'; save(); if (setup.mode === 'fixture') { @@ -94,6 +107,7 @@ export async function host(directory: string, url: string) { try { catalog = await session.catalog(); state.actual.evidence = await session.verify(); + if (!session.healthy) throw Error('ACP session failed before running commit'); } catch (e) { catalog = { state: 'failed', authentication: 'unverified' }; await stopOwned(); state.phase = 'stopped'; state.actual = null; @@ -127,7 +141,7 @@ export async function host(directory: string, url: string) { },2000); return { agent, async close() { if (closing) return; closing = true; - client.close(); clearInterval(heartbeat); await queue; + client.close(); clearInterval(heartbeat); acp?.cancel(); await queue; if (owned) { state.phase = 'transitioning'; save(); try { await stopOwned(); state.phase = 'stopped'; state.actual = null; } diff --git a/beehive/src/owned.ts b/beehive/src/owned.ts index 6ced27ca02..b179b8fd86 100644 --- a/beehive/src/owned.ts +++ b/beehive/src/owned.ts @@ -36,7 +36,13 @@ export function spawnOwned(executable: string, args: readonly string[], cwd: str await new Promise((resolve, reject) => child.send({ type: 'stop' }, error => error ? reject(Error('Owned Stop channel failed')) : resolve())); for (let i = 0; i < 100; i++) { try { process.kill(-pid, 0); } - catch (e) { if ((e as NodeJS.ErrnoException).code === 'ESRCH') return; throw e; } + catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return; + // macOS may transiently deny probing a group while SIGKILL reaps it. + // EPERM is NOT absence: keep polling, then quarantine if never ESRCH. + if (code !== 'EPERM') throw e; + } await delay(25); } throw Error('Owned process group did not exit; quarantined'); diff --git a/beehive/src/supervisor.ts b/beehive/src/supervisor.ts index 1a8afbeddd..12cf8b0725 100644 --- a/beehive/src/supervisor.ts +++ b/beehive/src/supervisor.ts @@ -4,18 +4,32 @@ import { spawn } from 'node:child_process'; if (!process.send) throw Error('Supervisor requires a private IPC channel'); let launched = false; const keepalive = setInterval(() => {}, 1000); +let stopping = false; +// Retain group identity through graceful shutdown, even if a runner ignores TERM. +process.on('SIGTERM', () => {}); +function stopGroup() { + if (stopping) return; + stopping = true; + process.kill(-process.pid, 'SIGTERM'); + // Escalation comes from the STILL LIVING group leader, never a recovered PGID. + setTimeout(() => process.kill(-process.pid, 'SIGKILL'), 500); +} +function notify(type: string) { + if (!process.connected) { stopGroup(); return; } + process.send?.({ type }, (error: Error | null) => { if (error) stopGroup(); }); +} process.on('message', (message: unknown) => { const m = message as { type?: string; executable?: string; args?: string[]; cwd?: string; env?: NodeJS.ProcessEnv }; if (m.type === 'stop') { // The group leader signals ITS OWN group: never a recovered or reused numeric identity. - process.kill(-process.pid, 'SIGTERM'); + stopGroup(); } else if (m.type === 'launch' && !launched && m.executable) { launched = true; const runner = spawn(m.executable, m.args ?? [], { cwd: m.cwd, env: m.env, stdio: ['inherit', 'inherit', 'inherit'] }); - runner.once('spawn', () => process.send?.({ type: 'spawned' })); - runner.once('error', () => process.send?.({ type: 'runner-error' })); - runner.once('exit', () => process.send?.({ type: 'runner-exit' })); + runner.once('spawn', () => notify('spawned')); + runner.once('error', () => notify('runner-error')); + runner.once('exit', () => notify('runner-exit')); } }); // Parent loss tears down the known group, without interpreting a persisted PID. -process.once('disconnect', () => { clearInterval(keepalive); process.kill(-process.pid, 'SIGTERM'); }); +process.once('disconnect', () => { clearInterval(keepalive); stopGroup(); }); diff --git a/beehive/test/acp-fixture.ts b/beehive/test/acp-fixture.ts index 88aca365ad..7b9e676f1c 100644 --- a/beehive/test/acp-fixture.ts +++ b/beehive/test/acp-fixture.ts @@ -1,5 +1,7 @@ /** Deterministic EXTERNAL ACP fixture: never calls a provider or reads credentials. */ import { createInterface } from 'node:readline'; +import { writeFileSync } from 'node:fs'; +import { setTimeout as delay } from 'node:timers/promises'; const mode = process.argv[2] ?? 'ok'; let selected = ''; const send = (v: unknown) => process.stdout.write(JSON.stringify(v) + '\n'); @@ -18,8 +20,14 @@ for await (const line of createInterface({ input: process.stdin })) { result = { sessionId, modelId: mode === 'wrong-model' ? 'other-model' : selected }; } else if (m.method === 'session/prompt') { if (selected !== process.env.BUZZ_AGENT_MODEL || m.params.sessionId !== sessionId || process.env.DATABRICKS_TOKEN || process.env.BUZZ_PRIVATE_KEY || process.env.BUZZ_AGENT_PROVIDER !== 'databricks_v2') process.exit(8); - send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: mode === 'wrong-session' ? 'other-session' : sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Fixture greeting, not live model proof.' } } } }); + if (mode === 'delayed') { writeFileSync('prompt-started', '1'); await delay(4000); } + const update = { jsonrpc: '2.0', method: 'session/update', params: { sessionId: mode === 'wrong-session' ? 'other-session' : sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Fixture greeting, not live model proof.' } } } }; result = { stopReason: mode === 'cancelled' ? 'cancelled' : 'end_turn' }; + if (mode === 'bad-tail') { + process.stdout.write(JSON.stringify(update) + '\n' + JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\nnot-json\n'); + continue; + } + send(update); } else process.exit(9); send({ jsonrpc: '2.0', id: m.id, result }); } diff --git a/beehive/test/acp.test.ts b/beehive/test/acp.test.ts index 8f94e543ff..1359d537c9 100644 --- a/beehive/test/acp.test.ts +++ b/beehive/test/acp.test.ts @@ -26,7 +26,7 @@ test('external ACP boundary: snapshot, catalog provenance, exact-model same-sess assert.equal(Object.hasOwn(prepared.env, 'DATABRICKS_TOKEN'), false); assert.equal(Object.hasOwn(prepared.env, 'BUZZ_PRIVATE_KEY'), false); assert.throws(() => prepareAgent({ ...plan(dir), databricksHost: 'https://user:secret@example.com' })); - for (const mode of ['ok', 'empty', 'filtered', 'wrong-model', 'wrong-session', 'cancelled', 'reject', 'malformed', 'timeout', 'flood']) { + for (const mode of ['ok', 'empty', 'filtered', 'wrong-model', 'wrong-session', 'cancelled', 'reject', 'malformed', 'timeout', 'flood', 'bad-tail']) { const s = new AgentSession(plan(dir, mode), 1500); try { if (['malformed', 'timeout', 'flood'].includes(mode)) { await assert.rejects(s.catalog()); continue; } @@ -34,7 +34,7 @@ test('external ACP boundary: snapshot, catalog provenance, exact-model same-sess assert.equal(catalog.authentication, 'unverified'); assert.equal(catalog.state, mode === 'empty' ? 'empty' : mode === 'filtered' ? 'filtered' : 'reported'); assert.ok(!JSON.stringify(catalog).includes('DO-NOT-RELAY')); - if (['wrong-model', 'wrong-session', 'cancelled', 'reject'].includes(mode)) { + if (['wrong-model', 'wrong-session', 'cancelled', 'reject', 'bad-tail'].includes(mode)) { await assert.rejects(s.verify(), e => e instanceof Error && !e.message.includes('DO-NOT-RELAY')); } else { const evidence = await s.verify(); diff --git a/beehive/test/cancellation.test.ts b/beehive/test/cancellation.test.ts new file mode 100644 index 0000000000..d3bebdc818 --- /dev/null +++ b/beehive/test/cancellation.test.ts @@ -0,0 +1,58 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { host } from '../src/host.ts'; +import { relay } from '../src/relay.ts'; +import { connect } from '../src/client.ts'; +import { message, newKey, publicKey, type Message } from '../src/protocol.ts'; +import { writePrivate } from '../src/storage.ts'; + +test('in-flight ACP Start is cancellable by authorized Stop and host close; malformed completion cannot commit running', async () => { + for (const action of ['stop', 'close', 'bad-tail']) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-cancel-'))); + const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); + writePrivate(join(dir, 'setup.json'), { host: 'cancel-host', ownerSecret: secret, agentSecret, runner: realpathSync(process.execPath), args: [resolve('test/acp-fixture.ts'), action === 'bad-tail' ? 'bad-tail' : 'delayed'], workspace: dir, mode: 'buzz-agent-databricks-v2', serviceHome: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid' }); + const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + const url = `ws://127.0.0.1:${address.port}`; + const h = await host(dir, url); const seen: Message[] = []; + const client = connect(url, secret, m => seen.push(m)); await client.ready; + async function until(predicate: () => boolean) { + for (let i = 0; i < 160; i++) { if (predicate()) return; await delay(25); } + throw Error('Missing cancellation evidence'); + } + const start = message('start', 'cancel-host', agent, 0); + const journal = () => JSON.parse(readFileSync(join(dir, 'journal.json'), 'utf8')); + try { + client.send(start); + if (action !== 'bad-tail') { + await until(() => existsSync(join(dir, 'prompt-started'))); + assert.equal(journal().phase, 'transitioning'); + if (action === 'stop') { + // A different agent/revision/body must not cancel an admitted Start. + client.send(message('stop', 'cancel-host', publicKey(newKey()), 0)); + client.send(message('stop', 'cancel-host', agent, 99)); + client.send(message('stop', 'cancel-host', agent, 0, { unexpected: true })); + await delay(100); assert.equal(journal().phase, 'transitioning'); + } + const began = Date.now(); + if (action === 'close') await h.close(); + else { + const stop = message('stop', 'cancel-host', agent, 0); client.send(stop); + await until(() => seen.some(m => m.type === 'receipt' && m.body.operation === stop.id)); + assert.equal(seen.find(m => m.type === 'receipt' && m.body.operation === stop.id)?.body.result, 'accepted'); + } + assert.ok(Date.now() - began < 2500, 'Stop/close must interrupt the four-second prompt, not wait for it'); + } else await until(() => seen.some(m => m.type === 'receipt' && m.body.operation === start.id)); + assert.notEqual(journal().operations[start.id].reply.body.result, 'accepted'); + assert.equal(journal().phase, 'stopped'); assert.equal(journal().actual, null); + assert.ok(!seen.some(m => m.type === 'inventory' && m.body.phase === 'running')); + } finally { + await h.close(); client.close(); for (const c of server.clients) c.terminate(); + await new Promise(resolve => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true }); + } + } +}); diff --git a/beehive/test/conversation.test.ts b/beehive/test/conversation.test.ts new file mode 100644 index 0000000000..4a47b078e4 --- /dev/null +++ b/beehive/test/conversation.test.ts @@ -0,0 +1,91 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { prepareConversation, conversationSummary } from '../src/conversation.ts'; +import { host } from '../src/host.ts'; +import { relay } from '../src/relay.ts'; +import { connect } from '../src/client.ts'; +import { message, newKey, publicKey, type Message } from '../src/protocol.ts'; +import { writePrivate } from '../src/storage.ts'; + +const model = 'databricks-claude-haiku-4-5'; +const executable = realpathSync(process.execPath); +function agent(dir: string) { return { executable, args: [resolve('test/acp-fixture.ts')], workspace: dir, home: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid', model }; } + +test('external buzz-acp launch contract preserves identity and rejects lossy/unsafe transport inputs', () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-conversation-'))); + try { + const secret = newKey(); const owner = publicKey(newKey()); + const setup = { executable, relay: 'wss://conversation.invalid', authTag: 'fixture-opaque-credential' }; + const p = prepareConversation(setup, agent(dir), secret, owner); + assert.equal(p.agentPublicKey, publicKey(secret)); + assert.equal(p.env.BUZZ_PRIVATE_KEY, secret); + assert.equal(p.env.BUZZ_ACP_AGENT_OWNER, owner); + assert.equal(p.env.BUZZ_AUTH_TAG, setup.authTag); + assert.equal(p.env.BUZZ_ACP_MODEL, model); + assert.equal(p.env.BUZZ_AGENT_MODEL, model); + assert.equal(p.env.BUZZ_ACP_RESPOND_TO, 'owner-only'); + assert.equal(p.env.BUZZ_ACP_AGENT_COMMAND, executable); + assert.equal(p.env.BUZZ_ACP_AGENT_ARGS, agent(dir).args.join(',')); + assert.equal(p.env.BUZZ_RELAY_URL, 'wss://conversation.invalid/'); + assert.equal(Object.hasOwn(p.env, 'DATABRICKS_TOKEN'), false); + assert.equal(Object.hasOwn(p.env, 'BUZZ_ACP_REQUIRED_MODEL'), false); + assert.equal(Object.isFrozen(p.env), true); + const second = prepareConversation(setup, agent(dir), secret, owner); + assert.equal(second.agentPublicKey, p.agentPublicKey); + for (const relay of ['ws://example.com', 'wss://user:password@example.com', 'https://example.com', 'wss://example.com/?secret=x']) { + assert.throws(() => prepareConversation({ ...setup, relay }, agent(dir), secret, owner)); + } + for (const args of [['a,b'], [''], ['a\0b']]) assert.throws(() => prepareConversation(setup, { ...agent(dir), args }, secret, owner)); + const summary = JSON.stringify(conversationSummary(setup)); + for (const value of [secret, setup.authTag, executable]) assert.ok(!summary.includes(value)); + assert.match(summary, /unverified/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('real management WS rejects unsafe conversation Start before spawn; Save/Stop and key binding survive', async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-conversation-host-'))); + const ownerSecret = newKey(); const agentSecret = newKey(); const key = publicKey(agentSecret); + const setup = { host: 'conversation-host', ownerSecret, agentSecret, runner: executable, args: agent(dir).args, workspace: dir, mode: 'buzz-agent-databricks-v2', serviceHome: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid', conversation: { executable, relay: 'wss://conversation.invalid', authTag: 'fixture-attestation' } }; + writePrivate(join(dir, 'setup.json'), setup); + const server = await relay(0, publicKey(ownerSecret), join(dir, 'relay.json')); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + const url = `ws://127.0.0.1:${address.port}`; + const h = await host(dir, url); const seen: Message[] = []; + const client = connect(url, ownerSecret, m => seen.push(m)); await client.ready; + async function request(type: 'start' | 'save' | 'stop', revision: number, body = {}) { + const m = message(type, setup.host, key, revision, body); client.send(m); + for (let i = 0; i < 160; i++) { + const r = seen.find(r => r.type === 'receipt' && r.body.operation === m.id); + if (r) return r; + await delay(25); + } + throw Error('No management receipt'); + } + try { + const start = await request('start', 0); + assert.match(String(start.body.result), /Conversation Start unavailable.*No process started/); + const state = JSON.parse(readFileSync(join(dir, 'journal.json'), 'utf8')); + assert.equal(state.phase, 'stopped'); assert.equal(state.actual, null); assert.equal(state.revision, 0); + assert.equal((await request('save', 0, { model, workspace: dir, profile: 'default' })).body.result, 'saved; running configuration unchanged'); + assert.equal((await request('stop', 1)).body.result, 'accepted'); + assert.equal(JSON.parse(readFileSync(join(dir, 'setup.json'), 'utf8')).agentSecret, agentSecret); + assert.ok(!JSON.stringify(seen).includes('fixture-attestation')); + assert.ok(!JSON.stringify(seen).includes(agentSecret)); + const cli = spawnSync(process.execPath, ['src/cli.ts', 'conversation-setup', dir], { encoding: 'utf8', timeout: 3000 }); + assert.notEqual(cli.status, 0); assert.ok(existsSync(join(dir, 'host.lock'))); + } finally { + await h.close(); client.close(); for (const c of server.clients) c.terminate(); + await new Promise(resolve => server.close(() => resolve())); + } + try { + // Input/preflight failure unwinds only this invocation's local exclusion. + const cli = spawnSync(process.execPath, ['src/cli.ts', 'conversation-setup', dir], { input: '/nonexistent\n', encoding: 'utf8', timeout: 3000 }); + assert.notEqual(cli.status, 0); assert.equal(existsSync(join(dir, 'host.lock')), false); + assert.equal(JSON.parse(readFileSync(join(dir, 'setup.json'), 'utf8')).agentSecret, agentSecret); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/beehive/test/disconnect.test.ts b/beehive/test/disconnect.test.ts new file mode 100644 index 0000000000..5d6dff2ebf --- /dev/null +++ b/beehive/test/disconnect.test.ts @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { spawnOwned } from '../src/owned.ts'; + +test('parent IPC loss retains anchor until resistant descendants are forcibly stopped', async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-disconnect-'))); + const owned = spawnOwned(process.execPath, [resolve('test/orphan-fixture.ts'), 'resist'], dir, { PATH: '/usr/bin:/bin' }); + owned.child.stdout.resume(); owned.child.stderr.resume(); + try { + await owned.ready; + for (let i = 0; i < 100 && !existsSync(join(dir, 'resistant.ready')); i++) await delay(10); + assert.ok(existsSync(join(dir, 'resistant.ready'))); + const descendant = Number(readFileSync(join(dir, 'descendant.pid'), 'utf8')); + owned.child.disconnect(); // Simulates host loss, not a recovered-PID cleanup operation. + let absent = false; + for (let i = 0; i < 150; i++) { + try { process.kill(-owned.child.pid!, 0); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') { absent = true; break; } } + await delay(20); + } + assert.ok(absent, 'IPC loss must not destroy the anchor before resistant group teardown'); + assert.equal(readFileSync(join(dir, 'term-seen'), 'utf8'), '1'); + assert.throws(() => process.kill(descendant, 0), (e: unknown) => (e as NodeJS.ErrnoException).code === 'ESRCH'); + } finally { + if (owned.child.connected) await owned.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/beehive/test/orphan-fixture.ts b/beehive/test/orphan-fixture.ts index 6eaef3fc37..8560ab319c 100644 --- a/beehive/test/orphan-fixture.ts +++ b/beehive/test/orphan-fixture.ts @@ -2,9 +2,16 @@ import { spawn } from 'node:child_process'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; -if (process.argv[2] === 'child') setInterval(() => {}, 1000); +if (process.argv[2] === 'child') { + if (process.argv[3] === 'resist') { + process.on('SIGTERM', () => writeFileSync(join(process.cwd(), 'term-seen'), '1')); + writeFileSync(join(process.cwd(), 'resistant.ready'), '1'); + } + setInterval(() => {}, 1000); + setTimeout(() => process.exit(0), 15_000); // Bound failed regression runs without numeric cleanup kills. +} else { - const child = spawn(process.execPath, [import.meta.filename, 'child'], { stdio: 'ignore' }); + const child = spawn(process.execPath, [import.meta.filename, 'child', process.argv[2] ?? 'cooperate'], { stdio: 'ignore' }); writeFileSync(join(process.cwd(), 'descendant.pid'), String(child.pid)); writeFileSync(join(process.cwd(), 'anchor.pid'), String(process.ppid)); child.unref(); diff --git a/beehive/test/slice.test.ts b/beehive/test/slice.test.ts index 826159957b..209ad7d868 100644 --- a/beehive/test/slice.test.ts +++ b/beehive/test/slice.test.ts @@ -96,11 +96,11 @@ test('real websocket relay, external runner, durable retry, UI close/reopen, hos }); test('quarantined leader exit retains Stop and close teardown for owned descendants', async () => { - for (const action of ['stop', 'close']) { + for (const action of ['stop', 'close']) for (const behavior of ['cooperate', 'resist']) { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-orphan-'))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); const hd = join(dir, 'host'); mkdirSync(hd); - writePrivate(join(hd, 'setup.json'), { host: 'orphan-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/orphan-fixture.ts')], workspace: dir, mode: 'fixture' }); + writePrivate(join(hd, 'setup.json'), { host: 'orphan-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/orphan-fixture.ts'), behavior], workspace: dir, mode: 'fixture' }); const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); const url = `ws://127.0.0.1:${address.port}`; @@ -122,6 +122,7 @@ test('quarantined leader exit retains Stop and close teardown for owned descenda const receipt = await wait(m => m.type === 'receipt' && m.body.operation === stop.id); assert.equal(receipt.body.result, 'accepted'); } else await h.close(); + if (behavior === 'resist') assert.equal(readFileSync(join(dir, 'term-seen'), 'utf8'), '1'); assert.throws(() => process.kill(pid, 0), (e: unknown) => (e as NodeJS.ErrnoException).code === 'ESRCH'); } finally { await h.close(); ui.close(); for (const c of server.clients) c.terminate();