fix(beehive): preserve teardown ownership and cancel ACP startup

Add reproducible standalone dependencies and a source-grounded external conversation launch plan, gated until ACP subtree containment is implemented.

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 22:47:45 -04:00
parent 6bd8594795
commit 9ff9c6cf16
17 changed files with 628 additions and 163 deletions

View File

@@ -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 <existing-host-dir>` 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 <host-dir>` 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 <loganj@squareup.com> 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
<loganj@squareup.com> 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.

View File

@@ -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 <host-name>`, `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 |

View File

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

83
beehive/pnpm-lock.yaml generated Normal file
View File

@@ -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: {}

View File

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

View File

@@ -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 <new-directory> Create a NEW local owner identity
setup <new-host-directory> <identity-file> Guided local harness + key setup
auth-info <host-directory> Print exact local sign-in context (no login)
conversation-setup <host-directory> Attach external buzz-acp to EXISTING identity (Start gated)
relay <port> <owner-public-key> <log-file> Dedicated ciphertext relay
host <host-directory> <ws://127.0.0.1:port> Persistent foreground host
tui <identity-file> <ws://127.0.0.1:port> 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');

View File

@@ -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<typeof prepareConversation>;
/**
* 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' };
}

View File

@@ -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<string, { fingerprint: string; reply: Message }>; 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<typeof connect>;
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; }

View File

@@ -36,7 +36,13 @@ export function spawnOwned(executable: string, args: readonly string[], cwd: str
await new Promise<void>((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');

View File

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

View File

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

View File

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

View File

@@ -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<void>(resolve => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true });
}
}
});

View File

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

View File

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

View File

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

View File

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