diff --git a/beehive/HOST_SETTINGS_BUILD.md b/beehive/HOST_SETTINGS_BUILD.md index b6eb0dcdb8..bc6dba3a1f 100644 --- a/beehive/HOST_SETTINGS_BUILD.md +++ b/beehive/HOST_SETTINGS_BUILD.md @@ -39,3 +39,42 @@ External evidence: `/Users/loganj/.buzz/artifacts/beehive-host-settings-build-fb ## Publication/package boundary Do not claim installed/delivered/live credentials verified. Only task files should be committed on `feat/beehive-opentui-57f9c8fa`; no push/PR/merge/default branch or process mutation. Continue missing functionality and narrow credential/process review before delivery. Export exact accepted commit including all new `beehive/src/*.ts`; use the existing package-local lock/runtime recipe. Each eventual occupied `opentui-current` is replaced with one current export, preserving settings/evidence/processes and no retained rollback copy. Installed synthetic smoke must use the extended loader, not fresh HOME alone. + +## Continuation 0394e318 (still incomplete; do not publish) + +Recovered 78f0c373 without restarting its implementation. This continuation adds: + +- Shared `protocol.ts::serializeManagement` / `MANAGEMENT_WIRE_BYTES` for both encrypted transports. The wire ceiling remains exactly 32768 UTF-8 plaintext bytes; diagnostic envelope and NIP-59 consumers use that same constant. +- `host.ts` validates each **actual projected slot inventory** before changing any binding map or loaded revision, at startup and heartbeat adoption. The inventory includes retained assignment/configurations/history/actual run and repeated model/workspace fields. Adoption reserves 8192 bytes (the existing configuration budget) below the wire ceiling. Oversized saved catalogs remain public saved revisions, not loaded revisions; prior reports continue. Startup with an oversized retained catalog refuses and releases its own lock after successful teardown. +- Real long-workspace/200-character-model regression: a fitting catalog round-trips through both codecs, a 19-runtime catalog is saved but not adopted, prior inventory remains unchanged and publishable, and reopen refuses it. Exact-limit/multibyte-overflow tests exercise both producers. +- Synthetic authenticated service fixture: unauthenticated Stop does not invoke teardown; concurrent verified Stops share one failed teardown promise; subsequent Stop/status remain Unknown; reservations/host lock remain; duplicate Start refuses. No process/key deletion is authorized by failure. +- Broker assertion now reports mode plus underlying verification error, without changing its checks or 2000 ms deadline. + +### Seven-point delivery status + +1. **Registration/UI:** preserved prior implementation and evidence; unchanged here. +2. **Databricks v2:** NOT completed. No native helper, OS-token custody/refresh or model bridge was built. Bounded source check confirmed `crates/buzz-agent/src/auth.rs::PkceOAuthTokenSource::new_with_http_timeout` immediately derives/reads its file cache and `save` writes it. A Beehive-only custody seam at that owner is necessary; passing a fresh directory is not OS token storage. Do not run the existing helper as a substitute. No helper packaging recipe can be claimed until a working helper exists. Existing normal build owner is `cargo build --release -p buzz-agent` with pinned Rust 1.95.0; future bridge must use an isolated target output and include the built helper in export. +3. **Harness discovery:** still Buzz Agent PATH-only; Desktop-equivalent adapter/CLI/version rules remain required. Prior-art plan has exact source owners and current Pi correction. No discovery rewrite made. +4. **Models/effort:** existing OpenAI listing/custom model retained; Databricks models and supported harness-specific effort selection/launch propagation remain required. No new end-to-end Databricks/effort evidence. +5. **Relay:** URL/actual connection state retained; optional metadata name capture still missing. No network fetch added. +6. **Wire:** pre-adoption actual representation gate and shared producer/consumer ceiling added and tested. Residual: 8 KiB reserve is conservative headroom, NOT proof that arbitrarily growing assignment/run history fits forever. It also applies to legacy startup inventories; unusually large existing inventories can now refuse startup even if below 32 KiB. Assess this compatibility boundary before release. Existing send failure handling does not constitute a future-lifecycle fit proof. +7. **Lifecycle/failures:** new service teardown/unauthorized-stop coverage passes. Broader credential helper late-completion/cancellation/failure matrix remains incomplete. Existing active-run immutability fixture still passes. + +### Validation and failure assessment + +Artifacts: `/Users/loganj/.buzz/artifacts/beehive-host-settings-continuation-0394e318/`. + +- Node 24.15.0 direct TypeScript `--noEmit`: passed (`typecheck-final.log`). +- Focused host settings: 6/6 (`settings-fixed.log`). Original test failure retained in `settings.log`: JSON round-trip omits an undefined `move` property; fixture now compares serialized representation, not an in-memory undefined key. +- Service: 3/3 (`service-failures.log`). +- Broker diagnostic run: 1/1, all existing modes (`broker-diagnostic.log`). +- One complete final touched-package suite: **159 passed, 2 failed, 19 skipped**, 180 tests, 32.063s (`full-suite.log`). NOT green. No full-suite rerun, deadline inflation, serialization or assertion weakening. +- Broker now identifies failing `ok` stimulus: `Conversation response timed out; check relay admission and local sign-in`. Actual delta inspection from 4bdb5f09 to 78f0c373 found no `broker.ts` diff; `acp.ts` adds resolvedProviderKey only under `if (plan.buzzProvider)`. This fixture has no buzzProvider and takes the legacy Databricks environment path. Thus this stimulus does not exercise the new provider-key launch branch. It still exercises shared supervisor/broker ownership; a targeted pass is not a root-cause diagnosis or release waiver. +- Additional final-suite failure: `conversation.test.ts:52`, first Start at line 75 received no management receipt within the existing polling bound. It uses the same synthetic legacy Databricks/conversation path, no buzzProvider. Causality is unestablished; no contention explanation is asserted and no flake-repair campaign was started. +- Reuse prior unchanged Bun renderer and synthetic PTY evidence; no new UI smoke claimed. + +### Narrow review authority owners / remaining risk + +Credential authority: `credential-store.ts`, `native-credentials.ts`, `credential-helper.ts`, `manager-credential.ts`, `settings-credentials.ts`, `manager-credential-child.ts`; native OAuth authority will be `crates/buzz-agent/src/auth.rs`, not Desktop cache access. Process authority: `host-service.ts` exact capability/nonce/HMAC, `host.ts` loaded revision/Stop fences, `owned.ts`, `broker.ts`, `acp.ts`. Wire authority: `protocol.ts`, `nostr-codec.ts`, `host.ts::applySettings` and slot `validateBindings`. + +This is a bounded continuation handoff, not a genuine external/toolchain blocker and not completion of BUILD IT ALL. Native auth/discovery/effort/relay work remains unexhausted. No push/install/package publication, native build, real credential access, real relay/provider request, browser login, installed harness execution or existing host control. Both installed packages remain at the previously reported 4bdb5f09; they were not inspected or mutated here. diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 83499482ff..64969010c6 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -18,7 +18,7 @@ import { hash, semanticHash, selection, sameSelection, sameLaunchSelection, move import { installationSlotsAsync } from './slots.ts'; import { validateGenesis, type Genesis } from './assignment.ts'; import { connect, validateRelayURL } from './client.ts'; -import { digest, fields, message, object, publicKey, text, type Message } from './protocol.ts'; +import { digest, fields, serializeManagement, message, object, publicKey, text, type Message } from './protocol.ts'; import { readPrivate, writePrivate } from './storage.ts'; import { ConversationSession } from './broker.ts'; import { spawnOwned, type OwnedProcess } from './owned.ts'; @@ -140,12 +140,12 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri let owned: OwnedProcess | ConversationSession | undefined; let acp: AgentSession | ConversationSession | undefined; let catalog: Catalog | { state: 'not-probed' | 'failed'; authentication: 'unverified' } = { state: 'not-probed', authentication: 'unverified' }; - function inventory() { - const candidate = bindings[state.selected.harnessSetup?.id ?? setupId]; + function inventory(projectedBindings = bindings) { + const candidate = projectedBindings[state.selected.harnessSetup?.id ?? setupId]; const nextSetup = !retiredBindings[state.selected.harnessSetup?.id ?? setupId] && candidate && (!state.selected.harnessSetup || state.selected.harnessSetup.fingerprint === bindingFingerprint(candidate)) ? candidate : undefined; return message('inventory',setup.host,agent,state.revision, { defaultHarnessSetup: setupId, - harnessSetups: Object.entries(bindings).map(([id, value]) => ({ id, availability: retiredBindings[id] ? 'retired' : value.mode === 'diagnostic-acp' ? 'diagnostic-only' : 'available', ...(value.custom ? { label: value.custom.label, contract: value.custom.contract, authentication: 'unverified', ...(value.mode === 'diagnostic-acp' ? { reason: diagnosticReason } : {}) } : {}), fingerprint: bindingFingerprint(value), kind: value.mode, models: setupModels(value), workspaces: value.allowedWorkspaces ?? [value.workspace] })), + harnessSetups: Object.entries(projectedBindings).map(([id, value]) => ({ id, availability: retiredBindings[id] ? 'retired' : value.mode === 'diagnostic-acp' ? 'diagnostic-only' : 'available', ...(value.custom ? { label: value.custom.label, contract: value.custom.contract, authentication: 'unverified', ...(value.mode === 'diagnostic-acp' ? { reason: diagnosticReason } : {}) } : {}), fingerprint: bindingFingerprint(value), kind: value.mode, models: setupModels(value), workspaces: value.allowedWorkspaces ?? [value.workspace] })), configurations: configurations(state.configurations, state.selected), runHistory: Object.values(state.runs ?? {}).slice(-8).map(run => ({ run: run.run, configuration: run.selection.configuration, harnessSetup: run.harnessSetup, model: run.selection.model, workspace: run.selection.workspace, appliedInstructions: run.appliedInstructions, preparedInputHash: run.preparedInputHash })), move: state.move ? 'destination preflight pending; source still assigned' : undefined, assignmentChain: state.assignment.chain ?? [], assignedHost: state.assignment.assignedHost, genesis: state.assignment.genesis, executionAuthority: state.assignment.assignedHost === setup.host, phase: state.phase, selectedNext: state.selected, actualRun: state.actual, @@ -575,7 +575,13 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri Object.defineProperty(state.operations,m.id,{ value: { fingerprint, reply }, enumerable: true, configurable: true, writable: true }); state.outbox.push(reply); save(); publish(reply); publish(inventory()); } - return { agent, receive, replay, heartbeat() { + return { agent, receive, replay, validateBindings(projected: Record) { + // Check the real inventory, including retained state and duplicated selected + // models/workspaces, not a row-count estimate. Reserve 8 KiB (the existing + // configuration budget) for subsequent lifecycle/selection growth. Both + // transports still enforce the exact 32 KiB ceiling at every publication. + serializeManagement(inventory(projected), 8192); + }, heartbeat() { if (owned && (owned.exited || acp?.healthy === false) && state.phase === 'running') { state.phase = 'quarantined'; save(); } publish(inventory()); }, async close() { @@ -624,10 +630,11 @@ export async function host(directory: string, url: string, signal?: AbortSignal, if (candidate.revision < effectiveSettings.revision) throw Error('Settings revision regressed'); for (const collection of ['agents','providers','runtimes'] as const) for (const row of effectiveSettings[collection]) if (!candidate[collection].some(n => JSON.stringify(n) === JSON.stringify(row))) throw Error('Retained settings changed'); const additions = entries.map(entry => runtimeBindings(entry.setup,candidate)); + // All slots must fit before mutating ANY effective binding map/revision. + for (const [index, entry] of entries.entries()) slots.get(entry.agent)!.validateBindings({ ...entry.bindings, ...additions[index] }); for (const [index, entry] of entries.entries()) Object.assign(entry.bindings,additions[index]); effectiveSettings = candidate; }; - applySettings(effectiveSettings); reloadSettings = () => { try { const candidate = readSettings(directory); if (candidate.revision !== effectiveSettings.revision) applySettings(candidate); } catch { /* Saved invalid settings remain on disk for repair; prior effective catalog is retained. */ } @@ -667,6 +674,7 @@ export async function host(directory: string, url: string, signal?: AbortSignal, function hostKeyForMetadata() { return transport?.binding.host ?? entry.setup.host; } }, url, providerRead)); } + applySettings(effectiveSettings); const setup = entries[0]?.setup; const hostKey = transport?.binding.host ?? setup!.host; // Every slot is hydrated before dialing. Initial WS history may arrive in the diff --git a/beehive/src/nostr-codec.ts b/beehive/src/nostr-codec.ts index 31f7e65823..3a92d920f9 100644 --- a/beehive/src/nostr-codec.ts +++ b/beehive/src/nostr-codec.ts @@ -1,6 +1,6 @@ import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent, type Event } from 'nostr-tools/pure'; import { v2 } from 'nostr-tools/nip44'; -import { fields, object, parseMessage, type Message } from './protocol.ts'; +import { fields, object, parseMessage, serializeManagement, MANAGEMENT_WIRE_BYTES, type Message } from './protocol.ts'; const domain = 'beehive-management-v1'; /** Authenticated inner message; never infer the sender from the ephemeral outer key. */ @@ -10,8 +10,7 @@ export type AuthenticatedMessage = { sender: string; message: Message }; * operation identity remains Message.id, NOT the rumor, seal, or wrap event id. */ export function wrapManagement(message: Message, secret: Uint8Array, recipient: string, now = Math.floor(Date.now() / 1000)): Event { - const content = JSON.stringify(parseMessage(message)); - if (Buffer.byteLength(content) > 32768) throw Error('Management message exceeds bounded wire size'); + const content = serializeManagement(message); const rumor = { kind: 14, pubkey: getPublicKey(secret), created_at: now, tags: [['p', recipient], ['subject', domain]], content }; const seal = finalizeEvent({ kind: 13, created_at: now, tags: [], content: v2.encrypt(JSON.stringify({ ...rumor, id: getEventHash(rumor) }), v2.utils.getConversationKey(secret, recipient)) }, secret); const ephemeral = generateSecretKey(); @@ -32,7 +31,7 @@ export function unwrapManagement(value: unknown, secret: Uint8Array): Authentica if (seal.tags.length) throw Error('Unexpected seal tags'); const rumor = object(JSON.parse(v2.decrypt(seal.content, v2.utils.getConversationKey(secret, seal.pubkey)))); fields(rumor, ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content']); - if (rumor.pubkey !== seal.pubkey || rumor.kind !== 14 || typeof rumor.content !== 'string' || Buffer.byteLength(rumor.content) > 32768 || JSON.stringify(rumor.tags) !== JSON.stringify([['p', recipient], ['subject', domain]]) || rumor.id !== getEventHash(rumor as unknown as Event)) throw Error('Invalid management rumor'); + if (rumor.pubkey !== seal.pubkey || rumor.kind !== 14 || typeof rumor.content !== 'string' || Buffer.byteLength(rumor.content) > MANAGEMENT_WIRE_BYTES || JSON.stringify(rumor.tags) !== JSON.stringify([['p', recipient], ['subject', domain]]) || rumor.id !== getEventHash(rumor as unknown as Event)) throw Error('Invalid management rumor'); return { sender: seal.pubkey, message: parseMessage(JSON.parse(rumor.content)) }; } /** Tracer authority boundary. Host-to-host Move traffic is intentionally denied until diff --git a/beehive/src/protocol.ts b/beehive/src/protocol.ts index 41c88fa480..f70efdac87 100644 --- a/beehive/src/protocol.ts +++ b/beehive/src/protocol.ts @@ -28,6 +28,14 @@ export function parseMessage(value: unknown): Message { object(m.body); return m as Message; } +/** Plaintext ceiling shared by the diagnostic and NIP-59 transports. */ +export const MANAGEMENT_WIRE_BYTES = 32768; +/** Serialize exactly what transports carry; optional reserve is for adoption headroom. */ +export function serializeManagement(value: Message, reserveBytes = 0): string { + const content = JSON.stringify(parseMessage(value)); + if (!Number.isSafeInteger(reserveBytes) || reserveBytes < 0 || Buffer.byteLength(content) + reserveBytes > MANAGEMENT_WIRE_BYTES) throw Error('Management message exceeds bounded wire size'); + return content; +} export function digest(value: string): Buffer { return createHash('sha256').update(value).digest(); } export function publicKey(secret: string): string { return Buffer.from(schnorr.getPublicKey(secret)).toString('hex'); } export function newKey(): string { return Buffer.from(schnorr.utils.randomPrivateKey()).toString('hex'); } @@ -35,12 +43,11 @@ function bytes(e: Omit): string { return JSON.stringify([ function encryptionKey(secret: string): Buffer { return digest(`beehive-private-v1:${secret}`); } /** Encrypt before relay publication; the relay only receives the owner's public key. */ export function seal(message: Message, secret: string): Envelope { - parseMessage(message); - if (Buffer.byteLength(JSON.stringify(message)) > 32768) throw Error('Management message exceeds bounded wire size'); + const content = serializeManagement(message); const nonce = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', encryptionKey(secret), nonce); cipher.setAAD(Buffer.from('beehive-private-v1')); - const ciphertext = Buffer.concat([cipher.update(JSON.stringify(message)), cipher.final()]).toString('hex'); + const ciphertext = Buffer.concat([cipher.update(content), cipher.final()]).toString('hex'); const e = { v: 1 as const, owner: publicKey(secret), nonce: nonce.toString('hex'), ciphertext, tag: cipher.getAuthTag().toString('hex') }; return { ...e, signature: Buffer.from(schnorr.sign(digest(bytes(e)), secret)).toString('hex') }; } @@ -51,7 +58,7 @@ export function verifyEnvelope(value: unknown, owner: string): Envelope { for (const [key, length] of [['nonce',24],['tag',32],['signature',128]] as const) { if (typeof e[key] !== 'string' || e[key].length !== length || !/^[0-9a-f]+$/.test(e[key])) throw Error('Invalid envelope'); } - if (typeof e.ciphertext !== 'string' || e.ciphertext.length > 65536 || !/^(?:[0-9a-f]{2})+$/.test(e.ciphertext)) throw Error('Invalid ciphertext'); + if (typeof e.ciphertext !== 'string' || e.ciphertext.length > MANAGEMENT_WIRE_BYTES * 2 || !/^(?:[0-9a-f]{2})+$/.test(e.ciphertext)) throw Error('Invalid ciphertext'); const envelope = e as Envelope; if (!schnorr.verify(envelope.signature, digest(bytes(envelope)), owner)) throw Error('Invalid signature'); return envelope; diff --git a/beehive/test/broker.test.ts b/beehive/test/broker.test.ts index 4bce924e47..6bd5d741bb 100644 --- a/beehive/test/broker.test.ts +++ b/beehive/test/broker.test.ts @@ -28,7 +28,7 @@ test('broker enforces model, completion health, cancellation and owned teardown const r = await result; if (['ok', 'optional-ok', 'optional-reject'].includes(mode)) { if (mode === 'optional-reject') assert.equal(readFileSync(join(dir, 'config-error-forwarded'), 'utf8'), 'yes'); - assert.ok('value' in r); assert.equal(r.value.session, 'conversation-session'); + assert.ok('value' in r, `${mode}: ${'error' in r ? String(r.error) : 'missing evidence'}`); assert.equal(r.value.session, 'conversation-session'); for (let i = 0; i < 40 && !existsSync(join(dir, 'reverse-rpc')); i++) await delay(20); assert.equal(readFileSync(join(dir, 'reverse-rpc'), 'utf8'), 'ok'); } else assert.ok('error' in r, `${mode} must not commit evidence`); diff --git a/beehive/test/host-service.test.ts b/beehive/test/host-service.test.ts index 54e800b316..ad962bf6f8 100644 --- a/beehive/test/host-service.test.ts +++ b/beehive/test/host-service.test.ts @@ -1,10 +1,12 @@ +import { connect } from 'node:net'; +import { writePrivate } from '../src/storage.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, realpathSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { pathToFileURL } from 'node:url'; -import { startService, stopService, serviceStatus } from '../src/host-service.ts'; +import { startService, stopService, serviceStatus, serveHost } from '../src/host-service.ts'; test('detached actual host survives client return; verified duplicate/reopen/stop; stale locks never adopted', async t => { const root = realpathSync(mkdtempSync('/tmp/beehive-pairing-cli-service-')); @@ -42,3 +44,29 @@ test('startup failure does not report running or remove an unowned endpoint', as await assert.rejects(startService(root,pathToFileURL(entry)),/unknown/); assert.equal(existsSync(join(root,'service.sock')),true); }); + +test('failed owned teardown remains Unknown, retains reservations and never retries or accepts an unauthenticated Stop', async t => { + const root = realpathSync(mkdtempSync('/tmp/beehive-pairing-cli-teardown-')); + const instance = 'a'.repeat(32), token = 'b'.repeat(64); + mkdirSync(join(root,'service.lock'),{mode:0o700}); + mkdirSync(join(root,'host.lock'),{mode:0o700}); + writePrivate(join(root,'service.json'),{version:1,instance,token,socket:join(root,'service.sock')}); + let closes = 0; + const server = await serveHost(root,instance,async () => ({status: () => ({agents: 1, relay: 'disconnected'}), async close() { closes++; throw Error('synthetic containment failure'); }})); + t.after(async () => { await new Promise(resolve => server.close(() => resolve())); rmSync(root,{recursive:true,force:true}); }); + await new Promise((resolve,reject) => { + const socket = connect(join(root,'service.sock')); + socket.on('error',reject); socket.on('close',() => resolve()); + socket.on('connect',() => socket.end(JSON.stringify({request:{action:'stop',instance,nonce:'c'.repeat(32)},mac:'0'.repeat(64)})+'\n')); + }); + assert.equal(closes,0); + assert.equal((await serviceStatus(root)).state,'running'); + const results = await Promise.all([stopService(root,instance),stopService(root,instance)]); + assert.ok(results.every(r => r.state === 'unknown')); + assert.equal(closes,1,'one retained teardown promise, not repeated process control'); + assert.equal((await serviceStatus(root)).state,'unknown'); + assert.equal((await stopService(root,instance)).state,'unknown'); + assert.equal(closes,1); + await assert.rejects(startService(root),/unknown/); + for (const path of ['service.json','service.lock','host.lock','service.sock']) assert.ok(existsSync(join(root,path)),path); +}); diff --git a/beehive/test/host-settings.test.ts b/beehive/test/host-settings.test.ts index af5282bcc7..6d016ca458 100644 --- a/beehive/test/host-settings.test.ts +++ b/beehive/test/host-settings.test.ts @@ -1,3 +1,4 @@ +import { wrapManagement, unwrapManagement } from '../src/nostr-codec.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; @@ -10,7 +11,7 @@ import { registerAgent, agentNsec, addOpenAI } from '../src/settings-credentials import { readSettings, saveSettings, settingsId } from '../src/settings.ts'; import { profileFromEvents } from '../src/agent-profile.ts'; import { isolatedFileCredentials } from './isolated-file-credentials.ts'; -import { newKey, publicKey, message, type Message } from '../src/protocol.ts'; +import { newKey, publicKey, message, serializeManagement, seal, open, type Message } from '../src/protocol.ts'; import { openAIModels } from '../src/settings-models.ts'; import { host, provision, type Setup, bindingFingerprint } from '../src/host.ts'; import { createGenesis } from '../src/assignment.ts'; @@ -110,3 +111,50 @@ test('saved runtime reaches host Save/new Start while the prior active run remai await sleep(2100); assert.equal(running.settingsRevision,2,'invalid replacement retains effective catalog'); await running.close(); }); + +test('oversized projected inventory retains loaded catalog and serializable reports, including on reopen', async t => { + const root = fixture(t), owner = newKey(), agent = newKey(), hostKey = publicKey(newKey()); + // Long real paths repeat in each wire row; 100 short public records are not a wire budget. + const setup: Setup = { host: hostKey, ownerSecret: owner, agentSecret: agent, runner: realpathSync(process.execPath), args: [], workspace: root, allowedWorkspaces: [root, ...Array.from({length: 12}, (_, i) => join(root, `workspace-${i}-` + 'x'.repeat(120)))], serviceHome: root, configDirectory: root, mode: 'fixture' }; + provision(root,setup,createGenesis(publicKey(owner),publicKey(agent),hostKey)); + const reports: Message[] = []; + const transport = { binding: {host: hostKey, owner: publicKey(owner)}, validate() {}, connect() { return { ready: Promise.resolve(), send(m: Message) { serializeManagement(m); reports.push(m); }, close() {} }; } }; + const running = await host(root,'ws://127.0.0.1',undefined,transport); + t.after(() => running.close()); + let stored: string | null = null; + addOpenAI(root,'Fixture','synthetic',{read: () => stored, create(_r,v) { stored = v; }}); + const prior = readSettings(root); + const row = {id: settingsId(), name: 'Fitting', harness: 'buzz-agent' as const, executable: realpathSync(process.execPath), providerId: prior.providers[0]!.id, model: 'm'.repeat(200)}; + saveSettings(root,{...prior, runtimes: [row]},prior.revision); + await wait(() => running.settingsRevision === 2); + const fitting = reports.filter(m => m.type === 'inventory').at(-1)!; + assert.equal((fitting.body.harnessSetups as unknown[]).length,2); + // Both production codecs accept the actual fitting inventory. + assert.deepEqual(open(seal(fitting,owner),owner),JSON.parse(serializeManagement(fitting))); + assert.deepEqual(unwrapManagement(wrapManagement(fitting,Buffer.from(owner,'hex'),publicKey(agent)),Buffer.from(agent,'hex')).message,JSON.parse(serializeManagement(fitting))); + const saved = readSettings(root); + saveSettings(root,{...saved,runtimes: [...saved.runtimes,...Array.from({length: 18}, () => ({...row,id:settingsId()}))]},saved.revision); + assert.equal(readSettings(root).revision,3,'public save is retained, not falsely reported loaded'); + const before = reports.length; + await sleep(2100); + assert.equal(running.settingsRevision,2); + assert.ok(reports.length > before,'prior inventory continues to publish'); + assert.deepEqual(reports.filter(m => m.type === 'inventory').at(-1)!.body.harnessSetups,fitting.body.harnessSetups); + await running.close(); + await assert.rejects(host(root,'ws://127.0.0.1',undefined,transport),/bounded wire size/); + assert.equal(existsSync(join(root,'host.lock')),false,'failed startup releases only its own lock'); +}); + +test('shared management serializer preserves the exact UTF-8 wire limit for both producers', () => { + const secret = newKey(), recipient = newKey(); + const m = message('inventory',publicKey(secret),publicKey(recipient),0,{ padding: '' }); + const overhead = Buffer.byteLength(JSON.stringify(m)); + m.body.padding = 'a'.repeat(32768-overhead); + assert.equal(Buffer.byteLength(serializeManagement(m)),32768); + assert.deepEqual(open(seal(m,secret),secret),m); + assert.deepEqual(unwrapManagement(wrapManagement(m,Buffer.from(secret,'hex'),publicKey(recipient)),Buffer.from(recipient,'hex')).message,m); + m.body.padding += 'é'; + assert.throws(() => serializeManagement(m),/bounded wire size/); + assert.throws(() => seal(m,secret),/bounded wire size/); + assert.throws(() => wrapManagement(m,Buffer.from(secret,'hex'),publicKey(recipient)),/bounded wire size/); +});