mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
83
beehive/pnpm-lock.yaml
generated
Normal 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: {}
|
||||
@@ -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 = ''; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
67
beehive/src/conversation.ts
Normal file
67
beehive/src/conversation.ts
Normal 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' };
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
58
beehive/test/cancellation.test.ts
Normal file
58
beehive/test/cancellation.test.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
91
beehive/test/conversation.test.ts
Normal file
91
beehive/test/conversation.test.ts
Normal 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 }); }
|
||||
});
|
||||
32
beehive/test/disconnect.test.ts
Normal file
32
beehive/test/disconnect.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user